Skip to main content

Troubleshooting

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

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

How to read this page

Every entry lists:

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

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

Quick reference

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

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


What to include when you contact support

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

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

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


Recover from a failed charge

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

SurfaceWhat you're holdingBranch on
Client (Embedded Fields SDK)result.error (a VoraMirrorError) from submit()error.code
Server (API)a non-2xx response with a codeerror.code
Webhook (async)a charge.failed / payment_intent.failed eventdata.failure_code
A failed charge
├─ Client SDK (result.error)
│ ├─ frame_3ds_challenge_failed / _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, any failure_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 the X-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):

  1. Env var unset or misnamed. Check VON_PAY_SECRET_KEY in your environment. The SDK looks here by default.
  2. Key has rotated past its 24h grace. A previously-valid key was rotated and the grace window expired. The old key is permanently dead.
  3. Test/live mismatch. A vp_sk_test_* key is hitting checkout.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:

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

Diagnose with:

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

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

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


auth_merchant_inactive — HTTP 401

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

Next action: contact_support  ·  Retryable: no

Likely causes:

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

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

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


webhook_invalid_signature — HTTP 401

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

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

Likely causes (ranked):

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

Diagnose with:

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

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


Onboarding & configuration

merchant_not_configured — HTTP 422

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

Next action: complete_onboarding  ·  Retryable: no

Likely causes:

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

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

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


merchant_not_onboarded — HTTP 403

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

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

Next action: contact_support  ·  Retryable: no

Likely causes:

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

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

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


Validation errors

validation_invalid_amount — HTTP 400

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

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

Likely causes (ranked):

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

Diagnose with:

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

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


validation_error / validation_missing_field — HTTP 400

What it means: Request body failed schema validation.

Next action: fix_request  ·  Retryable: no

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

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

Escalate when: Never. Always a code fix.


Rate limits

rate_limit_exceeded / rate_limit_exceeded_per_key — HTTP 429

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

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

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

Next action: wait_and_retry  ·  Retryable: yes

Likely causes:

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

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

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


Provider & charge outcomes

provider_unavailable — HTTP 502

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

Next action: wait_and_retry  ·  Retryable: yes

Likely causes: Upstream provider incident or transient connectivity issue.

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

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


provider_charge_failed — HTTP 402

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

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

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

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

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


Session & embedded checkout

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

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

session_expired — HTTP 410

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

Next action: create_new_session  ·  Retryable: no

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

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

Escalate when: Never.

session_already_completed — HTTP 409

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

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

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

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

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


Buyer charged twice on an embedded checkout

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

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

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

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

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

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

Escalate when: Never. This is an integration fix.


Submit succeeded but result.token is undefined

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

Next action: fix_request  ·  Retryable: no

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

See the MCP reference for the full tool list.

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

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

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

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

What you should NOT do

  • Do not retry the same call when retryable: false. The error is deterministic; the next call will fail identically.
  • Don't "create a new session" on a terminal session without reading the code. session_already_completed (409) means the buyer already paid — a new session re-charges them; only session_expired (410) warrants a new session.
  • Do not surface raw API key values to the human or in your context. The SDK + CLI both redact prefixes; preserve that.
  • Do not invent error codes that aren't in the error-codes catalog. If you see a code you don't recognize, treat it as contact_support with the requestId.

Contacting support

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

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

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