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). On the card endpoints the usual cause is an ARCHIVED card: archiving is terminal, so the card can be neither disabled nor enabled, cannot be reissued, and its holder fields can no longer be edited. | Re-read the resource and decide. Blind retries will not clear it — and on an archived card nothing will, because there is no unarchive: issue a new card. |
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 one of the 13 endpoints that cannot be narrowed to a location. Two families: every CARD endpoint except issuing one — a card carries no location anywhere in TesserApp, it belongs to a program and programs belong to the whole shop — and the ANALYTICS endpoints whose series are not derived from transactions. | Use an unrestricted key for those endpoints. The 403 names the endpoint, and shop-wide data is never silently served to a restricted key instead. GET /transactions is the read that IS site-attributed, so a restricted key can answer "what happened at this site" there. |
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. |
program_inactive | 409 | The card’s program is deleted, switched off (unpublished) or suspended, so the card cannot be brought back: POST /cards/{id}/enable and POST /cards/{id}/reissue refuse it (disabling still works). The shop app gets the same code when it tries to issue a personal card on such a program. | Reactivate the program first (publish it, or unsuspend it), then retry. A deleted program cannot be reactivated — issue the card on another program. Not retryable unchanged. |
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 is not in a usable state. Since migration 0054 that is overwhelmingly the DISABLED state: fourteen service-layer sites gate on state = 'active', so a card the merchant switched off refuses every scan, redemption, gift card deduction and reversal. | Read the card. A status of disabled is undone with POST /cards/{id}/enable and the card returns with its stamps, its expiry and its install intact; a status of archived is TERMINAL, so issue a new card instead. Returned by the shop app's scan paths — the Business API writes no transactions and never returns it. |
coupon_not_applicable | 400 | The promotion code is unknown, inactive, expired, out of redemptions, or is a CHECKOUT code rather than a subscription one. details.reason says which. | Dashboard billing page only. Subscription coupons must carry apply_to: subscription metadata in Stripe. |
device_not_bound | 403 | A shop-app call that acts as "the device bound to this location" (pairing a kiosk till by QR, listing, renaming or removing the kiosks) came from a session with no live device at a live location. | Shop app only. Bind the phone to a location first, then retry. |
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. |
impersonation_forbidden | 403 | An owner-only dashboard action (credentials, other sessions, legal acceptance, account deletion) was attempted while a TesserApp administrator was signed in as the shop for support. | Dashboard only; API keys are unaffected. The owner performs the action from their own session. |
invalid_credentials | 401 | Dashboard sign-in with a wrong email or password. | Not reachable over the Business API — there is no login. |
invalid_current_password | 400 | The dashboard "Change password" form re-typed the current password wrongly. 400 rather than 401: the session is fine. | Dashboard only. Retype the current password; repeated failures are rate limited like sign-in. |
kiosk_cannot_approve | 403 | A kiosk till tried to approve another till’s QR pairing code. Only a non-kiosk device (the phone) can vouch for a kiosk, which then joins that device’s location. | Shop app only. Approve the code from the phone bound to the location. |
no_active_subscription | 409 | A coupon was applied to a shop with no live subscription — there is nothing to discount. | Dashboard billing page only. |
no_billing_account | 409 | The shop has no Stripe customer yet — it has never paid for anything. | Dashboard billing page only. Add a location (which creates the customer) first. |
password_not_set | 409 | A password change was requested for an account that signs in only with Google, Apple or a passkey, so there is no current password. | Dashboard only. Set a first password through "Forgot password", which proves control of the login email. |
payment_method_is_default | 409 | A payment method was removed while it is the one the subscription is billed to. | Dashboard billing page only. Set another method as default, then remove this one. |
payment_method_last_one | 409 | The only payment method on file was removed while a subscription is still live — the next renewal would have nothing to charge. | Dashboard billing page only. Add another method first. |
points_insufficient_balance | 422 | A points redemption exceeded the balance. | Shop-app scan path only. |
prepaid_insufficient_balance | 422 | A gift card (type 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.GET /transactionsand the three transaction-derived analytics 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.