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 thecode. The clearest example is the pair with opposite fixes:session_expired(410 Gone, create a new session) vssession_already_completed(409 Conflict, you're done — creating a new session re-charges the already-paid buyer). Always read thecode.
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
| Code | HTTP | Next action | Retryable |
|---|---|---|---|
auth_invalid_key | 401 | rotate_key | no |
auth_invalid_key_publishable | 401 | rotate_key | no |
auth_key_expired | 401 | rotate_key | no |
auth_merchant_inactive | 401 | contact_support | no |
webhook_invalid_signature | 401 | fix_request | no |
merchant_not_configured | 422 | complete_onboarding | no |
validation_invalid_amount | 400 | fix_request | no |
validation_error / _missing_field | 400 | fix_request | no |
rate_limit_exceeded / _per_key | 429 | wait_and_retry | yes |
provider_unavailable | 502 | wait_and_retry | yes |
provider_charge_failed | 402 | no_action | no |
session_expired | 410 | create_new_session | no |
session_already_completed | 409 | no_action | no |
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-Idheader from the relevant API response. - The buyer's
buyerIdandbuyerEmail— as 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.
| Surface | What you're holding | Branch on |
|---|---|---|
| Client (Embedded Fields SDK) | result.error (a VoraMirrorError) from submit() | error.code |
| Server (API) | a non-2xx response with a code | error.code |
| Webhook (async) | a charge.failed / payment_intent.failed event | data.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, mostfailure_codevalues) → surface a clear message and offer another method. Never silently retry the same card.- One exception:
blocked_by_ruleis 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.
- One exception:
- 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 theX-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_duplicateis 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 theoriginal_payment_intent_idin 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):
- Env var unset or misnamed. Check
VON_PAY_SECRET_KEYin your environment. The SDK looks here by default. - Key has rotated past its 24h grace. A previously-valid key was rotated and the grace window expired. The old key is permanently dead.
- 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.comserves 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
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):
-
Your browser and server are talking to different hosts.
apiBaseUrlonnew 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.
-
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 asundefined. -
Key revoked from the dashboard. Check it still shows Active.
-
A secret key was passed instead. That throws a
TypeErrorin the constructor rather than reaching this error — but it's worth ruling out if you never see a network call at all.
Diagnose with:
-
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
apiBaseUrlor inherited the default. -
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)"); -
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:
- A deploy missed the rotation. A service is still configured with the old key. Find the deploy and update it.
- 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:
- Account suspension. Either by ops (compliance / chargeback issues) or by the merchant themselves.
- Sandbox merchant in
pending_approvalstate hitting live. Test keys are scoped to sandbox merchants regardless of mode. merchants.statusisdeniedordeleted.
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):
- 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. - 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.
- 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.
- 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:
- Sandbox merchant with no mock gateway. "Activate VORA Sandbox" didn't run cleanly, or the merchant was issued test keys without atomic provisioning.
- 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:
- Trying to create live keys before onboarding completes.
- A live API call with a merchant still in
pending_approval(you'll usually getauth_merchant_inactiveon the checkout API for this —merchant_not_onboardedis 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):
- Sending major units instead of minor units.
14.99for $14.99 is wrong; it must be1499. Float-rounding errors compound. - Zero or negative.
amountmust be>= 1for payment sessions. (Exception: setup-mode sessions —mode: "setup", used to vault a card with no charge — may sendamount: 0or omit it.) - Above the ceiling. The maximum is
99,999,999minor units (e.g.$999,999.99for USD). - Wrong type.
amountmust be a JSON number, not a string. Decimals are rejected. - 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/sessionsis 10 req / minute / IP).rate_limit_exceeded_per_key— the per-API-key limit (e.g.POST /v1/sessionsis 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:
- Burst from a single deployment — usually a retry loop without backoff.
- Missing or wrong
Idempotency-Keycausing 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.
- 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 reusablevp_pmt_*to read. - 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 aftersubmit()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_idfromGET /v1/payment_intents/{id}usingresult.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_duplicatemeans 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 inoriginal_payment_intent_idand 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-levelmirrorobject and as a text value insidemetadata. Keep the top-level object and delete the one inmetadata. Putting the block inmetadatais 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.
| Code | HTTP | Next action | Retryable |
|---|---|---|---|
mirror_shop_not_authorized | 400 | fix_request | no |
mirror_shop_missing_scopes | 422 | fix_request | no |
mirror_too_large | 400 | fix_request | no |
mirror_malformed | 400 | fix_request | no |
mirror_upsell_unsupported | 400 | fix_request | no |
mirror_line_missing_product_ref | 400 | fix_request | no |
mirror_voucher_disabled | 400 | fix_request | no |
mirror_voucher_rejected | 400 | fix_request | no |
mirror_voucher_amount_mismatch | 400 | fix_request | no |
mirror_voucher_unverifiable | 503 | retry | yes |
mirror_shopify_shipping_unsupported | 400 | fix_request | no |
mirror_shopify_voucher_unsupported | 400 | fix_request | no |
order_total_mismatch | 400 | fix_request | no |
order_mirror_conflict | 400 | fix_request | no |
order_model_disabled | 400 | contact_support | no |
upsell_duplicate | 409 | no_action | no |
mirror_dual_specification | 400 | fix_request | no |
mirror_alias_conflict | 400 | fix_request | no |
mirror_sandbox_email_not_reserved | 400 | fix_request | no |
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):
- 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.
- Wrong host or a typo. The value must be the exact store host:
acme.myshopify.com(Shopify) oracme.29next.store(Next Commerce). - The connection was revoked. An uninstall or a disconnect leaves the store inactive.
- 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 this400, 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:
- The store was connected before the permission set was finalized and needs a fresh grant.
- 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):
- An address was packed into
customer. Onlyemail/first_name/last_namebelong there. - Free-text descriptions on line items. Move long copy into
metadata. - 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:
- 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, orJSON.stringify'd into a string. - A reserved key (cause 2).
_gdpr_redactedmarks 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 incustomer,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 onmirror_upsell_unsupportedalone. This exact code is only returned for a rawmirrorblock onPOST /v1/payment_intents. The same combination sent as a raw block on hostedPOST /v1/sessionsis refused with the same400for the same reason but carries the generic codevalidation_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.
skuis not a fallback on Next Commerce. A line carrying onlyskukeeps 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
503is not an ambiguous outcome. A5xxfrom 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):
- Pre-discounted line prices. The usual cause: you subtracted the discount yourself, then the store subtracted it again.
- Local rounding that doesn't match the store's.
- 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):
- A coupon subtracted locally. A coupon records a code for reference and never reduces the charge, so don't take it off
amount. - Shipping charged but not declared in
order.shipping.amount(or declared but not charged). - 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.
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 errorcode(plus optionalstatus/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/outprovider_unavailable, auth-service issues).vonpay_checkout_get_session— inspect a session's real state (e.g. confirmsucceededfor asession_already_completed, orexpiredfor asession_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; onlysession_expired(410) warrants a new session. - Don't reconcile a refused connected-store charge as a maybe-charge. Every
mirror_*/order_*code, including the503mirror_voucher_unverifiable, is refused before any money moves. There is no payment to look up, void, or refund. Retry only the503; the rest need a request or store-connection fix first. upsell_duplicateis 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 inoriginal_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_supportwith therequestId.
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 page — status.vonpay.com. Check here first for ongoing incidents before opening a ticket.
- Email —
support@vonpay.comfor production issues;engineering@vonpay.comfor 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.
Related
- Error Codes catalog — the full
ErrorCodecatalog + the rate-limit table - Connected Platforms — the
mirrorblock, for themirror_*codes - Webhook Verification — for
webhook_invalid_signature - API Keys — for
auth_invalid_key/auth_key_expired - CLI reference —
vonpay checkout health/sessions getfor verification - MCP reference — the tools an AI agent can call