Troubleshooting
When the SDK throws or an API call returns a non-2xx, the response carries a structured code. Branch on the code, not just the HTTP status — session_expired (410, create a new session) and session_already_completed (409, the buyer already paid or is being charged right now; a new session re-charges them) need opposite fixes. This page is the diagnosis for the codes you will hit most often: likely causes ranked, the check that settles it, and when to escalate. The complete catalogue is on Error codes. Next action on each entry is the value the API returns in selfHeal.nextAction.
Start here
Quick reference
| Code | HTTP | Next action | Retryable |
|---|---|---|---|
auth_invalid_key | 401 | rotate_key | no |
auth_invalid_key_publishable | 401 | fix_request | 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 | only with the same Idempotency-Key — see below |
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.
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_charge_in_progress → a charge is ALREADY in flight → ⛔ do NOT retry,
│ │ do NOT open a new session; wait for the first to return
│ ├─ frame_consent_requires_buyer → never throws; the buyer PAID and the card was
│ │ silently not saved → fix the integration
│ ├─ frame_buyer_required_for_saved_card → threw; NOTHING was charged → do NOT fulfil,
│ │ create a new session with buyerId
│ └─ 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
│ ├─ charge_in_progress (409) → ⛔ do NOT retry, do NOT open a new session,
│ │ and do NOT mint a fresh Idempotency-Key — a new key
│ │ for the same payment is what double-charges. Read the
│ │ original's status instead (session retrieve, or the
│ │ payment by id). Two scopes, one code: session-keyed on
│ │ the hosted surface; key- or saved-card-keyed on /v1/payment_intents
│ ├─ buyer_required_for_saved_card (422) → nothing charged; retry can never work → new session + buyerId
│ ├─ idempotency_key_required (400) → a refund OR a charge was refused for carrying no
│ │ Idempotency-Key (any charge after the 28 Oct 2026
│ │ cut-off; some routes already) → ADD a stable key.
│ │ Adding one is the fix; VARYING one is the double-charge
│ ├─ provider_unavailable (502) → the provider WAS called and the outcome is unknown.
│ │ on a CHARGE: retry only with the SAME Idempotency-Key.
│ │ Keyless, a retry can charge the buyer twice — reconcile.
│ │ ⛔ on a REFUND: state UNKNOWN — the reversal may have gone
│ │ through. Retrying pays the buyer twice; reconcile instead
│ └─ 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
Two things the tree cannot say in a line: blocked_by_rule is not an issuer decline — a rule on your own account refused the charge and no bank saw it, so offer another method but do not send the buyer to their bank; and a transient error on a charge is retried only with the same Idempotency-Key — the SDKs withhold their own auto-retry on money calls unless you supplied one. Per-failure_code guidance is in Decline reasons.
Auth & key errors
Keys and access errors
The request never reached your account. Nothing was charged by any code in this group.
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 grace window (
1h/24h/7d, whichever was chosen at rotation). 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: fix_request · Retryable: no
The API says fix_request, not rotate_key, because for the most common cause — a host mismatch — rotating discards a working key and costs a redeploy. Rule out cause 1 first. The tell: your server calls succeed while every browser call fails, and the key shows no recorded use on the host you expected; a bad or revoked key fails on both sides.
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?. - 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, 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 grace window — 1h, 24h or 7d, whichever was requested at rotation (24h when unspecified).
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 and you still get auth_key_expired.
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, by Von Payments or by the merchant.
- A sandbox account that is still pending approval hitting live. Test keys are scoped to sandbox merchants regardless of mode.
- The application was declined, or the account deleted.
Diagnose with: Check the merchant's status at app.vonpay.com/dashboard (if you have access). Merchant status is set by Von Payments, 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 rotated and your handler has not picked up the new value. Deliveries you answered with a 4xx in that window are not retried — see Rotating a signing secret.
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.
Onboarding & configuration
Account and onboarding errors
The key is valid but the account behind it cannot take this payment yet.
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 payment provider attached — sandbox activation did not complete.
- Live merchant whose payment provider configuration was removed.
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 complete but live keys are still gated.
Validation errors
Request and rate-limit errors
The request was refused before it reached a payment provider, so nothing was charged.
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>= 1. - 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: a route's own limit, per IP on most routes. With a secret key, the payment endpoints (/v1/payment_intents,/v1/refunds,/v1/tokens,/v1/buyers,/v1/payment_methods) share 300 / minute per key, plus a 600 / minute per-IP ceiling; without one they get 100 / minute per IP. OnPOST /v1/sessionsthe 10 / minute / IP limit applies only to calls with a publishable key or no key; a secret-key call (vp_sk_*, or a legacyvp_key_*) gets a loose 300 / minute / IP ceiling instead.rate_limit_exceeded_per_key: only onPOST /v1/sessions, 30 / 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. - Session creates from a shared address (serverless or edge workers) sent with a publishable key, so many callers share one 10/min per-IP quota. Create sessions from your server with your
vp_sk_*key.
Diagnose with: Read the Retry-After header (1 to 60 seconds) and wait that long, then retry with jittered backoff; 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
Charge and session errors
These reach the payment provider or the session, so whether money moved depends on the code — read it before you retry anything.
provider_unavailable — HTTP 502
What it means: The upstream payment provider is not responding. Transient.
Next action: wait_and_retry · Retryable: true — the API returns the same value here whether the call was a charge or a refund. On a charge, act on it. On a refund, do not:
On POST /v1/refunds a provider_unavailable (or a timeout) does not tell you the reversal was refused — it may already have gone through. Retrying it can pay the buyer twice. GET /v1/refunds lists the refunds that were recorded, but an ambiguous refund may not appear there, and it does not reach you as a refund.failed webhook. Record it as unresolved in your own ledger and settle it with support rather than re-issuing it. Full remedy: provider_unavailable.
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
Session gone — two opposite outcomes (branch on the code)
410 session_expired and 409 session_already_completed need 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 is already finished, or still finishing. Either it reached succeeded and the buyer was charged exactly once (on a captureMethod: "manual" session, authorised exactly once), or a charge for it is in flight right now and your call lost the race. The code does not say which, and an in-flight charge can still decline.
Next action: no_action (already done) · Retryable: no
Likely causes: A duplicate or late call: a retry, a double-submit, or a buyer reloading the page after paying, arriving either after the payment went through or while it is still running.
Diagnose with: Do not retry and do not create a new session, since either risks re-charging the buyer. Read the outcome from your payment_intent.succeeded / charge.succeeded / charge.failed webhook and show the buyer whatever it reports, which may be a decline. Do not mark the order paid on the strength of this code alone, and do not settle it by polling GET /v1/public/sessions/:id: while a charge is in flight that endpoint answers 200 with no charge-status field, so finished and unfinished look identical from the browser. On a captureMethod: "manual" session, read the payment intent rather than waiting for a webhook. Mind which object you are reading: on a payment intent authorized means the money is held and yours to capture, while succeeded means it has already moved. If your UI showed the buyer a generic error here, surface the outcome state instead.
Escalate when: The buyer was charged but no charge.* webhook and no succeeded record arrives after a few minutes (then it's a recording issue, not this).
Something looks wrong, but you got no error
No failure was reported, and the outcome is still not what you expected.
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
requires_actionresult carries no token — the card is vaulted when the charge completes, and every 3-D Secure card lands here because the challenge finishes aftersubmit()returns;pendingmay not carry one yet. Read the card back from the payment intent oncharge.succeeded.
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 codes have a single fix and no recipe below:
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 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.
Connected store errors
These only fire when you are mirroring orders into a connected store. Every one of them is refused before the charge, so the buyer is never charged when you see one.
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.
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. Both platforms support several stores on one account; each charge routes to the store you name.
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:
- 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. The cap is enforced on hosted POST /v1/sessions for every destination and on order / mirrorTo on both routes; only a raw Shopify mirror block on POST /v1/payment_intents is not size-checked — keep that one under 4096 bytes yourself.
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.
mirror_voucher_disabled — HTTP 400
What it means: Store-priced vouchers are not available on this call. On POST /v1/sessions that is always the case — a hosted charge bills the amount declared at session-create and never re-checks a discounted total; on POST /v1/payment_intents it means the platform has store-priced vouchers switched off. (A Shopify voucher on the hosted route fails schema validation first and returns validation_error.)
Next action: fix_request · Retryable: no
Diagnose with: On the hosted route, move the charge to POST /v1/payment_intents and send the voucher there; on the direct charge, the feature is off. Either way mirror.coupon records the code as a display-only label, or send full-price lines, let the store's own promotions engine price the order, and charge the total it returns.
Do not work around it by removing the voucher and charging the discounted amount anyway — the mirrored order is created at full price and permanently disagrees with the money taken.
Escalate when: It comes back on POST /v1/payment_intents — that is the platform switch, not your request.
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 check fails closed and the request was refused before any money moved — nothing to void or refund.
Next action: retry · Retryable: yes
Diagnose with: The one voucher error where the identical request can succeed unchanged: retry with backoff, same body, same Idempotency-Key. 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. Refused before the charge. This code is the raw block's on POST /v1/payment_intents; on POST /v1/sessions and on order / mirrorTo the same refusal returns validation_error.
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. Refused before the charge. This code is the raw block's on POST /v1/payment_intents; on POST /v1/sessions the same refusal returns validation_error, and order / mirrorTo has no voucher field at all (validation_unknown_field).
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.
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.
Working with an AI agent
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.
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 · reconcile · 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.
To reproduce or verify, use the MCP tools (vonpay_checkout_diagnose_error, vonpay_checkout_health, vonpay_checkout_get_session) or have the human run the CLI (vonpay checkout health --json, vonpay checkout sessions get <id>). Never surface a raw API key value to the human or into your context — the SDK and CLI redact all but the prefix; preserve that. Do not invent error codes that are not in the catalog; an unrecognised code is contact_support with the requestId.
Getting support
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 session (vp_cs_*) or transaction (vp_tx_*) id, 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. For anything about a buyer — a duplicate charge, "is this the same person?" — include the buyerId and buyerEmail you sent on the session; a stable per-user buyerId is what lets us link a buyer's transactions (Buyer identification).
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