Appearance
Errors
Every error, on every surface, has the same shape:
json
{
"error": {
"code": "unauthorized",
"message": "Missing scope: cards:write",
"request_id": "req_01J9…"
}
}- Switch on
code, never onmessage. Codes are stable; messages are prose and get better over time. request_idis echoed in theX-Request-Idresponse header on every response, not just failures. Log it. Quoting it on a support ticket turns "it broke yesterday" into a single indexed lookup.detailsappears on validation failures and lists the offending fields.
The catalogue below is generated from the server's shared error vocabulary, so a code cannot ship without a row here.
Codes the Business API returns
These are the codes worth handling in an integration.
error.code | HTTP | When it happens | What to do |
|---|---|---|---|
bad_request | 400 | The request is well-formed but cannot be satisfied as written (a semantic problem no schema can catch). | Read message. Not retryable unchanged. |
business_api_required | 401 | The shop has no active Business API add-on, and no internal grant standing in for one. Also returned when the whole module is switched off at the deployment. | Switch on Business API from Billing in the dashboard, or ask support for a grant. The key is not revoked and is not re-issued — do not mint a new one. |
card_expired | 409 / 422 | The card instance's expires_at has passed. | Reissue the card, or extend the expiry before it lapses. |
card_locked | 409 | The card instance is installed on a device, which freezes its holder fields and expiry. | Reissue the card instead of editing it. This is a state, not a failure — a locked card is a card someone is carrying. |
conflict | 409 | The write conflicts with the resource's current state (a duplicate, or a transition that is not allowed from here). | Re-read the resource and decide. Blind retries will not clear it. |
idempotency_conflict | 409 | An Idempotency-Key was reused with a different request body, OR the key was claimed by an earlier request that never recorded a response. | Do NOT retry with the same key. The message distinguishes the two cases: fix the caller in the first, start over with a fresh key in the second. |
internal_error | 500 | An unhandled server-side failure. message is deliberately generic. | Retry with the same Idempotency-Key — that is exactly what it is for. Quote request_id on a support ticket. |
location_disabled | 402 on authentication, 400 / 422 in service checks | The location the request concerns is deactivated or soft-deleted. | Re-enable the location, or target another one. |
location_scope_unsupported | 403 | A location-restricted key called an analytics endpoint whose series are not derived from transactions, so they cannot be narrowed to one location. | Use an unrestricted key for those endpoints. The 403 names the endpoint; shop-wide numbers are never silently served to a restricted key. |
mcp_access_required | 401 | The credential is fine and REST access may well be working — what is missing is entitlement to the MCP connector specifically, which has its own switch and its own grant. | A distinct code precisely so nobody is sent to billing for something billing cannot fix: MCP always also requires API access, so check that first. Ask support to enable the connector. |
not_found | 404 | The resource does not exist, or belongs to another shop. The two are deliberately indistinguishable. | Do not treat a 404 as "deleted" — a key whose location_ids exclude the resource also gets a 404 on some paths and a 403 on others. |
program_has_installed_cards | 409 | The program has at least one installed card, so its template can no longer be edited. | Create a new program for a new design. Existing holders keep the card they installed. |
rate_limited | 429 | The calling key exceeded its per-minute limit. The limit is per KEY, not per shop. | Honour Retry-After and retry with the SAME Idempotency-Key. If two integrations share a key, give each its own. |
subscription_suspended | 402 on authentication, 422 in service checks | The shop's subscription is suspended or cancelled (unpaid invoice, or a cancellation that has taken effect). | Stop writing and alert a human. Your key is intact. |
unauthenticated | 401 | No Authorization header, a malformed one, an unknown key, a revoked key, an expired key — or a key of the wrong class (an MCP tmk_ key presented to the REST API). | The message names the mistake, including the wrong-class case. Never retry an unauthenticated call in a loop. |
unauthorized | 403 | The key is valid but lacks the scope the endpoint requires, or its location_ids restriction excludes the target. | Call GET /me — it returns the calling key's own scopes and location restriction, which is the fastest way to resolve a 403. Issue a key with the scope you need. |
unprocessable_entity | 422 | The request is valid but the domain rejects it. | Read message; it names the rule. |
validation_failed | 400 | A request body, query parameter or path parameter failed schema validation. | details lists the offending fields with a stable per-field code. Fix the request; retrying it unchanged will fail identically. |
Platform codes
Emitted by the shop app, the consumer app or the dashboard. Listed because the vocabulary is shared and you may see them in a support conversation — an integration does not have to handle them.
error.code | HTTP | When it happens | What to do |
|---|---|---|---|
amount_below_min_stamp | 400 | A spend amount was too small to earn one stamp. | Shop-app scan path only. |
cannot_reverse_completed_cycle | 422 / 500 | A reversal would undo a final-prize redemption that already reset the card. | Shop-app reversal path only. |
card_full_no_repeat | 422 | A stamp scan hit a full card on a program with repeat turned off. | Shop-app scan path only; the Business API never writes transactions. |
card_suspended | 422 | The card instance is suspended and cannot be used. | Unsuspend it from the dashboard. |
email_not_verified | 401 | A dashboard account has not verified its email address. | Dashboard only; API keys are unaffected. |
feature_disabled | 401 / 404 | A deployment-level feature switch is off (e.g. a sign-in method). | Not reachable through the Business API. |
gone | 410 | A one-time resource (an install link, an enrollment token) has already been used. | Issue a new one. |
invalid_credentials | 401 | Dashboard sign-in with a wrong email or password. | Not reachable over the Business API — there is no login. |
points_insufficient_balance | 422 | A points redemption exceeded the balance. | Shop-app scan path only. |
prepaid_insufficient_balance | 422 | A prepaid deduction exceeded the remaining balance. | Shop-app scan path only. |
spend_not_enabled | 400 | A scan sent an amount to a program that is not spend-based. | Shop-app scan path only. |
spend_required | 400 / 422 | A scan sent a manual stamp count to a spend-based program. | Shop-app scan path only. |
threshold_already_claimed | 422 | A prize at that threshold was already redeemed this cycle. | Shop-app redeem path only. |
token_invalid | 401 / 403 / 422 | A one-time link or verification token (email verification, password reset, passkey challenge, social sign-in) is invalid. | Dashboard flows only. |
Reserved codes
Declared in the shared vocabulary but not constructed by any handler today. Handle them defensively if you switch on error.code; do not build a flow that depends on one.
error.code | HTTP | When it happens | What to do |
|---|---|---|---|
already_exists | 409 | Reserved. Duplicates are reported as conflict. | Treat as conflict. |
dependency_unavailable | 503 | Reserved for a dependency outage. | Retry with the same idempotency key after a backoff. |
invalid_input | 400 | Reserved. No handler constructs it today; validation_failed covers input problems. | Treat as validation_failed if you ever see it. |
password_reset_required | 401 | Reserved for a forced-reset policy. | Dashboard only. |
token_expired | 401 | Reserved. Expired API keys are reported as unauthenticated. | Rotate the key. |
totp_invalid | 401 | Reserved. Two-factor prompts are dashboard-side. | Dashboard only. |
totp_required | 401 | Reserved. Two-factor prompts are dashboard-side. | Dashboard only. |
upstream_error | 502 | Reserved for a third-party failure. | Retry with the same idempotency key. |
Recoverable failures
These are the errors an unattended integration will meet in normal operation. None of them means your credential is gone.
business_api_required— RECOVERABLE. Keys are suspended, never destroyed, when access lapses:disabled_by_plan_atis stamped and cleared again on restore, so the same key resumes working and the customer never re-keys their CRM.location_disabled— RECOVERABLE. Re-enabling the location restores the call with no change on your side.location_scope_unsupported— RECOVERABLE by calling with an unrestricted key. The three transaction-derived endpoints work with a restricted key as-is.mcp_access_required— RECOVERABLE. Likebusiness_api_required, MCP keys are suspended rather than destroyed and resume working the moment access is restored.rate_limited— RECOVERABLE by waiting. It is the one error a correct client resolves entirely on its own.subscription_suspended— RECOVERABLE. Nothing about the key changed: the moment the subscription is settled in the dashboard, the same key works again. Back off, retry periodically, and do not re-key your integration.
Handling errors, in order of what actually happens
- 429 and 5xx: retry. With the same
Idempotency-Key, honouringRetry-After. See Idempotency — this is the single highest -value thing to get right. business_api_required,mcp_access_required,subscription_suspended: back off and alert a human. All three are recoverable and none of them means your credential is gone. Do not re-key, and do not delete your stored key — the same key resumes working when the shop's access is restored.- 409
idempotency_conflict: stop. Retrying cannot help. Either your caller reused a key with a different body, or an earlier attempt claimed the key and never finished — the message says which. - 400 and 422: fix the request. The same request will fail identically forever.
- 401: read
error.codebefore you touch the credential. A 401 is not always a bad key.unauthenticatedis — check the prefix first; the message will tell you if you presented an MCP key to the REST API. Butbusiness_api_requiredandmcp_access_requiredalso return 401, and there the key is intact and only the shop's entitlement is missing. Branch on the code, never on the status alone.