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_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.
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 / _timeout → buyer re-authenticates → re-run submit()
│ ├─ 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
│ ├─ 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, anyfailure_code) → surface a clear message and offer another method. Never silently retry the same card. - 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.
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.
- Test/live mismatch. A
vp_sk_test_*key is hittingcheckout.vonpay.com(live) or vice versa. Test keys only work in sandbox.
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_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: This is the guest / no-buyer charge-only path under the charge-and-save flow. With no buyer attached to the session, the embed charges once and saves nothing — the result is { charged: true }, not { token }. There is no reusable vp_pmt_* to read.
Diagnose with: Branch on the three-way result instead of assuming a token. The result is a discriminated union — { token } | { charged: true } | { error }:
const result = await collection.submit();
if (result.error) {
// tokenization / charge failed
} else if (result.token) {
// buyer attached — a reusable vp_pmt_* was vaulted
} else if (result.charged) {
// guest path — charged once, nothing vaulted; result.token is undefined by design
}
Fix: Branch on result.charged. If you need a vaulted token, attach a buyer to the session so the submit returns { token }. Do not treat the missing token as frame_tokenization_failed — the charge succeeded; this is the expected guest-path shape.
Escalate when: Never. This is an integration fix.
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. - 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 - 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