MobiariDocs

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
APIPathsWho calls itOpenAPI document
Admin/v1/...The admin and mobile apps, your integrationsadmin.json
Storefront/v1/storefront-api/...Storefront themes, shoppers' browsersstorefront.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" }
}
FieldMeaning
codeStable, snake_case. Branch on this. Renaming a code is a breaking change.
errorHuman-readable message. Wording can change at any time; show it, don't parse it.
detailsOptional 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:

StatuscodeWhen
400bad_requestMalformed request.
400invalid_cursor, invalid_limit, invalid_offsetBad pagination parameters.
401unauthorizedMissing, expired or revoked credentials.
401invalid_refresh_token, refresh_token_reusedRefresh failed; sign in again.
403forbidden, missing_permissionValid credentials, but the user or token scope lacks the permission.
404not_foundThe resource doesn't exist, or isn't in this store.
404route_not_foundNo such endpoint under /v1. Check the path and method.
409conflictThe resource's state doesn't allow this.
409idempotency_request_in_progressSee Idempotency.
413payload_too_largeBody over the endpoint's limit.
422validation_failedField errors in details.fields.
422idempotency_key_reusedSee Idempotency.
429rate_limitedSee Rate limits.
500internal_errorOur 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
}
FieldMeaning
dataThis page's items, in the order the endpoint documents.
has_moreWhether another page follows.
next_cursorPass as cursor to get the next page. null on the last page.
totalCount of all matching items. Only present with include_total=true.
Query parameterMeaning
limitPage size, 1 to 200. Default 50.
cursornext_cursor from the previous page. Omit for the first page.
offsetItems to skip, for UIs that jump to page N. Ignored when cursor is set.
include_totaltrue 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
done

Walking 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.

CallerDefault budget
Authenticated1200 requests / minute
Anonymous300 requests / minute

Every response reports where you stand:

RateLimit-Limit: 1200
RateLimit-Remaining: 1187
RateLimit-Reset: 42

RateLimit-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 GMT
  • Deprecation (RFC 9745): the date the path was deprecated, as a Unix timestamp (2026-10-01).
  • Link with rel="successor-version": the /v1 path 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

KindConventionExample
MoneyInteger cents, in fields named *_cents. Never floats."total_cents": 18900 is R$ 189,00
TimestampsRFC 3339 in UTC, in fields named *_at"created_at": "2026-10-01T14:03:22.418Z"
IdsUUIDs, as strings"id": "0199a4f2-6c1e-7d3a-9b1f-3e2c8a5d7f10"
Enumssnake_case strings. New values can appear; handle unknown ones."status": "paid"
Request bodiesJSON, 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.

On this page