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

CodeStatusMeaning
missing_authorization401No Bearer token in the Authorization header.
invalid_api_key401The API key is unknown or has been revoked.
forbidden403The resource exists but belongs to another account.
invalid_request400Malformed body or parameters — the error message says exactly what to fix.
pass_not_found404 / 400No pass with that ID on your account (400 from Process Order when the passId is inactive).
instance_not_found404No pass instance matches that instanceId or barcode.
holder_not_found404No loyalty holder matches that identifier.
no_reward_pending409POST /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_points409The membership card's POINTS balance is below the reward's cost (POST /loyalty/redeem). The response carries balance and cost; nothing was deducted.
no_journey409POST /instances/action journeyMoveToNext on a card whose pass has no journey steps.
journey_no_matching_hop409POST /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_pending409POST /instances/action: the card is held by a pending reward; it moves once the reward is redeemed.
program_deprecatedwarningNot 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_unavailable409The step's next pass is missing, archived or not active; the card stays.
bank_not_owned403PATCH /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_missing400PATCH /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_found404GET 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_dynamic409POST /loyalty/programs on a pass that already runs a loyalty program. Update it with PATCH /loyalty/programs/{programId} instead.
program_state_conflict422PATCH /loyalty/programs/{programId}: launching needs a published pass (reason publish_pass_first); pausing needs an active program (reason pause_requires_active).
partner_not_found404No partner with that id on this workspace (unknown, deleted, or another workspace's).
campaign_not_found404No campaign with that id on this pass (GET or DELETE /passes/{passId}/campaigns/{campaignId}).
pass_archived400Archived passes can't issue new instances.
fields_not_editable400This pass has no editable fields — add fields in the studio first.
template_owned_field400The key is template-owned (title, details, terms, expiry) — update it for ALL holders instead.
unknown_field_key400The field key doesn't exist on the pass; the message lists the valid keys.
invalid_date_format400eventDate 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_long400A 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_fields400design.extraFields holds more than 4 front rows or more than 8 back rows; the message says which and how many were received.
invalid_time_zone400design.timeZone is not an IANA time zone name (Europe/Amsterdam, America/New_York); the message names the value received.
invalid_link_url400design.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_language400A 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_languages400design.languages holds more than 4 languages. A pass carries 5 at most, the language it is written in included.
original_language_locked400design.originalLanguage cannot change while the pass has added languages, since every translation would be stale. Remove the added languages first.
draft_unavailable502 / 503POST /passes/{passId}/translations/draft could not make the draft. Nothing was changed; retry.
no_loyalty_program400Points operations on a pass with no loyalty program.
program_inactive400The loyalty program exists but is not active.
program_not_launched400The loyalty program has not been launched yet.
fields_update_unsupported409This 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_exhausted409No barcodes remain in the pass's pool — upload more codes.
code_format_locked409PATCH /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_reached409POST /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_taken409PATCH /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_uses422POST /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_barcode409A shared Simple-mode code matches several instances; the response carries matchCount and how to disambiguate.
invalid_dates400POST /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_required400A campaign with an offer or a replacement pass needs endsAt. Only a message-only campaign may have no end.
offer_unsupported400The campaign carries an offer or a replacementPass, which the API does not take yet. Send the message without them.
no_devices409Nobody 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_scheduled409DELETE /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_failed503The start of a scheduled campaign could not be queued. Nothing was saved or charged; retry (retryable: true).
asset_fetch_failed422An 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_large413An image URL points to a file over 20 MB. Nothing was created or changed; resize the file and retry.
insufficient_credits402Not enough credits — top up in Billing and re-run.
subscription_inactive402The account is on the Basic plan and its Shopify subscription is not active. Nothing was created; the merchant renews it in Billing.
channel_needs_phone400The 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_connected400The 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_approved400The pass delivers by WhatsApp but no WhatsApp template is approved yet — the Twilio card in Settings shows its state. Nothing issued.
insufficient_scope403The API key is read-only, or restricted to other passes. Use a key with the right scope; see Key scopes and rotation.
rate_limited429Request rate exceeded for this API key. Honor Retry-After, then retry. See Rate limits.
pass_not_active409The 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_running409An existing-customer invite sweep is already running for this pass. Wait for it to finish (state.running clears) and retry.
paused_no_credits409Existing-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_failed502The store's customers could not be read or invited; reason says why (store_not_connected, a Shopify error). Fix the connection, then retry.
internal_error500Something 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 a warnings entry). Top up in Billing and re-send (with a fresh externalRef if you use them) to deliver.
  • 4xx — don't retry unchanged; fix the request. The exceptions: insufficient_credits (retry after topping up) and rate_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.