API Error Codes
Every v1 API error carries a stable machine-readable code. Branch on the code — never on the message.
The error shape
Errors return a non-2xx HTTP status and a JSON body with a human-readable error message and a stable code:
{
"error": "No barcodes remaining in this pass's pool.",
"code": "pool_exhausted"
}The code values below are a contract: they never change meaning and are only ever added to. The error strings are for humans and may be reworded — don't match on them. Some errors carry extra context fields (e.g. matchCount on ambiguous_barcode, retryable: true on retry-safe 500s).
Tracing a call
Every response carries an X-Request-Id header, and error bodies also include it as requestId. Quote that value when you contact support and we can find the exact call.
You can supply your own instead: send an X-Request-Id header with any request — your log id, up to 64 characters (letters, digits, ., _, -). We record it alongside ours and echo it back, so a failure in your logs matches ours exactly. If you don't send one, we generate it.
Codes
| Code | Status | Meaning |
|---|---|---|
missing_authorization | 401 | No Bearer token in the Authorization header. |
invalid_api_key | 401 | The API key is unknown or has been revoked. |
forbidden | 403 | The resource exists but belongs to another account. |
invalid_request | 400 | Malformed body or parameters — the error message says exactly what to fix. |
pass_not_found | 404 / 400 | No pass with that ID on your account (400 from Process Order when the passId is inactive). |
instance_not_found | 404 | No pass instance matches that instanceId or barcode. |
holder_not_found | 404 | No loyalty holder matches that identifier. |
no_reward_pending | 409 | POST /loyalty/redeem on a membership card that runs a loyalty program: no reward is pending on the card (the response carries balance and threshold). Rewards on program cards unlock at the threshold; use POST /loyalty/points to award. |
insufficient_points | 409 | The membership card's POINTS balance is below the reward's cost (POST /loyalty/redeem). The response carries balance and cost; nothing was deducted. |
no_journey | 409 | POST /instances/action journeyMoveToNext on a card whose pass has no journey steps. |
journey_no_matching_hop | 409 | POST /instances/action: the pass has steps, but none holds for this card on this call. Add an "On the API call: action (journeyMoveToNext)" step to move cards on demand. |
journey_reward_pending | 409 | POST /instances/action: the card is held by a pending reward; it moves once the reward is redeemed. |
program_deprecated | warning | Not an error: POST /loyalty/programs (and the GET/PATCH of an earning card) answer 2xx with warnings: [{ code: "program_deprecated", message: "DEPRECATED: Create a Journey instead" }] — the call built or read a journey, no program (since 2026-10-27). |
journey_target_unavailable | 409 | The step's next pass is missing, archived or not active; the card stays. |
bank_not_owned | 403 | PATCH /bank with a key of a workspace that inherits its points bank; only the workspace that owns the bank renames it or sets its period, timezone, and ladder. The response names ownerWorkspaceId. |
level_names_missing | 400 | PATCH /bank: every level on the ladder, and the base A once any level exists, needs a name in levelNames (since 2026-10-28); the response lists the missing letters. |
bank_not_found | 404 | GET or PATCH /bank on a workspace that has no points bank yet — banks are created at sign-up, at team creation, by the migration, or when a team chooses its own. |
pass_already_dynamic | 409 | POST /loyalty/programs on a pass that already runs a loyalty program. Update it with PATCH /loyalty/programs/{programId} instead. |
program_state_conflict | 422 | PATCH /loyalty/programs/{programId}: launching needs a published pass (reason publish_pass_first); pausing needs an active program (reason pause_requires_active). |
partner_not_found | 404 | No partner with that id on this workspace (unknown, deleted, or another workspace's). |
campaign_not_found | 404 | No campaign with that id on this pass (GET or DELETE /passes/{passId}/campaigns/{campaignId}). |
pass_archived | 400 | Archived passes can't issue new instances. |
fields_not_editable | 400 | This pass has no editable fields — add fields in the studio first. |
template_owned_field | 400 | The key is template-owned (title, details, terms, expiry) — update it for ALL holders instead. |
unknown_field_key | 400 | The field key doesn't exist on the pass; the message lists the valid keys. |
invalid_date_format | 400 | eventDate or expiryDate is not an ISO 8601 date (YYYY-MM-DD, optionally with a time and offset), or doorTime is not HH:MM; the message names the key and the value received. |
label_too_long | 400 | A label in design.labels, or the label of a row in design.extraFields, is longer than 30 characters; the message names the key and the length received. |
too_many_fields | 400 | design.extraFields holds more than 4 front rows or more than 8 back rows; the message says which and how many were received. |
invalid_time_zone | 400 | design.timeZone is not an IANA time zone name (Europe/Amsterdam, America/New_York); the message names the value received. |
invalid_link_url | 400 | design.backAppStoreUrl or design.backWebsiteUrl is not a full address with a scheme (https://…, http://…, mailto:…, tel:…) or null; the message reads "Malformed URL - design.<key>: …" and names the value received. Google Wallet refuses a link without a scheme, so the API does too. |
unsupported_language | 400 | A language code a pass cannot carry, in design.languages, design.translations, design.translationsEnabled, design.originalLanguage (en, fr, or pt only), or the language of a translation draft (which cannot be the language the pass is written in). For a campaign: a key of translations that is not an added language of the pass (the original goes in message). The message names the code received. |
too_many_languages | 400 | design.languages holds more than 4 languages. A pass carries 5 at most, the language it is written in included. |
original_language_locked | 400 | design.originalLanguage cannot change while the pass has added languages, since every translation would be stale. Remove the added languages first. |
draft_unavailable | 502 / 503 | POST /passes/{passId}/translations/draft could not make the draft. Nothing was changed; retry. |
no_loyalty_program | 400 | Points operations on a pass with no loyalty program. |
program_inactive | 400 | The loyalty program exists but is not active. |
program_not_launched | 400 | The loyalty program has not been launched yet. |
fields_update_unsupported | 409 | This holder's card predates per-holder addressing or ownership tracking — per-holder values (or clearing one with null) aren't possible on it; re-adding the pass mints an addressable card. |
pool_exhausted | 409 | No barcodes remain in the pass's pool — upload more codes. |
code_format_locked | 409 | PATCH /passes/{passId} with a codeStartingNumber that differs from the one the pass has, on a pass that is published or archived. The starting number is set while the pass is a draft. codePrefix and codeSuffix can still change and apply to codes issued from then on. The value GET returns, sent back unchanged, is accepted. |
draft_limit_reached | 409 | POST /passes, a clone, the deprecated program builder, A pass per level on a draft card, or restoring an archived pass as a draft on a Freedom workspace that already holds 25 drafts (10 until 2026-10-28). Unpublishing a live pass is never refused. Publish or archive a draft, then retry; Growth and Pro have no draft limit. The body carries limit and drafts. |
barcode_taken | 409 | PATCH /instances/fields with a barcodeValue that another active pass of the same template already carries. Applies in unique and pool mode, where the barcode identifies the holder's pass. In simple mode holders may share a barcode value and none is checked: to give several holders the same barcode, set the pass to distributionMode simple. |
simple_mode_limited_uses | 422 | POST /passes or PATCH /passes/{passId} that would leave a pass with distributionMode simple and design.fields.maxUses above 0 (since 2026-10-25). A simple pass gives every holder the same code, so redemptions cannot be limited per holder. Send design.fields.maxUses 0 (unlimited), or use distributionMode unique or pool for a per-holder limit; to keep your own codes, send barcodeValue when you issue each card (POST /passes/{passId}/issue) or later with POST /instances/fields. A limit you did not send (the coupon default of 1, or one already on the pass) is set to 0 instead of refused. |
ambiguous_barcode | 409 | A shared Simple-mode code matches several instances; the response carries matchCount and how to disambiguate. |
invalid_dates | 400 | POST /passes/{passId}/campaigns: startsAt is in the past (more than 5 minutes), endsAt is not after the start, endsAt is more than 90 days after the start, or a value is not an ISO 8601 date and time. The message says which. Nothing was saved. |
end_required | 400 | A campaign with an offer or a replacement pass needs endsAt. Only a message-only campaign may have no end. |
offer_unsupported | 400 | The campaign carries an offer or a replacementPass, which the API does not take yet. Send the message without them. |
no_devices | 409 | Nobody in the campaign's audience holds the pass in a wallet that receives updates, so no device can be notified. Nothing was sent or charged. |
campaign_not_scheduled | 409 | DELETE /passes/{passId}/campaigns/{campaignId} on a campaign that is no longer scheduled: it started, ended, or was canceled. status in the body says which. Only a scheduled campaign can be canceled. |
schedule_failed | 503 | The start of a scheduled campaign could not be queued. Nothing was saved or charged; retry (retryable: true). |
asset_fetch_failed | 422 | An image URL could not be used — slot names the parameter and reason says why: invalid_url (not https), blocked_host (private network, localhost, or cloud metadata address), unreachable, timeout (10 s), too_many_redirects (max 3), not_an_image, unsupported_type (PNG, JPEG, WebP only), too_small (below the slot's minimum). Nothing was created or changed; host the file publicly and retry. |
payload_too_large | 413 | An image URL points to a file over 20 MB. Nothing was created or changed; resize the file and retry. |
insufficient_credits | 402 | Not enough credits — top up in Billing and re-run. |
subscription_inactive | 402 | The account is on the Basic plan and its Shopify subscription is not active. Nothing was created; the merchant renews it in Billing. |
channel_needs_phone | 400 | The pass (or the call's channel) delivers by SMS or WhatsApp and no phone in international format (+15550000000) was given. Nothing issued; add the phone or pass channel: "email". |
channel_not_connected | 400 | The pass delivers by SMS or WhatsApp but the account has no Twilio sender for that channel. Connect one under Settings → Integrations → Twilio, or set the pass back to email. Nothing issued. |
whatsapp_template_not_approved | 400 | The pass delivers by WhatsApp but no WhatsApp template is approved yet — the Twilio card in Settings shows its state. Nothing issued. |
insufficient_scope | 403 | The API key is read-only, or restricted to other passes. Use a key with the right scope; see Key scopes and rotation. |
rate_limited | 429 | Request rate exceeded for this API key. Honor Retry-After, then retry. See Rate limits. |
pass_not_active | 409 | The action needs a published pass: issuing (single or batch) from a draft, inviting Shopify customers to one, or sending a campaign. Publish it, then retry. Publishing is where the 10-credit fee is charged; drafts never issue. |
sweep_running | 409 | An existing-customer invite sweep is already running for this pass. Wait for it to finish (state.running clears) and retry. |
paused_no_credits | 409 | Existing-customer invites are paused because the workspace balance cannot cover one claim; the hourly sweep resumes on its own once credits are added. Top up, then retry. |
sweep_failed | 502 | The store's customers could not be read or invited; reason says why (store_not_connected, a Shopify error). Fix the connection, then retry. |
internal_error | 500 | Something failed on our side. When retrying is safe the response carries retryable: true. |
Retry guidance
- 202 — saved but not delivered: the update applied, but the wallet push was skipped for lack of credits (
pushed: false,pushSkipReason: "insufficient_credits", plus awarningsentry). Top up in Billing and re-send (with a freshexternalRefif you use them) to deliver. - 4xx — don't retry unchanged; fix the request. The exceptions:
insufficient_credits(retry after topping up) andrate_limited(retry with backoff). - 500 with retryable: true — safe to retry: the operation released its claim, and idempotent endpoints (issue, points with externalRef, Process Order) return the original result instead of double-applying.
- 500 without retryable — contact support with the request details.
See also Rate limits and the API & integrations guide.