Skip to main content

Troubleshooting

When the SDK throws or an API call returns a non-2xx, the response carries a structured code you can branch on. This page is the self-diagnose recipe for the codes you'll hit most often. Each entry is structured so an AI agent can parse it directly: cause, ranked likely sources, the exact check to run, and when to escalate.

Branch on the code, not just the HTTP status. The remediation lives in the code. The clearest example is the pair with opposite fixes: session_expired (410 Gone, create a new session) vs session_already_completed (409 Conflict, you're done — creating a new session re-charges the already-paid buyer). Always read the code.

How to read this page

Every entry lists:

  • What it means — the contract this code expresses
  • Likely causes — ranked by frequency in real integrations
  • Diagnose with — the exact check that resolves the cause
  • Next action — one of the self-heal values: retry · rotate_key · fix_request · wait_and_retry · contact_support · complete_onboarding · create_new_session · no_action
  • Retryable — whether retrying the same call may succeed
  • Escalate when — the signal that says "this isn't a code-fix; ask support"

These are the exact values the API returns in selfHeal.nextAction — see For AI agents.

Quick reference

CodeHTTPNext actionRetryable
auth_invalid_key401rotate_keyno
auth_invalid_key_publishable401rotate_keyno
auth_key_expired401rotate_keyno
auth_merchant_inactive401contact_supportno
webhook_invalid_signature401fix_requestno
merchant_not_configured422complete_onboardingno
validation_invalid_amount400fix_requestno
validation_error / _missing_field400fix_requestno
rate_limit_exceeded / _per_key429wait_and_retryyes
provider_unavailable502wait_and_retryyes
provider_charge_failed402no_actionno
session_expired410create_new_sessionno
session_already_completed409no_actionno

merchant_not_onboarded (403) is returned by live-key creation (not the checkout API) — see Onboarding & configuration.

The connected-store order codes (mirror_*, order_*, upsell_duplicate) have their own table: see Connected-platform orders below.


What to include when you contact support

To let us correlate a transaction to a buyer and investigate fast (duplicate charges, "is this the same buyer?", disputes), include:

  • The session ID (vp_cs_*) or transaction ID (vp_tx_*).
  • The X-Request-Id header from the relevant API response.
  • The buyer's buyerId and buyerEmailas you sent them on the session.

The last point is the one integrators miss. If you send a stable, per-user buyerId plus buyerEmail on every session (see Buyer identification), we can link all of a buyer's transactions instantly. If buyerId changes per visit and no email is sent, a returning buyer can't be linked — which is exactly what makes duplicate-charge questions hard to answer. Pass clear buyer info up front and troubleshooting becomes a lookup.


Recover from a failed charge

A charge can fail at three surfaces. Identify which one you're holding, then follow the flow.

SurfaceWhat you're holdingBranch on
Client (Embedded Fields SDK)result.error (a VoraMirrorError) from submit()error.code
Server (API)a non-2xx response with a codeerror.code
Webhook (async)a charge.failed / payment_intent.failed eventdata.failure_code
A failed charge
├─ Client SDK (result.error)
│ ├─ frame_3ds_challenge_failed → challenge rejected/failed → re-run submit()
│ ├─ frame_3ds_challenge_timeout → buyer ran out of time → re-run submit()
│ ├─ frame_3ds_challenge_cancelled → buyer closed the challenge → abandoned, not failed; let them retry
│ ├─ frame_tokenization_failed → card rejected before charge → ask for a different card
│ └─ frame_payment_declined → issuer declined → surface the decline, offer another method
├─ Server API (non-2xx code)
│ ├─ provider_charge_failed (402) → buyer decline → surface, do NOT retry the same card
│ ├─ validation_* / unsupported_media_type→ fix the request, then retry
│ ├─ mirror_* / order_* (connected store) → refused BEFORE the charge → no money moved
│ ├─ provider_unavailable (502) → transient → wait + retry (the SDK auto-retries)
│ └─ auth_* / merchant_* → key / config issue → see the per-code recipes below
└─ Webhook (data.failure_code)
└─ branch per the Decline reasons table → retry / different card / surface to buyer

Rule of thumb:

  • A buyer decline (the issuer said no — provider_charge_failed, frame_payment_declined, most failure_code values) → surface a clear message and offer another method. Never silently retry the same card.
    • One exception: blocked_by_rule is not an issuer decline — a rule on your own account refused the charge and no bank ever saw it. Same buyer-facing action (offer another method), but do not tell the buyer to contact their bank.
  • A request error (validation_*, auth_*, config) → fix the input or key, then retry.
  • A transient error (provider_unavailable) → the SDK already retried; wait and retry once more, then escalate with the X-Request-Id.
  • A connected-store error (mirror_*, order_*) → the request was refused before the charge ran. Nothing was authorized, nothing was captured, no store order exists. Fix the block (or the store connection) and send it again. See Connected-platform orders below.
  • upsell_duplicate is the exception and behaves the opposite way. It fires because an earlier charge already succeeded and its line was already added to the store order. Money moved, and the store order exists. Treat the original payment as the outcome, using the original_payment_intent_id in the response. Do not send it again, and do not remove the idempotency key to force it through, which re-creates the double charge this check exists to prevent.

The per-failure_code retry/message guidance is in Decline reasons.


Auth & key errors

auth_invalid_key — HTTP 401

What it means: The API key is malformed or does not exist in our auth registry.

Next action: rotate_key  ·  Retryable: no

Likely causes (ranked):

  1. Env var unset or misnamed. Check VON_PAY_SECRET_KEY in your environment. The SDK looks here by default.
  2. Key has rotated past its 24h grace. A previously-valid key was rotated and the grace window expired. The old key is permanently dead.
  3. The key was issued by a different deployment. The key is valid where it was created, but you're calling a host whose registry has never seen it. Note this is not a mode problem — checkout.vonpay.com serves both _test_ and _live_ keys; the prefix picks the mode, not the host. See Which environment am I talking to?.

Diagnose with:

# Confirm the API + your key reach us
vonpay checkout health --json

# Check the key's age + grace state in the dashboard
open https://app.vonpay.com/dashboard/developers/api-keys

Escalate when: the dashboard shows the key as Active, its mode matches the URL you're hitting, and you still get auth_invalid_key. That's an auth-service issue — open a ticket with the X-Request-Id.


auth_invalid_key_publishable — HTTP 401

What it means: The publishable key your browser presented is malformed, revoked, or not present in the registry of the host it called.

Next action: rotate_key  ·  Retryable: no

Read this before you rotate the key

The API returns rotate_key, but for the most common cause — a host mismatch — rotating is the wrong fix. It discards a working key and, because a publishable key is baked into your browser bundle, costs you a redeploy without changing the outcome. Rule out cause 1 below first.

The diagnostic tell: your server calls succeed and 100% of your browser calls fail. A bad or revoked key fails on both sides. An asymmetry like that points at the browser reaching a different host, not at the key.

Likely causes (ranked):

  1. Your browser and server are talking to different hosts. apiBaseUrl on new Vora({ … }) defaults to the production host and is not inferred from your key. If your server created the session somewhere else, the browser presents a key that host never issued. See Which environment am I talking to?.

    The tell: your server-side calls keep succeeding while every browser call fails — 100% of them, not intermittently — and the key shows no recorded use on the host you expected. That split is this cause and effectively nothing else. Check it before you look at the key itself.

  2. Copy-paste truncation. Publishable keys are long; a clipped tail fails the registry lookup. Confirm the value that reached the browser, not the value in your .env — a build-time variable that was never wired through arrives as undefined.

  3. Key revoked from the dashboard. Check it still shows Active.

  4. A secret key was passed instead. That throws a TypeError in the constructor rather than reaching this error — but it's worth ruling out if you never see a network call at all.

Diagnose with:

  1. Find the host your browser actually used. Open DevTools → Network, filter to the failing request, and read its domain. That is the host the SDK used — whether you set apiBaseUrl or inherited the default.

  2. Confirm the key reached the browser intact. Add this next to your new Vora({ … }) call (not in the console — bundler variables aren't readable there):

    // Logs length + a safe prefix. A publishable key is safe to log; a secret key is not.
    console.log("key:", publishableKey?.length, publishableKey?.slice(0, 12));
    console.log("host:", apiBaseUrl ?? "(unset — using the production default)");
  3. Confirm your server names that same host when it creates the session.

If step 1 and step 3 disagree, that's your answer — set apiBaseUrl to match, and don't rotate the key.

Escalate when: the key shows Active in the dashboard, your browser and server provably name the same host, and the full untruncated key still fails. Open a ticket with the X-Request-Id from the failing browser response.


auth_key_expired — HTTP 401

What it means: A key was rotated and the previous key has passed its 24-hour grace window.

Next action: rotate_key  ·  Retryable: no

Likely causes:

  1. A deploy missed the rotation. A service is still configured with the old key. Find the deploy and update it.
  2. Multiple rotations within 24h — when you rotate while a previous grace is still active, the oldest key deactivates immediately. If you rotated twice within 24h, the very first key is already dead.

Diagnose with:

# Check rotation badges in the dashboard
open https://app.vonpay.com/dashboard/developers/api-keys

# Find services still using the old key
grep -rn "vp_sk_" --include="*.env*" .

Escalate when: All of your services are on the active key but you're still getting auth_key_expired. That implies a propagation issue with the auth-cache service.


auth_merchant_inactive — HTTP 401

What it means: The merchant account is disabled or suspended.

Next action: contact_support  ·  Retryable: no

Likely causes:

  1. Account suspension. Either by ops (compliance / chargeback issues) or by the merchant themselves.
  2. Sandbox merchant in pending_approval state hitting live. Test keys are scoped to sandbox merchants regardless of mode.
  3. merchants.status is denied or deleted.

Diagnose with: Check the merchant's status at app.vonpay.com/dashboard (if you have access). Merchant status is an ops surface, not something you fix in code.

Escalate when: Always escalate on this code unless it's a brand-new sandbox account waiting for the auto-activation grace.


webhook_invalid_signature — HTTP 401

What it means: The HMAC signature on a webhook does not match what we computed.

Next action: fix_request  ·  Retryable: no (don't retry; fix the verifier)

Likely causes (ranked):

  1. Wrong secret. Each webhook endpoint has its own whsec_* signing secret, minted when you registered the endpoint at /dashboard/developers/webhooks. Confirm the secret in your handler env matches the endpoint your URL was registered against — not a different endpoint's secret, and not your API key. See Webhook Signing Secrets.
  2. Body was JSON-parsed before HMAC. You must hash the raw bytes of the request body, not the re-stringified JSON. Different JSON serializers normalize whitespace differently and produce different signatures.
  3. Timestamp outside the replay window. Reject if more than 5 min in the past or 30 sec in the future. Check your server clock against NTP.
  4. A signing secret was just rotated and your handler hasn't picked up the new value (the rare case — check this only after the three above).

Diagnose with:

// Node — log what's reaching your verifier
const rawBody = await req.text(); // NOT req.json()
console.log("body length:", rawBody.length);
console.log("signature header:", req.headers.get("x-vonpay-signature"));
// the timestamp is the t= field INSIDE x-vonpay-signature — there is no separate header
console.log("body first 80 chars:", rawBody.slice(0, 80));
# Python (Flask/FastAPI) — same shape
raw_body = request.get_data() # NOT request.get_json()
print(f"body length: {len(raw_body)}, sig: {request.headers.get('X-VonPay-Signature')}")

Escalate when: You're computing the HMAC correctly (verified against our reference implementations byte-for-byte), the secret is the right key, the timestamp is fresh, and verification still fails. That's a delivery-engine bug.


Onboarding & configuration

merchant_not_configured — HTTP 422

What it means: The merchant is missing required configuration — no payment provider is bound, or the gateway routing is incomplete.

Next action: complete_onboarding  ·  Retryable: no

Likely causes:

  1. Sandbox merchant with no mock gateway. "Activate VORA Sandbox" didn't run cleanly, or the merchant was issued test keys without atomic provisioning.
  2. Live merchant whose payment provider configuration was removed by ops.

Diagnose with: This is a merchant-side action, not an integrator code fix — the merchant must attach a payment provider in the dashboard (onboarding). If you're the integrator and not the merchant, surface a "your account isn't finished setting up payments" message and capture the X-Request-Id.

Escalate when: Onboarding shows complete in the dashboard but the API still returns merchant_not_configured. Contact support with your X-Request-Id.


merchant_not_onboarded — HTTP 403

Scope: this code comes from live-key creation (the merchant/onboarding surface), not the checkout API. You'll see it when trying to mint live keys before the merchant is approved — not on POST /v1/sessions.

What it means: Live keys are gated behind merchant application approval. The merchant hasn't completed KYC + contract review.

Next action: contact_support  ·  Retryable: no

Likely causes:

  1. Trying to create live keys before onboarding completes.
  2. A live API call with a merchant still in pending_approval (you'll usually get auth_merchant_inactive on the checkout API for this — merchant_not_onboarded is the key-creation gate).

Diagnose with: Look at app.vonpay.com/dashboard — the banner names the missing onboarding step.

Escalate when: Onboarding is documented complete but live keys are still gated. That's an operational glitch.


Validation errors

validation_invalid_amount — HTTP 400

What it means: The amount field is not a positive integer (in minor units), or it exceeds the maximum.

Next action: fix_request  ·  Retryable: no (fix the input)

Likely causes (ranked):

  1. Sending major units instead of minor units. 14.99 for $14.99 is wrong; it must be 1499. Float-rounding errors compound.
  2. Zero or negative. amount must be >= 1 for payment sessions. (Exception: setup-mode sessions — mode: "setup", used to vault a card with no charge — may send amount: 0 or omit it.)
  3. Above the ceiling. The maximum is 99,999,999 minor units (e.g. $999,999.99 for USD).
  4. Wrong type. amount must be a JSON number, not a string. Decimals are rejected.
  5. Currency-exponent mismatch. JPY has no minor units (1499 = ¥1,499). KWD has 3 (1499000 = KWD 1,499.000).

Diagnose with:

// Confirm you're sending a positive integer in minor units
console.log("amount type:", typeof params.amount, "value:", params.amount);
// MUST be a number, 1..99_999_999. $14.99 → 1499; ¥1499 → 1499; KWD 1.499 → 1499

Escalate when: Never. This is always a code fix on the integrator side.


validation_error / validation_missing_field — HTTP 400

What it means: Request body failed schema validation.

Next action: fix_request  ·  Retryable: no

Likely causes: Missing required fields, wrong types, malformed strings (non-ISO-4217 currency, non-ISO-3166 country, etc.).

Diagnose with: The error message names the failing field. For example: "Expected number, received string at \"amount\"" — the fix is to coerce that field to a number.

Escalate when: Never. Always a code fix.


Rate limits

rate_limit_exceeded / rate_limit_exceeded_per_key — HTTP 429

What it means: You exceeded a rate limit. Two distinct codes share the 429:

  • rate_limit_exceeded — the per-IP limit (e.g. POST /v1/sessions is 10 req / minute / IP).
  • rate_limit_exceeded_per_key — the per-API-key limit (e.g. POST /v1/sessions is 30 / minute / key; the payment-ops endpoints — /v1/payment_intents, /v1/refunds, /v1/tokens — are 100 / minute / key).

The full per-endpoint ceilings are in the rate-limit table.

Next action: wait_and_retry  ·  Retryable: yes

Likely causes:

  1. Burst from a single deployment — usually a retry loop without backoff.
  2. Missing or wrong Idempotency-Key causing duplicate creates that each count against the limit.

Diagnose with: Read the Retry-After header and wait that long — don't retry sooner. The SDK auto-retries with backoff; if you're seeing this surfaced, retries are exhausted.

Escalate when: Your legitimate volume needs a higher per-key ceiling. Don't work around it by rotating keys (it creates more problems). Contact support with your projected volume.


Provider & charge outcomes

provider_unavailable — HTTP 502

What it means: The upstream payment provider is not responding. Transient.

Next action: wait_and_retry  ·  Retryable: yes

Likely causes: Upstream provider incident or transient connectivity issue.

Diagnose with: Capture the X-Request-Id. Retry with exponential backoff starting at ~3 seconds (the SDK auto-retries on 502). If it still fails after 2–3 retries, contact support with that ID.

Escalate when: Persistent for >10 minutes across multiple sessions while the upstream provider's status is green.


provider_charge_failed — HTTP 402

What it means: The card was declined or the charge was rejected by the issuer/provider. This is a buyer-side outcome, not an integration bug.

Next action: no_action (terminal, expected)  ·  Retryable: no

Likely causes: Insufficient funds, card blocked, fraud-prevention rejection by the issuer.

Diagnose with: Surface the decline to the buyer and offer another payment method. Do not retry the same card.

Escalate when: Never on this code — it's the issuer's call. If every transaction fails, that's a config issue (merchant_not_configured), not a per-charge decline.


Session & embedded checkout

HTTP 410 — two opposite outcomes (branch on the code)

A 410 can mean two things with opposite remediations. Read the code, never just the status.

session_expired — HTTP 410

What it means: The session passed its TTL, or it ended with no successful charge (failed / cancelled).

Next action: create_new_session  ·  Retryable: no

Likely causes: The session sat unpaid past its TTL (configurable at create — default 30 minutes, range 5 minutes to 7 days), or the buyer's payment failed/was cancelled.

Diagnose with: Create a fresh session via sessions.create() with the original parameters. Sessions cannot be extended.

Escalate when: Never.

session_already_completed — HTTP 409

What it means: A completion call landed on a session that already succeeded — the buyer was charged exactly once and it's recorded. A distinct 409 Conflict (vs session_expired's 410 Gone), with the opposite remediation.

Next action: no_action (already done)  ·  Retryable: no

Likely causes: A duplicate or late call after the payment already went through — a retry, a double-submit, or a buyer reloading the page after paying.

Diagnose with: Do not retry and do not create a new session — either re-charges the buyer. Treat it as success: read the outcome from your payment_intent.succeeded / charge.succeeded webhook or GET /v1/public/sessions/:id (status succeeded). If your UI showed the buyer an error here, that's the bug to fix — surface a "payment complete" state instead.

Escalate when: The buyer was charged but you have no succeeded record on retrieve or webhook after a few minutes (then it's a recording issue, not this).


Buyer charged twice on an embedded checkout

What it means: A buyer was charged two times for a single embedded checkout submit.

Next action: fix_request (remove the duplicate charge call)  ·  Retryable: no

Likely cause: Your account uses the charge-and-save flow — the embedded checkout (Vora / Embedded Fields) charges on submit and, with a buyer on the session, also vaults a reusable vp_pmt_* in one step. If your integration then also calls POST /v1/payment_intents with the result, the buyer is charged a second time.

Diagnose with: Inspect the submit result. On the charge-and-save flow a successful submit resolves to { token } (buyer attached) or { charged: true } (guest / no buyer) — it has already moved money. Any subsequent POST /v1/payment_intents for that same session is a double-charge.

const result = await collection.submit();
if (result.error) {
// handle the error
} else if (result.token) {
// a reusable vp_pmt_* was vaulted AND the buyer was charged — do NOT charge again
} else if (result.charged) {
// the buyer was charged (guest path) — do NOT charge again
}

Fix: Remove the POST /v1/payment_intents call for that session — the embed already charged. Confirm settlement via the webhook before fulfilling; the client result is a UX signal, not a settlement guarantee.

Escalate when: Never. This is an integration fix.


Submit succeeded but result.token is undefined

What it means: An embedded checkout submit resolved without an error, but result.token is undefined, so there's nothing to vault.

Next action: fix_request  ·  Retryable: no

Likely cause: one of two, and they need opposite fixes.

  1. No buyer on the session (charge-and-save). The embed charges once and saves nothing — the result is { charged: true }, not { token }. There is no reusable vp_pmt_* to read.
  2. The card is still being authorised (charge-at-submit). A card is vaulted only once it is actually charged, so a submit that resolves chargeStatus: "requires_action" or "pending" carries no token yet — even with a buyer attached. A card sent through 3-D Secure always lands here, because the challenge finishes after submit() has already returned.

Diagnose with: read chargeStatus before concluding anything from a missing token. On a charge-at-submit session requires_action and pending match neither token nor charged, so a three-way branch drops them silently:

const result = await collection.submit();
if (result.error) {
// tokenization / charge failed
} else if (result.chargeStatus === "requires_action") {
// Not charged yet, so not vaulted yet. Redirect to result.redirectUrl;
// read the saved card back after settlement (see the fix below).
} else if (result.chargeStatus === "pending") {
// Authorised, settling asynchronously — the token arrives after settlement.
} else if (result.token) {
// buyer attached, charge completed — a reusable vp_pmt_* was vaulted
} else if (result.charged) {
// guest path — charged once, nothing vaulted; result.token is undefined by design
}

Fix: depends which cause you're in.

  • Cause 1 (guest): attach a buyer to the session so the submit returns { token }.
  • Cause 2 (not settled yet): the token is not lost — attaching a buyer changes nothing, because you already have one. Once the payment reaches a settled state, read payment_method_id from GET /v1/payment_intents/{id} using result.paymentIntentId. That is the same saved-card handle the charge response would have carried.

In neither case is the missing token frame_tokenization_failed — the charge did not fail.

Escalate when: Never. This is an integration fix.


Connected-platform orders

These codes come from POST /v1/sessions and POST /v1/payment_intents when the request describes a connected-store order — with order / mirrorTo, or with a raw mirror block — so the charge also creates the matching order in your store. The block itself is documented in Connected Platforms.

Every code in this section refused this request before the charge ran, including the one 503. This call authorized nothing and captured nothing, so do not reconcile it as a maybe-charge.

One carve-out: upsell_duplicate means an earlier charge already went through and its line was already added to the store order. There money did move and the order does exist. Look up the payment named in original_payment_intent_id and treat it as the outcome. For every other code here, there is no payment to find.

Three further codes are listed above without a full recipe below, because each has a single obvious fix:

  • mirror_dual_specification (hosted checkout) - you sent the store block both as the top-level mirror object and as a text value inside metadata. Keep the top-level object and delete the one in metadata. Putting the block in metadata is not a working alternative: values there are text, and a text block is never read, so the payment succeeds and no store order is ever created.
  • mirror_alias_conflict (direct charge) - you sent a block in both places and the two disagree. Identical copies are accepted; only a genuine difference is refused, because picking one silently could attach the wrong order. Send one.
  • mirror_sandbox_email_not_reserved - the store block must use a reserved test address (@example.com, @example.org, @example.net, or an address ending .test / .invalid / .localhost). Switching to a live-mode key does not lift this. The rule fires when either the account is a sandbox/playground account or the request used a test-mode key — so a sandbox account is caught in both key modes. Only a live account charging on a live provider is exempt. If you're on a sandbox account, use a reserved address rather than hunting for a different cause.

Two of these checks do not run on a raw mirror block with destination: "shopify" sent to POST /v1/payment_intents: the store-authorization check and the store-permission check are skipped on that one path. An unconnected store or a missing permission surfaces there as a successful charge with no store order, rather than as an error. The hosted flow checks both up front.

CodeHTTPNext actionRetryable
mirror_shop_not_authorized400fix_requestno
mirror_shop_missing_scopes422fix_requestno
mirror_too_large400fix_requestno
mirror_malformed400fix_requestno
mirror_upsell_unsupported400fix_requestno
mirror_line_missing_product_ref400fix_requestno
mirror_voucher_disabled400fix_requestno
mirror_voucher_rejected400fix_requestno
mirror_voucher_amount_mismatch400fix_requestno
mirror_voucher_unverifiable503retryyes
mirror_shopify_shipping_unsupported400fix_requestno
mirror_shopify_voucher_unsupported400fix_requestno
order_total_mismatch400fix_requestno
order_mirror_conflict400fix_requestno
order_model_disabled400contact_supportno
upsell_duplicate409no_actionno
mirror_dual_specification400fix_requestno
mirror_alias_conflict400fix_requestno
mirror_sandbox_email_not_reserved400fix_requestno

No error, but no order either? On most accounts the store order is created after the charge settles, so a clean 2xx isn't proof it landed. (On an account using order-before-charge the order is created first — see created does not mean paid before acting on a created there.) Poll GET /v1/public/sessions/{sessionId}/mirror-order (hosted checkout) or GET /v1/payment_intents/{id}/mirror-order (direct charge). The status is one of created, pending, failed, or not_mirrored. not_mirrored is terminal, so stop polling, but it does not prove no order exists. It covers three different situations: no store block was attached; the mirror was recorded but never sent (store disconnected, access revoked, mirroring turned off, or more than one store connected); or an order was created and later cancelled in your store. order_number is empty in all three, so the response alone cannot tell them apart. Check the store before you reconcile or refund. In particular, do not issue a second refund on the strength of this status. Note the two routes differ on one case: a direct charge carrying no store block at all reports pending, not not_mirrored, so cap your poll attempts rather than waiting for a terminal value that will not arrive.

mirror_shop_not_authorized — HTTP 400

What it means: The store you named is not a connected, active store on this merchant, so the order can't be mirrored to it. A merchant cannot mirror orders into a store they don't own.

Next action: fix_request  ·  Retryable: no

Likely causes (ranked):

  1. The store was never connected. Connect-first is required: the store has to be in the merchant's active list before any mirror can reference it.
  2. Wrong host or a typo. The value must be the exact store host: acme.myshopify.com (Shopify) or acme.29next.store (Next Commerce).
  3. The connection was revoked. An uninstall or a disconnect leaves the store inactive.
  4. The store name is right but the connection is not the one you think. Re-check which store the account is actually connected to.

Not a cause of this error, but check it anyway: connecting a second Shopify store stops mirroring for the whole account. That failure looks completely different - the charge succeeds, you get no error at all, and no order appears on either store while the mirror status reports not_mirrored. If you are getting this 400, that is not what is happening to you.

Diagnose with: Compare the mirror.shop value you sent against the stores listed in the Vora dashboard under Connected Platforms. Connect that exact host, then retry. Next Commerce supports several stores on one account and routes each charge to the store you name; Shopify does not, so keep exactly one active Shopify connection and disconnect the rest.

Escalate when: The dashboard lists the store as connected and active, the host matches character for character, and the call still fails.


mirror_shop_missing_scopes — HTTP 422

What it means: The connected Shopify store named in the request is missing a permission the order mirror needs, so the store cannot create the order. We refuse before charging the buyer rather than charge and then fail to mirror.

Next action: fix_request  ·  Retryable: no

Likely causes:

  1. The store was connected before the permission set was finalized and needs a fresh grant.
  2. A permission was declined or reduced during install or re-install.

Diagnose with: The permissions we're missing are listed in missing_scopes on the response. Re-connect the store in the Vora dashboard under Connected Platforms and grant write_orders (to create the order) and write_customers (to attach the buyer). This is a store-side re-grant, not a request-field change. Editing the request body will not clear it.

Escalate when: You re-granted both permissions, the connection reads as healthy, and the call still returns this code.


mirror_too_large — HTTP 400

What it means: The serialized mirror block exceeds the 4096-byte envelope cap.

Next action: fix_request  ·  Retryable: no

Likely causes (ranked):

  1. An address was packed into customer. Only email / first_name / last_name belong there.
  2. Free-text descriptions on line items. Move long copy into metadata.
  3. Store-side objects pasted in wholesale, carrying fields the block doesn't need.

Diagnose with:

// Measure the block before you send it
console.log("mirror bytes:", JSON.stringify(mirror).length); // must be <= 4096

Trim line_items titles, drop the optional customer.first_name / customer.last_name, or move the bulk into metadata. Note the scope: the cap is enforced on hosted POST /v1/sessions for every destination, but on POST /v1/payment_intents only for Next Commerce mirrors. A direct-charge Shopify mirror is not size-checked at the API boundary, so keep it under 4096 bytes yourself there.

Escalate when: Never. This is always a code fix.


mirror_malformed — HTTP 400

What it means: Two different causes, both on the direct-charge flow, both refused before the charge so the buyer is never charged for an un-mirrorable request. (1) metadata.mirror arrived as a non-object (a string or a number), which cannot be a mirror block. (2) The block contains the reserved key _gdpr_redacted somewhere inside it.

Next action: fix_request  ·  Retryable: no

Likely causes:

  1. A client-side serialization bug (cause 1, and by far the more common). The mirror object was String()-coerced (which yields the literal "[object Object]"), template-interpolated, or JSON.stringify'd into a string.
  2. A reserved key (cause 2). _gdpr_redacted marks an order whose buyer data was erased on a privacy request. It is written only by the erasure process and may never be supplied by a caller, so any request carrying it is refused regardless of its value, at the top level or nested anywhere in customer, metadata, an address, or a line item.

Diagnose with:

// Cause 1: must log "object", never "string"
console.log(typeof body.metadata.mirror);

For cause 1, send metadata.mirror as a nested JSON object ({ destination, shop, customer, line_items }). For cause 2, the error message names the exact path where the key was found. Rename or remove it there; if you mirror your own privacy state into the block, carry it under a different key name. Don't audit your serialization for cause 2: the block shape is fine, the key name is the problem.

Escalate when: Never. This is always a code fix.


mirror_upsell_unsupported — HTTP 400

What it means: A Shopify mirror carried an upsell append (parent_order_ref). Shopify mirrors can only create a new order, never append a line to an existing one, so this would charge the buyer and never reach the store. Refused before the charge. Next Commerce supports append; Shopify does not.

Next action: fix_request  ·  Retryable: no

Diagnose with: Remove parent_order_ref / upsell_key and mirror the item as its own new order, or route the upsell to a connector that supports append (Next Commerce).

If you branch on the error code, do not match on mirror_upsell_unsupported alone. This exact code is only returned for a raw mirror block on POST /v1/payment_intents. The same combination sent as a raw block on hosted POST /v1/sessions is refused with the same 400 for the same reason but carries the generic code validation_error. The message is what identifies it, so a hosted caller will never match the specific code.

Escalate when: Never. This is a request-shape fix.


mirror_line_missing_product_ref — HTTP 400

What it means: A Next Commerce mirror has a line whose catalog id is absent or is not a positive integer. Next Commerce requires the store's variant id on every line; Shopify mirrors don't. Validated before the charge on both POST /v1/sessions and POST /v1/payment_intents, so the buyer is never charged for a line that can't be mapped.

Next action: fix_request  ·  Retryable: no

Diagnose with: The offending index is in line_index on the response. Set the catalog id on every line:

  • Raw block: mirror.line_items[].external_product_ref, the variant id as a positive-integer string (e.g. "76").
  • Order model: order.lineItems[].variantId, the same id.

sku is not a fallback on Next Commerce. A line carrying only sku keeps failing this check no matter how valid the id looks, because a sku can resolve to a different product and would attach the wrong item after the buyer is charged. Only the variant id counts there. (Shopify accepts either field, and doesn't require one at all.)

Escalate when: Never. This is a request fix.


Store discounts: four codes, and only one is retryable

A mirror.voucher is a real, store-priced discount: the connected store recomputes the order total, so we ask the store to price the basket before the card is touched. Four codes come out of that check. All four refuse the request before the charge, but they need three different responses:

  • mirror_voucher_disabled (400): the flow can't honor a store-priced voucher at all. Change approach.
  • mirror_voucher_rejected (400): the store answered and refused the code. That's a verdict; don't retry on a timer.
  • mirror_voucher_amount_mismatch (400): the store priced it differently to you. Re-quote and resend.
  • mirror_voucher_unverifiable (503): the store couldn't be reached to price the basket. The only retryable one.

The 503 is not an ambiguous outcome. A 5xx from a charge endpoint normally means "this may or may not have gone through". This one doesn't. The verification check fails closed on purpose: rather than charge a total nobody verified, we refuse before any money moves. Nothing was authorized, nothing was captured, no store order exists. Treat it as a clean pre-charge decline and retry it. Do not reconcile it as a maybe-charge, and do not search for a payment to void or refund.

Only Next Commerce prices vouchers. A voucher on a Shopify mirror is not applied by the store and not verified, so charge Shopify mirrors at the full line-item total.

mirror_voucher_disabled — HTTP 400

What it means: The request carried mirror.voucher on hosted checkout (POST /v1/sessions), which does not support store-priced vouchers. A hosted charge bills the amount declared at session-create and never re-checks a discounted total with the store, so accepting a voucher there would bill an amount nobody verified.

Next action: fix_request  ·  Retryable: no

Diagnose with: Move the charge to POST /v1/payment_intents and send the voucher there, or use mirror.coupon, which records the code on the order as a display-only label and never changes any total.

It is refused rather than ignored on purpose: silently dropping the voucher would charge the discounted total you intended while the store recorded the full price. So do not work around it by removing the voucher and charging the discounted amount anyway. The mirrored order is created at full price and will permanently disagree with the money taken. To discount without this feature, send full-price lines, let the store's own promotions engine price the order, and charge the total it returns.

Escalate when: Never. Move the voucher to the direct-charge call, or record the code with mirror.coupon.

mirror_voucher_rejected — HTTP 400

What it means: The connected store answered and refused the code: it's unknown, expired, or not applicable to these items. This is a verdict, not a hiccup: re-sending the identical request returns the same answer until the store's own promotion changes.

Next action: fix_request  ·  Retryable: no

Diagnose with: Treat it as an invalid discount code and say so to the buyer. Confirm in the connected store that the code exists, is active, and applies to these products, then retry. To complete the sale now, drop mirror.voucher and charge the undiscounted total. Do not retry on a timer; a backoff loop here just replays the same refusal.

Escalate when: The code is active in the store and applies to those products, and it's still refused.

mirror_voucher_amount_mismatch — HTTP 400

What it means: The store priced the basket with the code applied and its total does not equal the amount you're charging. The store owns the discount arithmetic (it excludes shipping from the discount base and applies its own rounding), so a discount computed on your side will not reliably match.

Next action: fix_request  ·  Retryable: no

Likely causes (ranked):

  1. Pre-discounted line prices. The usual cause: you subtracted the discount yourself, then the store subtracted it again.
  2. Local rounding that doesn't match the store's.
  3. Shipping folded into the discount base, which the store excludes.

Diagnose with: Re-quote and resend. Call POST /v1/mirror/quote with the same lines and the same code, then charge item_total plus the shipping you will send, or supply shipping_amount on the quote and charge the amount_to_charge it returns. Send full-price line_items and let the store subtract.

Escalate when: The quote total and the amount you charge agree and it still fails.

mirror_voucher_unverifiable — HTTP 503

What it means: We could not get an answer from the connected store about the discounted total: the store was unreachable, throttled, timed out, or cannot price codes. This is not a verdict on your code or your amount, and it is not an ambiguous charge: the request was refused before any money moved.

Next action: retry  ·  Retryable: yes

Diagnose with: This is the one voucher error where the identical request can succeed unchanged. Retry with backoff, re-sending the same body with the same Idempotency-Key. Do not reconcile it as a maybe-charge, do not go looking for a payment to void or refund, and do not get past it by dropping the voucher and charging the discounted amount.

Escalate when: It persists across retries. That points at the store connection, not at your request.


mirror_shopify_shipping_unsupported — HTTP 400

What it means: You sent a shipping amount on a Shopify mirror. Shopify does not carry that amount onto the created order, so the store's total would be short by exactly the shipping and would permanently disagree with what the buyer paid. It is refused before the charge rather than creating an order whose recorded total is wrong.

Next action: fix_request  ·  Retryable: no

Diagnose with: Send shipping as a line item instead — a line whose price is the shipping cost:

"order": { "lineItems": [
{ "name": "Trail Runner - Size 10", "quantity": 1, "unitAmount": 3499, "sku": "TR-10" },
{ "name": "Standard shipping", "quantity": 1, "unitAmount": 599 }
]}

The lines then add up to the charge amount, and the store order totals what you took.

Escalate when: Never. This is a request-shape fix.


mirror_shopify_voucher_unsupported — HTTP 400

What it means: You sent a store-priced voucher on a Shopify mirror. Shopify does not price the code, so the order would be created at full price while the buyer is charged the discounted amount — a permanent mismatch between the order and the money.

Next action: fix_request  ·  Retryable: no

Diagnose with: Use mirror.coupon to record the code as a display-only label without changing any total, or charge the full line-item amount. Store-priced vouchers are Next Commerce only.

Do not work around it by dropping the voucher and charging the discounted amount anyway — that produces exactly the mismatch this check exists to prevent.

Escalate when: Never. This is a request-shape fix.


order_total_mismatch — HTTP 400

What it means: You sent the order model (order + mirrorTo) and the order doesn't add up to the amount you're charging. In minor units, the sum of order.lineItems[].unitAmount × quantity — plus order.shipping.amount on Next Commerce, where that field is accepted — must equal the top-level amount. On Shopify shipping is a line item, so the lines alone carry the whole total. A coupon is display-only and excluded.

Next action: fix_request  ·  Retryable: no

Likely causes (ranked):

  1. A coupon subtracted locally. A coupon records a code for reference and never reduces the charge, so don't take it off amount.
  2. Shipping charged but not declared in order.shipping.amount (or declared but not charged).
  3. A per-unit price or rounding error on one line.

Diagnose with: The response carries derived_amount (what the order adds up to) and charge_amount (what you sent). Fix whichever is wrong, then retry.

Escalate when: Never. This is always a code fix.


order_mirror_conflict — HTTP 400

What it means: The request carries both the order / mirrorTo order model and a raw mirror block (in mirror or metadata.mirror). They describe the same store order two ways — the order model compiles into exactly that raw block internally — so accepting both would mean silently picking one and attaching a possibly-wrong order to the charge.

Next action: fix_request  ·  Retryable: no

Diagnose with: Send one. Keep order / mirrorTo and drop the raw block, or keep the raw block and drop order / mirrorTo. Usually one of the two is a leftover from an earlier integration path, or a shared request builder is adding a block the caller already set.

Escalate when: Never. This is a request-shape fix.


order_model_disabled — HTTP 400

What it means: This deployment is not serving the order / mirrorTo order model. It's refused rather than ignored, because ignoring it would charge the buyer with no store order created. This request charged nothing.

Next action: contact_support  ·  Retryable: no

Diagnose with: Confirm you are calling the environment you expect — a sandbox or regional endpoint can differ from the one your integration was built against. If you are on the right endpoint, contact support.

Do not reshape a retry to get past this error

If this request reused an Idempotency-Key you have already sent, do not rewrite the body into a raw mirror block to work around the refusal. Reshaping strips the fields that produced the error, so the retry sails past this gate and reaches the charge carrying the same key — which is how one order becomes two charges.

Retrieve the original outcome with GET /v1/payment_intents/{id} instead, or re-send the identical request. Sending the identical body with the same key is always safe and returns the original result.

Escalate when: This code is the escalation. Ask support to enable the order model.


upsell_duplicate — HTTP 409

What it means: The upsell_key you sent already has a charge against this store, so this request is a re-fire of an upsell decision that already succeeded. Classically a timeout-then-retry where the first attempt actually landed. It fires before any charge, so this call did not charge the buyer.

Next action: no_action  ·  Retryable: no

Diagnose with: Treat the original payment as the outcome. Its id is in original_payment_intent_id on the response; read its status with GET /v1/payment_intents/{id}. Do not retry with the same key, and do not strip the key to force the call through: that re-creates the double-charge this guard exists to prevent. A genuinely new upsell decision needs a new unique key.

Escalate when: The id in original_payment_intent_id doesn't resolve to a payment you recognize.


For AI agents

If you're an AI agent (Claude Code, Cursor, GitHub Copilot, ChatGPT, etc.) reading a Von Payments error and trying to fix it autonomously, branch on the code, then use the structured surfaces below.

Option 1 — read the error envelope directly

Every API error response (and every VonPayError thrown by @vonpay/checkout-node) carries:

err.code        // canonical error code, e.g. "auth_invalid_key" — BRANCH ON THIS
err.status // HTTP status, e.g. 401
err.fix // human-imperative remediation
err.docs // canonical reference URL — this page or a sibling
err.requestId // X-Request-Id for support correlation
err.rateLimit // { limit, remaining, reset } on 429s

The raw API response body also carries a selfHeal block — retryable, nextAction (one of retry · rotate_key · fix_request · wait_and_retry · contact_support · complete_onboarding · create_new_session · no_action), and llmHint (a 1–3 sentence diagnostic written for an LLM). The per-code Next action values on this page are exactly those nextAction values. Use selfHeal.nextAction to decide what to do and selfHeal.llmHint for the most-likely root cause.

Option 2 — reproduce and verify with the MCP tools

If you're running with @vonpay/checkout-mcp loaded, use the available tools to diagnose and verify a failure:

  • vonpay_checkout_diagnose_error — pass an error code (plus optional status / requestId) and get back structured self-heal data (retryable, nextAction, llmHint, docs, agentInstructions) as pure data — no API call. Use it first to choose retry vs. rotate-key vs. fix-input vs. contact-support, then reach for the reproduce/verify primitives below.
  • vonpay_checkout_health — API + provider reachability (rules in/out provider_unavailable, auth-service issues).
  • vonpay_checkout_get_session — inspect a session's real state (e.g. confirm succeeded for a session_already_completed, or expired for a session_expired).
  • vonpay_checkout_simulate_payment — drive a sandbox outcome to reproduce a decline path.
  • vonpay_checkout_create_session / vonpay_checkout_list_test_cards — set up a clean repro.

See the MCP reference for the full tool list.

Option 3 — verify the integrator's environment with the CLI

Have the human run the real CLI (no secrets are printed):

vonpay checkout health --json          # API + provider health
vonpay checkout sessions get <id> # a session's true server-side state

Then confirm (by name only, never value) that the expected env var (VON_PAY_SECRET_KEY) is set and that its prefix mode (vp_sk_test_ vs vp_sk_live_) matches the URL being hit. See the CLI reference.

What you should NOT do

  • Do not retry the same call when retryable: false. The error is deterministic; the next call will fail identically.
  • Don't "create a new session" on a terminal session without reading the code. session_already_completed (409) means the buyer already paid — a new session re-charges them; only session_expired (410) warrants a new session.
  • Don't reconcile a refused connected-store charge as a maybe-charge. Every mirror_* / order_* code, including the 503 mirror_voucher_unverifiable, is refused before any money moves. There is no payment to look up, void, or refund. Retry only the 503; the rest need a request or store-connection fix first.
  • upsell_duplicate is the exception. Do not resend it. It means an earlier charge already succeeded and its line is already on the store order. There is a payment to look up: the one named in original_payment_intent_id. Treat that payment as the outcome and take no further action. Do not fix the body and retry, and do not drop the idempotency key, either of which charges the buyer a second time.
  • Do not surface raw API key values to the human or in your context. The SDK + CLI both redact prefixes; preserve that.
  • Do not invent error codes that aren't in the error-codes catalog. If you see a code you don't recognize, treat it as contact_support with the requestId.

Contacting support

When the recipe above says contact_support or you've ruled out an integrator-side fix, open a ticket through one of the channels below. Always include the X-Request-Id from the failing response — every Von Payments error envelope carries one, and our triage flow is keyed off it.

  • Status pagestatus.vonpay.com. Check here first for ongoing incidents before opening a ticket.
  • Emailsupport@vonpay.com for production issues; engineering@vonpay.com for SDK / API / spec-level questions.
  • Dashboard/dashboard/support (when signed in to app.vonpay.com) — preferred for merchant-account issues since it auto-attaches your merchant context.

What to include in the ticket: the X-Request-Id(s) from one or more failing responses, the time window, the API key prefix (vp_sk_test_xxxx…yyyy — never the full key), the SDK + version you're using, and a one-paragraph description of the expected vs. actual behavior. Tickets with an X-Request-Id get a first-pass triage SLA; tickets without one fall back to the general queue.