API conventions
The rules every endpoint follows. Base URL, versioning, errors, pagination, idempotency, rate limits and data formats.
Everything on this page holds for every endpoint of the Admin API and the Storefront API. The reference pages only document what is specific to each operation.
Base URL and versioning
https://api.mobiari.com/v1| API | Paths | Who calls it | OpenAPI document |
|---|---|---|---|
| Admin | /v1/... | The admin and mobile apps, your integrations | admin.json |
| Storefront | /v1/storefront-api/... | Storefront themes, shoppers' browsers | storefront.json |
Point Postman, an SDK generator or a mock server at the OpenAPI documents;
their servers entry already includes /v1.
Within /v1, changes are additive only. We may, without notice:
- add endpoints, optional request fields and query parameters;
- add fields to responses;
- add values to response enums;
- add error
codes for new failure cases.
Anything else is breaking and ships as a new version or through a deprecation cycle: removing or renaming a path, field or error code, changing a type, making an optional field required, or changing what a status code means. Every change is in the API changelog.
Write clients that survive additive changes:
- ignore response fields you don't know;
- treat an unknown enum value as "other", never as an error;
- branch on
code, not on the error message.
Documented endpoints
An endpoint is covered by this contract once it appears in the API
reference. Areas still being documented already answer under /v1 with
the same error shape, idempotency and rate limits, but some of their
responses keep older shapes (such as lists without the Page envelope)
until they move into the reference.
Authentication
Send a bearer token on every request that needs one:
Authorization: Bearer <token>The token is either a session access token from POST /v1/auth/login
(short-lived, renewed with a refresh token) or a store API token
(mobi_st_…) you create in a store for a script or integration. Both can do at most
what the user behind them can, and a store API token works only for its store. See Authentication.
Most Storefront API operations are public; each operation in the reference lists its requirements.
Errors
Every error response, on every endpoint, has one shape:
{
"error": "Cannot merge a customer into itself",
"code": "same_customer",
"details": { "...": "optional, depends on code" }
}| Field | Meaning |
|---|---|
code | Stable, snake_case. Branch on this. Renaming a code is a breaking change. |
error | Human-readable message. Wording can change at any time; show it, don't parse it. |
details | Optional structured context. Its shape is fixed per code. |
When no specific code applies, code is derived from the status. The
codes you will meet most:
| Status | code | When |
|---|---|---|
| 400 | bad_request | Malformed request. |
| 400 | invalid_cursor, invalid_limit, invalid_offset | Bad pagination parameters. |
| 401 | unauthorized | Missing, expired or revoked credentials. |
| 401 | invalid_refresh_token, refresh_token_reused | Refresh failed; sign in again. |
| 403 | forbidden, missing_permission | Valid credentials, but the user or token scope lacks the permission. |
| 404 | not_found | The resource doesn't exist, or isn't in this store. |
| 404 | route_not_found | No such endpoint under /v1. Check the path and method. |
| 409 | conflict | The resource's state doesn't allow this. |
| 409 | idempotency_request_in_progress | See Idempotency. |
| 413 | payload_too_large | Body over the endpoint's limit. |
| 422 | validation_failed | Field errors in details.fields. |
| 422 | idempotency_key_reused | See Idempotency. |
| 429 | rate_limited | See Rate limits. |
| 500 | internal_error | Our fault. Safe to retry idempotent requests. |
Operations list the specific codes they can return in the reference.
A validation failure names each offending field:
{
"error": "Invalid phone",
"code": "validation_failed",
"details": { "fields": { "phone": "at most 20 digits" } }
}Failed responses carry an x-trace-id header. Include it when you report
a problem; it points straight at the request in our logs.
Pagination
Every endpoint that returns a collection returns a Page:
{
"data": [{ "id": "…" }, { "id": "…" }],
"has_more": true,
"next_cursor": "bzo1MA",
"total": 812
}| Field | Meaning |
|---|---|
data | This page's items, in the order the endpoint documents. |
has_more | Whether another page follows. |
next_cursor | Pass as cursor to get the next page. null on the last page. |
total | Count of all matching items. Only present with include_total=true. |
| Query parameter | Meaning |
|---|---|
limit | Page size, 1 to 200. Default 50. |
cursor | next_cursor from the previous page. Omit for the first page. |
offset | Items to skip, for UIs that jump to page N. Ignored when cursor is set. |
include_total | true to get total. Costs an extra query; leave it off when you just walk pages. |
Cursors are opaque strings: store and send them back as they are, never
build or decode them. To read a whole list, follow next_cursor until
has_more is false. Cursor paging stays correct when rows are added
while you walk; offsets can skip or repeat rows.
Bounded lists that belong to a resource (an order's line items, a customer's addresses) are plain arrays inside that resource, not pages.
Walking every page with curl
url="https://api.mobiari.com/v1/stores/$STORE_ID/customers/duplicates?limit=200"
cursor=""
while :; do
page=$(curl -sf "$url${cursor:+&cursor=$cursor}" -H "Authorization: Bearer $BP_TOKEN")
echo "$page" | jq -c '.data[]'
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -z "$cursor" ] && break
doneWalking every page in TypeScript
type Page<T> = {
data: T[];
has_more: boolean;
next_cursor: string | null;
total?: number;
};
const BASE = 'https://api.mobiari.com/v1';
async function* listAll<T>(path: string, token: string, query: Record<string, string> = {}) {
let cursor: string | null = null;
do {
const url = new URL(BASE + path);
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
url.searchParams.set('limit', '200');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) {
const err = await res.json();
throw new Error(`${res.status} ${err.code}: ${err.error}`);
}
const page: Page<T> = await res.json();
yield* page.data;
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
}
for await (const group of listAll(`/stores/${storeId}/customers/duplicates`, token)) {
console.log(group);
}Idempotency
A POST that times out may or may not have happened. Send an
Idempotency-Key header and retry with the same key to get the
original response instead of a second order, refund or message:
curl -X POST "https://api.mobiari.com/v1/stores/$STORE_ID/customers/$CUSTOMER_ID/merge" \
-H "Authorization: Bearer $BP_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f0c1c52-2a3e-4d8e-9d59-0d3c2f6f4b1e" \
-d '{"from_customer_id": "…"}'- Use a fresh random value (a UUID) per logical operation, at most 255 characters. Keys are scoped to your credentials.
- A retry with the same key, path and body within 24 hours returns the
stored status and body, with
Idempotent-Replayed: true. - A retry while the first request is still running gets 409
idempotency_request_in_progress. Wait briefly and retry. - The same key with a different body or path gets 422
idempotency_key_reused. That is a bug in the client: one key, one request. - 5xx responses are not stored, so a retry after a server error runs the request again.
- Applies to POST requests with a JSON (or empty) body of up to 1 MB. File uploads ignore the header. Other methods don't need it: GET, PUT and DELETE are already safe to repeat.
Rate limits
Each caller gets a budget per one-minute window: per token when authenticated, per client address otherwise.
| Caller | Default budget |
|---|---|
| Authenticated | 1200 requests / minute |
| Anonymous | 300 requests / minute |
Every response reports where you stand:
RateLimit-Limit: 1200
RateLimit-Remaining: 1187
RateLimit-Reset: 42RateLimit-Reset is the number of seconds until the window resets. Over
budget, you get 429 rate_limited with Retry-After (seconds) and
details.retry_after_seconds. Wait that long, then retry:
async function call(url: string, init: RequestInit): Promise<Response> {
for (;;) {
const res = await fetch(url, init);
if (res.status !== 429) return res;
const wait = Number(res.headers.get('Retry-After') ?? '1');
await new Promise((r) => setTimeout(r, wait * 1000));
}
}Budgets can change; read the headers instead of hard-coding the numbers.
Deprecation
The API also answers at the old unversioned paths (/stores/...
instead of /v1/stores/...) for clients built before /v1. Those
responses say so:
Deprecation: @1790812800
Link: </v1/stores/…/orders>; rel="successor-version"
Sunset: Sat, 31 Jan 2027 00:00:00 GMTDeprecation(RFC 9745): the date the path was deprecated, as a Unix timestamp (2026-10-01).Linkwithrel="successor-version": the/v1path to use instead.Sunset(RFC 8594): when the old path stops working. Sent once a date is set.
The same headers mark anything deprecated inside /v1 later. Log them in
your client so you hear about a sunset before it happens.
Data formats
| Kind | Convention | Example |
|---|---|---|
| Money | Integer cents, in fields named *_cents. Never floats. | "total_cents": 18900 is R$ 189,00 |
| Timestamps | RFC 3339 in UTC, in fields named *_at | "created_at": "2026-10-01T14:03:22.418Z" |
| Ids | UUIDs, as strings | "id": "0199a4f2-6c1e-7d3a-9b1f-3e2c8a5d7f10" |
| Enums | snake_case strings. New values can appear; handle unknown ones. | "status": "paid" |
| Request bodies | JSON, Content-Type: application/json, except file uploads (multipart) |
Send timestamps with an offset or Z; the API answers in UTC. Treat ids as
opaque strings: don't parse them or rely on their ordering.