Error Codes
Errors from the /v1 API return JSON with error, code, fix, docs, and selfHeal fields, plus an X-Request-Id response header.
{
"error": "Human-readable error message",
"code": "error_code",
"fix": "Suggested action to resolve the error",
"docs": "https://docs.vonpay.com/reference/error-codes#error_code",
"selfHeal": {
"retryable": false,
"nextAction": "no_action",
"llmHint": "Machine-readable guidance for SDKs and agents."
}
}
The selfHeal object is always present in the standard envelope and gives SDKs and agents a machine-readable hint about whether the request is retriable and what to do next. A small number of internal responses (for example a streaming connection that closes with a bare 503) fall outside this envelope and carry neither the JSON body nor the X-Request-Id header.
HTTP Status Codes
| Code | Meaning | Common Causes |
|---|---|---|
| 400 | Bad Request | Invalid request body, missing required fields, validation failure |
| 401 | Unauthorized | Missing or invalid Authorization: Bearer token |
| 404 | Not Found | Session ID doesn't exist |
| 409 | Conflict | Session is in the wrong state (e.g., already completed) |
| 410 | Gone | Session has expired (configurable TTL — 30-minute default) |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
Error Codes Reference
| Code | HTTP | Description |
|---|---|---|
auth_missing_bearer | 401 | No Authorization: Bearer header provided |
auth_invalid_key | 401 | API key is malformed or does not exist |
auth_key_expired | 401 | Key has rotated past its grace window |
auth_missing_bearer_publishable | 401 | No Authorization: Bearer header on a browser-callable /v1/public/* route |
auth_invalid_key_publishable | 401 | Publishable key not recognized on this host — most often an environment mismatch, not a bad key. See below before rotating anything |
auth_key_expired_publishable | 401 | Publishable key is past its rotation grace window. Publishable keys reach the browser by redeploy, not by swapping an environment variable |
auth_key_type_forbidden | 403 | Publishable key used on a secret-only endpoint, or sandbox/live mode mismatch |
auth_merchant_inactive | 401 | Merchant account is disabled or suspended |
merchant_not_onboarded | 403 | Live-key creation is blocked until merchant-onboarding review completes |
live_keys_need_processor | 403 | The account is approved, but no payment processor is connected to it yet |
test_keys_are_sandbox_only | 403 | Test keys were requested on a live business; they come from its sandbox |
live_keys_not_on_sandbox | 403 | A live key was requested on a sandbox |
processor_check_unavailable | 503 | We could not confirm the account's payment setup. Temporary, and not a problem with your request; retry shortly |
auth_service_unavailable | 503 | Authentication service is temporarily unavailable |
session_not_found | 404 | Session ID does not exist |
checkout_address_not_found | 404 | The request reached a {name}.vonpay.com address that is not serving checkout. Use the checkoutUrl from POST /v1/sessions exactly as returned |
session_expired | 410 | Session expired (TTL elapsed), or a terminal session with no successful charge — create a new one |
session_already_completed | 409 | A completion call hit an already-succeeded session — the buyer was charged once (or, on a captureMethod: "manual" session, authorised once); do not retry or create a new session (would double-charge). Read the result via webhook or session retrieve |
session_wrong_state | 409 | Session is in the wrong state for this operation |
session_not_expirable | 409 | The session could not be expired and nothing changed. A payment may exist on it that has not been reported, so do not take payment another way; read the session in the response. See below |
session_not_modifiable | 409 | PATCH /v1/sessions/{id} could not change the total and nothing changed. reason says why. See below |
charge_in_progress | 409 | A charge is already in flight — for this session on the hosted surface, or for this Idempotency-Key on POST /v1/payment_intents. Do not retry, do not create a new session, and do not reissue with a fresh Idempotency-Key — on some provider configurations a second attempt is a second charge that cannot be merged afterwards. Read the original's status instead. See below |
charge_already_in_flight | 409 | Another request with this Idempotency-Key is still running. Retry with the same key after retryAfterSeconds — never with a fresh one. Nothing was charged by this request |
charge_reservation_unavailable | 503 | The pre-charge record for your Idempotency-Key could not be written; nothing reached the processor. Retry the same request with the same key |
expected_amount_mismatch | 409 | The payment was for a total the session no longer holds. The buyer was not charged. Show the current total and have the buyer approve it again. See below |
session_integrity_error | 500 | Internal session state mismatch — contact support |
validation_error | 400 | Request body failed schema validation |
currency_not_supported | 422 | The currency is not accepted for new payments yet. Nothing was charged, saved or created. Use a different currency |
validation_missing_field | 400 | A required field is missing from the request body |
validation_invalid_amount | 400 | Amount is not a positive integer or exceeds maximum |
validation_unknown_field | 400 | The body carried a field this endpoint does not accept. Refused, never ignored. Two causes: wrong casing, or a real field that belongs on a different call |
merchant_not_configured | 422 | Merchant is missing required configuration (e.g., payment provider credentials) |
binder_unavailable | 409 | Merchant has no payment provider configured for this operation — complete onboarding. A test key does not avoid it |
sandbox_account_required | 422 | A test key was used where there is no test environment: on a live account, or on a sandbox with no payment provider. Test payments run only on a sandbox account |
capability_not_supported | 422 | The merchant's payment provider does not support the requested operation — check GET /v1/capabilities |
rate_limit_exceeded | 429 | A per-IP (or route) rate limit was reached. Wait Retry-After (1 to 60 seconds), then retry with backoff |
rate_limit_exceeded_per_key | 429 | Per-API-key rate limit (30 session-creates/min). Wait Retry-After, or contact support if you need a higher ceiling |
provider_unavailable | 502 | Upstream payment provider is not responding |
payment_outcome_unknown | 502 | A capture or void reached the payment provider, but its result could not be recorded. Do not retry: a retried capture can capture twice. Read the payment intent back. See below |
provider_attestation_failed | 403 | Payment provider rejected the session-bound attestation |
provider_charge_failed | 402 | Card declined or charge rejected by the upstream provider |
provider_request_rejected | 422 | Payment provider rejected the request as invalid before any money moved (not a decline, not an outage) — fix the offending field and retry |
internal_error | 500 | Unexpected server error |
webhook_missing_signature | 401 | Inbound provider webhook is missing its signature header |
webhook_invalid_signature | 401 | Webhook signature does not match the expected value |
webhook_not_configured | 503 | Webhook verification secret is not configured on the server |
webhook_test_delivery_failed | 502 | A synchronous webhook test-send probe could not be delivered. Retriable on the raw API; the 3.6.0+ SDKs do not repeat it |
origin_forbidden | 403 | Internal endpoint called from outside the checkout page |
transaction_verification_failed | 403 | Transaction could not be verified with the payment provider |
unsupported_media_type | 415 | Content-Type header is missing or not application/json |
endpoint_not_implemented | 501 | A payment-intent operation is not yet implemented for this merchant's provider (capability gate) |
endpoint_retired | 410 | A permanently retired endpoint. No public API call returns it today |
idempotency_replay_incompatible | 422 | Idempotency key was reused with a different request body |
unexpected_redirect | n/a | Raised by the server SDKs, never sent by the API: the SDK got an HTTP redirect and refused to follow it. Do not retry; check the base URL, proxy and network |
idempotency_key_required | 400 | An Idempotency-Key header is required and none was sent. Today it applies on refunds whose payment was taken on a connection keeping no queryable record of a reversal, and on payment routes that keep no record of their own. From the 28 October 2026 cut-off it can apply to any charge on POST /v1/payment_intents. Not retryable as sent. See below |
invalid_transition | 409 | Payment intent is in a state that forbids the requested operation (e.g. capture on succeeded) |
capture_amount_exceeds_authorized | 422 | amount_to_capture is larger than the amount authorized on the intent. The response carries authorized_amount |
refund_intent_not_refundable | 422 | The parent payment intent is not in a refundable state (refunds require status: "succeeded") |
refund_amount_exceeds_remaining | 422 | Requested refund amount is greater than the remaining refundable balance |
refund_before_settlement | 422 | The payment has not settled yet, so it cannot be refunded — nothing moved. Wait for settlement, then retry the same request |
refund_currency_mismatch | 422 | Refund currency does not match the parent intent's currency |
refund_target_is_duplicate | 422 | The transaction names a duplicate record of a payment recorded under another transaction. The response names the one to use in refund_instead |
refund_target_not_refundable | 422 | The transaction cannot be refunded through the API. Contact support with its id |
payment_method_required | 422 | This merchant's payment provider requires a vaulted payment method on the request |
payment_method_not_found | 404 | The payment_method.id does not exist or does not belong to this merchant, or it is a single-use card with no buyer saved more than 24 hours ago |
saved_card_buyer_required | 422 | A card was to be saved for later use with no buyer identified. Nothing was charged or saved |
required_fields_missing | 422 | A card payment lacked buyer details your account requires. The buyer was not charged |
payment_method_consent_missing | 422 | The token was vaulted without off-session consent. Consent is write-once — collect the card again rather than trying to update the token |
buyer_required_for_saved_card | 422 | The session's provider saves the card as part of the charge and needs a buyer to save it against. Nothing was charged. The buyer is fixed when the session is created and cannot be added afterwards — create a new session with buyerId. See below |
buyer_not_found | 404 | The buyer id is unknown to this merchant (opaque — same body for a missing id, another merchant's buyer, or a test/live mode mismatch) |
buyer_email_conflict | 409 | A buyer create or update used an email that already belongs to another of your buyers with a different reference. Nothing changed; not retryable |
mirror_amount_below_line_items | 400 | The connected-store mirror's line items (plus shipping, on Next Commerce) exceed the amount being charged. Refused before the charge — the store order would be worth more than the money taken. One-sided: charging more than the lines itemise is allowed. See Connected Platforms |
refund_declined | 422 | The provider declined the refund — no money moved through this refund. If error says the payment was disputed, the buyer already has the money back through the chargeback: do not retry or refund another way. Otherwise a corrected retry is possible. See below |
service_unavailable | 503 | The endpoint is temporarily unavailable — retry with backoff |
feature_unavailable | 503 | The capability is deliberately switched off, not briefly down. Retrying will not clear it — degrade to your webhook path. See below |
rate_limit_service_unavailable | 503 | The rate limiter itself is unavailable — you have not exceeded a limit |
charge_at_submit_not_enabled | 422 | This session is not eligible for charge-at-submit — vault the card and charge server-side |
validation_url_scheme_forbidden | 400 | successUrl / cancelUrl must be HTTPS on a public host in live mode (localhost is test-mode only) |
webhook_subscription_not_found | 404 | The webhook-subscription id is unknown to this merchant |
webhook_subscription_conflict | 409 | A subscription already exists for this URL — update or delete it instead |
webhook_subscription_url_immutable | 400 | An update sent url. A subscription's URL cannot change; nothing was changed. Create a new subscription and delete the old one |
webhook_event_not_found | 404 | The webhook-event id is unknown to this merchant |
wallet_domain_not_found | 404 | The wallet-domain id is not registered to this merchant |
wallet_domain_invalid | 422 | Body failed validation — either domain is not a bare hostname, or wallet_type is not apple_pay / google_pay |
wallet_domain_wrong_type | 404 | Only Apple Pay registrations host a verification file |
wallet_domain_no_binder | 409 | No wallet-capable gateway on the account yet |
wallet_domain_rate_limited | 429 | One verification re-run per domain every 30 seconds — poll for status instead |
wallet_domain_provider_unavailable | 503 | The upstream domain-registration provider is unreachable — retry with backoff |
mirror_settle_only_disabled | 400 | mirror.settle_only was refused for this request — use parent_order_ref with the add-on's line_items instead |
Connected-platform and order-model refusals
These refuse the request before any charge, so the buyer is never charged when one fires — with one exception. A raw mirror block on POST /v1/payment_intents with destination: shopify is checked only partially before the charge: an unconnected store or a missing permission is discovered after the payment, so mirror_shop_not_authorized and mirror_shop_missing_scopes are never returned on that path — you get a completed payment and no store order. The order / mirrorTo shape is checked before the charge on both routes.
Two codes depend on the surface. A field the shared schema refuses at parse — a Shopify voucher or shipping — comes back as validation_error on POST /v1/sessions (and a Shopify shipping on order / mirrorTo likewise; that shape has no voucher field at all); only the raw block on POST /v1/payment_intents names it with a mirror_shopify_* code. Diagnosis for each — the likely cause and the exact check to run — is on Troubleshooting.
| Code | HTTP | Meaning |
|---|---|---|
mirror_malformed | 400 | The mirror block (mirror or metadata.mirror on POST /v1/payment_intents) arrived as a string or number rather than a nested JSON object — usually a client-side serialization bug |
mirror_too_large | 400 | The serialized mirror block is over the 4096-byte cap. Trim line_items, drop optional customer fields, or move long strings into metadata |
mirror_line_missing_product_ref | 400 | A mirror line item has no external_product_ref — every line needs the connected store's variant id |
mirror_shop_not_authorized | 400 | That store host is not connected to this merchant. Connect it in the dashboard, then retry with the exact same host in mirror.shop |
mirror_shop_missing_scopes | 422 | The store connection exists but lacks the permissions to write orders and customers. Re-grant the connection, then retry |
mirror_upsell_unsupported | 400 | Upsell append is not available for Shopify mirrors — drop parent_order_ref / upsell_key and send the extra item as its own order |
mirror_shopify_voucher_unsupported | 400 | Raw block on POST /v1/payment_intents only. Shopify does not price store vouchers. Use mirror.coupon for a display-only label, or charge the full amount |
mirror_shopify_shipping_unsupported | 400 | Raw block on POST /v1/payment_intents only. mirror.shipping is not carried onto a Shopify order, so the order total would be short by that amount. Fold shipping into a line_items[] entry instead |
mirror_voucher_disabled | 400 | Store-priced vouchers are not available on this call: on POST /v1/sessions always (hosted checkout never re-prices a discounted total), and on POST /v1/payment_intents while the platform has them switched off. Use mirror.coupon as a label that does not change the total |
mirror_voucher_amount_mismatch | 400 | The amount sent is not what the store prices this basket and code at. Quote it first, or omit the amount on a hosted session and let us price it |
mirror_voucher_rejected | 400 | The connected store refused the discount code — check it exists and is active there, or drop the voucher and charge the undiscounted total |
mirror_voucher_unverifiable | 503 | The store could not price the discount right now. Retryable — unlike every other voucher refusal on this list |
order_total_mismatch | 400 | The order model does not add up to the charge: line totals plus order.shipping.amount must equal amount. Coupons are display-only and do not reduce it |
order_mirror_conflict | 400 | Both the order / mirrorTo model and a raw mirror block were sent. They describe the same store order two ways — keep one |
order_model_disabled | 400 | The order / mirrorTo model is not enabled for this account. Send a mirror block instead, or ask support to enable it |
upsell_duplicate | 409 | This upsell_key already has a charge on this store. Nothing was charged again — treat the original payment named in original_payment_intent_id as the outcome. That field is omitted when the earlier payment is in the other mode (live or test) from your key |
Codes whose docs link names another page are documented there. Rate-limit buckets are on the Rate Limits page.
Validation Errors (400)
Validation errors include a descriptive message from the schema validator. For most of them the error field carries the raw validator output — a JSON array of issue objects, each with the failing field's path and a message:
Exception —
validation_unknown_field. That code'serroris a written sentence naming the offending fields, and the body carries anunknownFieldsarray alongside it. If you wrote a parser that assumeserroris always a serialized issue array, it will not handle this one — readunknownFieldsinstead.
{
"error": "[\n {\n \"expected\": \"number\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"amount\"\n ],\n \"message\": \"Invalid input: expected number, received string\"\n }\n]",
"code": "validation_invalid_amount",
"fix": "Amount must be a positive integer in minor units (cents). 1499 = $14.99",
"docs": "https://docs.vonpay.com/integration/create-session#amount-format",
"selfHeal": {
"retryable": false,
"nextAction": "fix_request",
"llmHint": "Amount must be a positive integer in the currency's minor unit (cents for USD, pence for GBP). 1499 = $14.99. Decimals or strings are rejected.",
"actions": [
{ "type": "check_format", "field": "amount", "expected_pattern": "^[1-9][0-9]*$" }
]
}
}
Common validation issues:
| Field | Rule |
|---|---|
amount | Must be a positive integer (1–99,999,999) |
currency | Must be exactly 3 characters |
country | Must be exactly 2 characters |
successUrl | Must be HTTPS (localhost exempt in sandbox/test mode) |
lineItems | Max 100 items |
metadata values | Max 500 characters each |
For POST /v1/sessions, currency and amount are always required.
Debugging
Every /v1 response includes X-Request-Id (success or error). When contacting support, include this ID for fast issue resolution.
X-Request-Id: CezuRjYK_sos
The value is a short URL-safe identifier (mixed-case letters, digits, -, and _) on most routes; webhook-subscription and webhook-event routes return a full UUID instead.
Per-code reference
auth_missing_bearer
HTTP: 401. The request did not include an Authorization: Bearer <key> header. Add the header with your vp_sk_* or vp_pk_* key. (A legacy vp_key_* key, treated as a secret key, is also accepted.)
auth_invalid_key
HTTP: 401. The API key is malformed, unknown, or has been revoked. Check the prefix (vp_sk_test_, vp_sk_live_, vp_pk_test_, vp_pk_live_) and confirm the key exists in /dashboard/developers/api-keys. If you just rotated, double-check the grace window hasn't expired.
auth_key_expired
HTTP: 401. The key has rotated past its grace window (default 24 hours, configurable per rotation). Distinct from auth_invalid_key so SDKs can detect rotation and fetch a fresh key instead of failing the request. Get a fresh key from /dashboard/developers/api-keys.
The _publishable variants
The browser-callable /v1/public/* routes return _publishable counterparts of
the three key errors above — auth_missing_bearer_publishable,
auth_invalid_key_publishable, auth_key_expired_publishable. Same HTTP
statuses, same meanings; the self-heal envelope steers you toward a
publishable key. For auth_key_expired_publishable, remember a publishable
key reaches the browser inside your built bundle, so replacing it takes a
redeploy, not an environment-variable change.
auth_invalid_key_publishable
HTTP: 401. Check the environment before the key. A publishable key is
scoped to the host that issued it, and the browser SDK's apiBaseUrl option
defaults to production — it is not inferred from the key — so a session
created against one host and a browser left on the default fails every call with
this code while the key is valid where it came from. That is why it carries
nextAction: "fix_request", not rotate_key. Diagnosis: Troubleshooting →
auth_invalid_key_publishable.
auth_key_type_forbidden
HTTP: 403. Primary cause: a publishable key (vp_pk_*) used against a secret-only endpoint like GET /v1/sessions/:id. POST /v1/sessions accepts a publishable key, but five of its fields do not: gateway, storedCredentialUse, captureMethod, setupForFutureUse and the buyer fields (buyerId, buyerEmail, buyerName, buyer) each decide something about whose money moves or where a card is stored, so a publishable-key request carrying any of them is refused rather than having the field dropped. Also fires on sandbox/live-mode mismatches, in both directions: a sandbox key attempting to create a live payment session, and a live key used on a sandbox account (POST /v1/sessions, POST /v1/payment_intents, POST /v1/tokens and GET /v1/capabilities). A sandbox account holds test keys only; a live key there is refused outright and is never simulated. The fix field on the response tells you exactly what to switch to.
merchant_not_onboarded
HTTP: 403. You tried to create a live-mode API key before onboarding review completed. The message reads "Your account is being reviewed. Live keys unlock once we approve it.", or "This account is not approved for live payments." if the application was declined. Test keys come from your business's sandbox at any time; live keys can be created once the account is ready to transact. Distinct from auth_merchant_inactive (401), which fires on a previously-approved account that has since been deactivated.
A live key needs approval and a connected payment processor. This code covers the first condition only; live_keys_need_processor covers the second.
live_keys_need_processor
HTTP: 403. The account is approved, but no payment processor is connected to it yet, so a live key would have nothing to charge against. Connect one and live keys unlock; no second approval is needed. Rotating an existing live key is not gated.
test_keys_are_sandbox_only
HTTP: 403. Test keys were requested on a live business. Test keys belong to the business's sandbox: open Developers, activate the sandbox, and create them there. Test keys you already hold on the live business keep working.
live_keys_not_on_sandbox
HTTP: 403. A live key was requested on a sandbox. Switch to your live business to create live keys. Live keys you already hold keep working.
processor_check_unavailable
HTTP: 503. We could not confirm the account's payment setup in time. This is our side, not your request: nothing about the account has changed and no decision has been made. Retry shortly. A repeat is worth reporting to support.
auth_merchant_inactive
HTTP: 401. The merchant account has been disabled or suspended. Check /dashboard for status banners; contact support if unexpected.
auth_service_unavailable
HTTP: 503. The authentication service is temporarily unavailable. This is retriable — the SDK auto-retries with backoff.
session_not_found
HTTP: 404. The session ID does not exist. Sessions are scoped to the merchant; you cannot look up another merchant's session with your key. Confirm the ID was created with the same key mode you're now querying with.
checkout_address_not_found
HTTP: 404. The request reached a {name}.vonpay.com address that is not serving checkout. Send it to the host of the checkoutUrl that POST /v1/sessions returned, and use that URL exactly as returned: it is on checkout.vonpay.com unless the merchant has turned on a branded address. Building the hostname yourself is what produces this error, and retrying the same host returns it again.
session_expired
HTTP: 410. The session expired (TTL configurable at create — default 30 minutes, range 5 minutes to 7 days), or its status is failed or cancelled. A session reopened for another card after a decline is pending, not failed, and does not get this error. Create a new session. (If the session already succeeded, you get session_already_completed — HTTP 409 — instead; see below.)
session_already_completed
HTTP: 409. A completion call (POST /v1/public/tokens or …/charge), or a session retrieve, hit a session that is already finished or still finishing. The code covers two situations and does not tell you which one you are in:
- the session already reached
succeeded, so the buyer was charged exactly once (on acaptureMethod: "manual"session, authorised exactly once: readchargedon the response); or - a charge for this session is already in flight and your request lost the race to it. That charge has not finished, and it may still decline.
Do not retry, and do not create a new session for the same purchase: either would risk charging the buyer again. The right next step is the same for both situations, so you never need to distinguish them here. Read the outcome rather than assuming it, from your payment_intent.succeeded / charge.succeeded / charge.failed webhook, and show the buyer whatever it reports. That may be a decline.
Do not treat this code by itself as proof of payment. Polling GET /v1/public/sessions/:id does not settle it either: while a charge is in flight that endpoint answers 200 with no charge-status field of its own, so a completed payment and an unfinished one read alike from the browser. Your server sees the real outcome on the webhook.
This is a distinct 409 Conflict, not session_expired's 410 Gone, so even a status-only client will not "start over" into a re-charge. Branch on the code for the remediation.
charge_in_progress
HTTP: 409. A payment for this request is already under way, has already completed, or has an outcome we cannot confirm yet, and this request was refused so it is not taken twice. The code is keyed on one of two things, depending on the surface:
| Surface | What the code is keyed on | Read this to resolve it |
|---|---|---|
| Hosted checkout / Embedded Fields | the session | Retrieve the session, or wait for the charge.* webhook |
POST /v1/payment_intents | your Idempotency-Key for this merchant | Read the original payment's status |
Idempotency-KeyOn some provider configurations a second attempt becomes a second charge, and once both land nothing can merge them. A 409 here is not a key collision to route around: an Idempotency-Key names the operation, never the attempt — see Idempotency.
On POST /v1/payment_intents, depending on your account's payment connection, it has up to three causes. Each one means do not create another payment for this order:
| Cause | What the error message tells you |
|---|---|
Your Idempotency-Key was already used for a charge | Whether that charge is still running, has completed (approved or declined), or may have reached the buyer's card with no confirmed outcome. In that last case, contact support before doing anything else |
| This request reached the payment provider, and its outcome is not confirmed yet | The buyer may have been charged. A charge that settled for a different amount than requested is reversed automatically where we can identify it; where we cannot, our team is alerted and reverses it by hand. Contact support with the payment intent id to confirm |
| An earlier charge on the same saved card has not finished resolving | Nothing was sent for this request. Do not charge the card again until the earlier payment resolves |
When we can identify the earlier payment, the response carries original_payment_intent_id and the message names it: read it with GET /v1/payment_intents/{id}. It is omitted when it could not be read, or when that payment is in the other mode (live or test) from your key, so treat it as optional.
If you never received the original response and the 409 carried no original_payment_intent_id — the call reached us, the connection dropped, and you hold a key but no id — there is no lookup by Idempotency-Key, and the payment_intent.* webhooks carry the payment id rather than your key. Design for it: send your own order reference in metadata on every charge and record the Idempotency-Key against your order before you send the request, so a dropped response is recoverable from your records plus reconciliation. If it has already happened, stop and contact support with the Idempotency-Key you sent; a retry with a fresh key cannot resolve it. The same applies if the code persists beyond about a minute.
The browser SDK surfaces the session-scoped condition as frame_charge_in_progress — see Embedded Fields — errors.
A different code, one word apart — charge_already_in_flight is a 409 you do retry, with the same key:
charge_already_in_flight | charge_in_progress | |
|---|---|---|
| Retry? | Yes — same body, same key | No — do not retry, do not reissue |
| What to do | Wait the advertised interval, send it again | Read the original payment's status, or wait for the charge.* webhook |
charge_already_in_flight
HTTP: 409. Retryable — but only with the same Idempotency-Key. Another request carrying this key is still running, or an earlier one stopped before we could record its outcome; we refused rather than send a second charge for the same key. This request did not charge the buyer.
Wait retryAfterSeconds (carried on the response) and retry with the same request body and the same Idempotency-Key. A fresh key is the one action that turns this into a double charge — an earlier attempt on the existing key may already have charged the buyer. When we know which earlier attempt holds the key, and it is in the same mode (live or test) as your key, the response also carries original_payment_intent_id: read that payment with GET /v1/payment_intents/{id}. If the code keeps coming back for more than about a minute and no id was returned, stop: there is no lookup by Idempotency-Key, so contact support with the key you sent. Contrast with charge_in_progress, which you do not retry at all.
charge_reservation_unavailable
HTTP: 503. Retryable — with the same Idempotency-Key. The durable record we write for your key before charging could not be written, so the request was refused. The buyer was not charged — nothing was sent to the payment processor, so there is no in-flight attempt to collide with. Retry after retryAfterSeconds with the same request body and the same key.
A charge sent without an Idempotency-Key is reserved too and can get this code. Retry the same body, and send a key this time: without one, a retry is treated as a new payment. See Idempotency.
expected_amount_mismatch
HTTP: 409, from a wallet payment (POST /v1/public/tokens) or a card payment (POST /v1/public/sessions/{sessionId}/charge). The buyer was not charged: the refusal happens before anything reaches the payment provider. expected_source says why:
buyer_approved: theexpected_amountsent with the payment (the total shown to the buyer) differs from the session's current total, usually because it was changed withPATCH /v1/sessions/{id}after the buyer saw it.request_start: noexpected_amountwas sent, and the total changed while the request was being processed.
The response also carries expected, actual (the session's current amount and currency) and mismatched (["amount"], ["currency"] or both). Show the buyer the current total and have them approve it again. Not retryable as-is. The express-checkout element reports this to your page as frame_wallet_total_mismatch.
session_not_expirable
HTTP: 409, from POST /v1/sessions/{sessionId}/expire. Nothing changed, and this is not a safe signal that no money moved. A session is expirable while it is pending, unpaid, and carries no recorded payment, and a declined elements + chargeAtSubmit session can be closed once it has been quiet for 10 minutes. On a declined session the response carries a reason: recent_activity (with retryAfterSeconds and a Retry-After header) means call again after that many seconds; payment_may_be_in_flight may never clear, and an unrecognised reason means the same. Any other state returns this code, including a session that expired while a payment was in progress (expiredFrom: "processing") or before the platform recorded how sessions expired (expiredFrom: null). A payment may exist for those that has not been reported yet.
Do not take payment for the order another way after a 409, and do not rely on polling the session or on a webhook to tell you what happened: there is no session-expiry event, and otherwise a payment that lands on an already-expired session is not reported today. On an elements + chargeAtSubmit session, expiredFrom: "processing" can later change to failed once the provider confirms the payment was never authorized after a timed-out bank check, and that attempt's payment_intent.failed / charge.failed webhooks announce it. Read the session object in the response, then contact support with the session id. Contrast 200, which does mean the session is closed and nothing can start on it. See Close a session so you can charge another way.
session_not_modifiable
HTTP: 409, from PATCH /v1/sessions/{sessionId}. Nothing was changed. The response carries reason and a session object (id, status, paymentStatus):
not_open: a payment is in progress, the session was paid, held, declined or expired, or it has passed its time limit. Past its time limit, the echoedsessioncan still readpendingandunpaidfor a short while. Do not take payment for the same order another way while a payment is in progress.integration_mode: only sessions created withintegrationMode: "elements"can change their total, because hosted and embedded-form sessions fix it when the payment form loads. Create a new session with the new total.setup_session: a no-charge setup session has no total.store_order: the session is linked to a connected-store order, whose total is the store's. Change the order instead.in_page_bank_check: the buyer's bank checks the amount on the page before paying, and the charged total must match the checked one. Create a new session with the new total.
Not retryable. See Change an open session's total.
session_wrong_state
HTTP: 409. The session is in a state that forbids this operation (e.g. the session already succeeded and cannot be cancelled). Read the response body — the fix field describes the allowed state transitions.
session_integrity_error
HTTP: 500. Internal session state mismatch. Capture the X-Request-Id and contact support — not safely retriable without investigation.
currency_not_supported
HTTP: 422. The request named a currency that is not accepted for new payments yet: today IQD, LYD, ISK, MGA, CLF, UYW and UYI. How many decimal places their amounts carry is not yet confirmed, and a wrong guess would show or pass on an amount 10× or 100× off what was charged, so they are refused instead. Nothing was charged, saved or created. The body carries currency, the refused code. Returned by POST /v1/sessions, PATCH /v1/sessions/{id} and POST /v1/tokens when they send currency, and by POST /v1/payment_intents and POST /v1/mirror/quote. Payments already made in these currencies can still be read, captured, voided and refunded. Not retryable as sent: use a different currency, or contact support if you need this one.
validation_error
HTTP: 400. The request body failed schema validation. The response error field contains the validator's output — a JSON array of issue objects, each carrying the path to the bad field (for example path: ["amount"]) and a message such as Invalid input: expected number, received string. On POST and PATCH /v1/webhook_subscriptions it is also the answer when the enabledEvents list contains any unrecognised event name: the message is the fixed sentence "verify the URL and that every event type is a recognized event", nothing is stored, and no signing secret is issued. See Webhook events. On POST /v1/payment_intents it is also the answer to an Idempotency-Key starting with vp_sys: (any letter case), a prefix reserved for platform use: nothing is charged, and the message is "Idempotency-Key values starting with 'vp_sys:' are reserved for platform use. Choose a different key and retry." Send the request again with a different key.
validation_missing_field
HTTP: 400. A required field is missing. See Create a Session for the required fields.
validation_unknown_field
HTTP: 400. The request body carried one or more fields the endpoint does not accept. The error string names them in a readable sentence and unknownFields lists them.
This fires on every endpoint with a strict schema — payment intents, refunds, tokens, buyers, webhook subscriptions, sessions. Unknown fields are refused, never ignored. Two causes, in rough order of frequency:
- Casing. The session surface is camelCase —
success_url→successUrl,line_items→lineItems,unit_amount→unitAmount. Where asuggestionsmap is present it gives the canonical name for each offending key. - Right field, wrong endpoint. The field is real but belongs on a different call, and the
errorstring says where. The most common instance:setup_for_future_usesent toPOST /v1/payment_intents. Reuse consent is captured when the card is vaulted and read back automatically at charge time, so the charge call refuses it. See Preventing a double charge → common mistake.
Do not retry until the field is moved or renamed — retryable: false, nextAction: fix_request.
Wanting to attach arbitrary custom data? Nest it under the top-level
metadataobject, which is stored and round-tripped verbatim.
validation_invalid_amount
HTTP: 400. Amount is not a positive integer, is zero, or exceeds the 99,999,999 maximum. Remember: amounts are in minor units — 1499 = $14.99, not $1,499.
merchant_not_configured
HTTP: 422. The merchant has not completed onboarding for this operation — usually payment-provider credentials are not yet provisioned. Complete boarding via the merchant dashboard, or contact support.
binder_unavailable
HTTP: 409. The merchant has no payment provider configured for this operation, so the request cannot be routed to one. The merchant (not the integrator) must complete onboarding to attach a payment provider. A test key does not get around it: a test payment runs on the account's own provider test environment, so an account with no provider cannot take test payments either. To build before onboarding is done, the account holder applies for a sandbox account in the dashboard (see sandbox_account_required). Not retriable as-is.
sandbox_account_required
HTTP: 422. Test payments run only on a sandbox account backed by a payment provider's test environment. This request had no test environment to reach, for one of two reasons:
| You sent | On | Do this |
|---|---|---|
A test key (vp_sk_test_*, vp_pk_test_*) | A live merchant account | Use your sandbox account's test keys. A live account's test keys cannot take a test payment |
| A test key | A sandbox whose processor test account is not set up yet | Open the sandbox in Developer Tools and click Set up dashboard access, which finishes setting it up, then send the request again |
Returned by POST /v1/sessions, POST /v1/payment_intents, POST /v1/tokens, GET /v1/capabilities and the browser session, token and checkout calls. Nothing was charged. Retrying, or switching between test and live keys on the same account, will not help, and a live key on a sandbox account is refused with auth_key_type_forbidden.
capability_not_supported
HTTP: 422. The merchant's payment provider does not support the operation you invoked — for example partial refunds, ACH, or network tokens — even though the route exists. Call GET /v1/capabilities for the merchant's supported_operations matrix and gate capability-dependent calls on it; the matrix is provider-dependent. Not retriable without a different capability or provider.
It is also returned by POST /v1/sessions when the request asks for captureMethod: "manual" on an account whose payment connection cannot hold a payment for later capture. That refusal happens before any session exists, so nothing is charged. Create the session with automatic capture, or contact support about a connection that can hold.
rate_limit_exceeded
HTTP: 429. The generic rate-limit code, emitted on the IP-axis limiter (and the global primary limiter). Wait the Retry-After interval (1 to 60 seconds; the same number is in selfHeal.actions[].retryAfterSeconds), then retry with jittered exponential backoff. The SDK auto-retries up to maxRetries times. The per-API-key axis has its own distinct code, rate_limit_exceeded_per_key.
On POST /v1/sessions this per-IP limit (10/min) applies only to calls with a publishable key or no key. Create sessions from your server with a secret key (vp_sk_*, or a legacy vp_key_*): those calls are limited per key, plus a loose 300/min per IP, so integrations sharing one outbound address (serverless or edge workers) do not share a quota. See Rate Limits.
rate_limit_exceeded_per_key
HTTP: 429. Per-API-key bucket exceeded on POST /v1/sessions (30 session creates/min). Wait the Retry-After interval (1 to 60 seconds), then retry with jittered exponential backoff. A single key should not exceed this under normal traffic. If your integration legitimately does, contact support for a ceiling increase. Distinct from rate_limit_exceeded so SDKs can tell them apart.
provider_unavailable
HTTP: 502. Upstream payment provider is not responding.
⛔ Retriable on a charge only with the same Idempotency-Key. A 502 does not mean nothing happened — the provider was called and the response was lost, which is why the charge is recorded server-side as outcome-unknown. Send the retry with the same key you used the first time and it collapses onto the original attempt. Without a key there is nothing to collapse onto, and not every connection provides idempotency of its own, so a keyless retry can charge the buyer twice. The SDKs apply the same rule to their built-in retry: they withhold it on money-moving calls unless you supplied a key.
On a capture or void, check code before retrying: a 502 carrying payment_outcome_unknown means the provider accepted the operation, and it must not be retried.
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 arrive as a refund.failed webhook today. Record the refund as unresolved in your own ledger and settle it with support rather than re-issuing it.
payment_outcome_unknown
HTTP: 502, from POST /v1/payment_intents/{id}/capture or POST /v1/payment_intents/{id}/void. The payment provider accepted the operation, but its result could not be recorded. The body carries capture_outcome: "unknown" or void_outcome: "unknown". The money may have moved (capture), or the hold may already be released (void).
⛔ Do not retry. A retried capture can capture twice. This is the opposite of provider_unavailable, the other 502 these routes return, which may be retried with the same Idempotency-Key: branch on code, never on the status alone. Read the payment intent back with GET /v1/payment_intents/{id}; the payment webhook reports the final outcome. Do not tell the buyer a hold was released until the intent confirms it. The response's selfHeal.nextAction is reconcile, and the server SDKs never retry this code on their own from 2.11.0.
provider_attestation_failed
HTTP: 403. The payment provider rejected the session-bound attestation token during the charge step. The most common cause is amount or scope drift between session create and complete — the attested amount or scope no longer matches the session. Fix: verify the session amount matches the attestation payload, or create a new session and re-attest. Not safely retriable without investigation when the cause is a scope / integrity mismatch — capture the X-Request-Id before retrying. The specific provider-native reason (e.g. ATTESTATION_EXPIRED, ATTESTATION_INVALID, ATTESTATION_MERCHANT_MISMATCH) is included in the error message so the buyer can be shown a more specific hint in your UI.
provider_charge_failed
HTTP: 402. The upstream payment provider returned a terminal charge failure — card declined, insufficient funds, fraud-rule block, or a network-side decline (issuer, scheme, or processor). Fix: prompt the buyer to try a different payment method. Retrying the same card/session is unlikely to succeed and may trigger additional issuer-side flags. This is distinct from provider_unavailable (transient infrastructure) and transaction_verification_failed (post-charge reconciliation mismatch).
provider_request_rejected
HTTP: 422. The merchant's payment provider rejected the request as invalid before any money movement was attempted — this is neither a card decline (provider_charge_failed, 402) nor a transient outage (provider_unavailable, 502). It is a permanent request-validation failure: retrying the identical body will fail again, and because the buyer's card was never the problem, sending them for a different card fails identically. Inspect the fields a provider most commonly constrains beyond our own schema — currency (must be enabled on the merchant's provider account), amount (within the provider's minimum/maximum for that currency), and any statement-descriptor or metadata values (provider length/character limits) — then retry with corrected values. If every charge on this merchant fails this way, the merchant's provider configuration is likely incomplete; capture the X-Request-Id and escalate. Not retriable as-is.
On POST /v1/public/sessions/{sessionId}/charge (charge at submit) this code has one dominant cause: the provider had no 3-D Secure return target. The hosted challenge needs somewhere to send the buyer back to, which comes from the session's successUrl. Create the session with a successUrl and retry. The card was neither charged nor declined. See Charge at submit → 3-D Secure.
On POST /v1/tokens this code covers provider rejections during vaulting. The 422 is permanent — correct the request rather than retrying.
payment_method_consent_missing
HTTP: 422. Your server attempted a merchant-initiated charge (mit.initiator: "merchant") against a token whose setup_for_future_use is null or "on_session" — the buyer never consented to off-session reuse.
Consent is write-once: it is recorded at vault time and no endpoint updates it, so retrying with the same token cannot succeed. The card must be collected again, with setup_for_future_use: "off_session" sent on the call that vaults it:
| Your flow | Send it on |
|---|---|
| Embedded fields, charging at submit | POST /v1/public/sessions/{id}/charge |
| Embedded fields, vault without charging | POST /v1/public/tokens |
| Server-to-server, already holding a provider-side card handle | POST /v1/tokens |
POST /v1/tokens is the wrong endpoint for the common case — for a card collected by embedded fields it needs a provider-side handle you don't hold. Use the route that collected the card.
Obtain explicit buyer consent before that call — the consent record must match what the buyer agreed to — then charge the new token normally. retryable: false, nextAction: fix_request.
Full model: Tokenization → reusability.
internal_error
HTTP: 500. Unexpected server error. Capture the X-Request-Id and contact support.
webhook_missing_signature
HTTP: 401. Answered to a payment provider whose call to our inbound webhook endpoint arrived without its signature header — not something your integration receives.
webhook_invalid_signature
HTTP: 401. Two meanings, one code. Raised to you by the server SDK's webhooks.constructEvent / construct_event when your verification of a webhook we sent fails — wrong whsec_* secret (never your API key), body parsed before hashing, or a timestamp outside the window (5 minutes past, 30 seconds future); diagnosis on Troubleshooting. Answered to a payment provider when its call to our inbound endpoint carries a signature that does not match.
webhook_not_configured
HTTP: 503. The verification secret for an inbound provider webhook is not configured on our side — answered to the provider, not to you. Contact support with the X-Request-Id.
webhook_test_delivery_failed
HTTP: 502. A synchronous webhook test-send probe ran but the delivery could not be completed — a transport error or timeout reaching your subscription endpoint. The API marks it retriable: calling the API directly, retry with exponential backoff starting at 3 seconds, and if it persists, confirm the subscription endpoint is reachable. Note the distinct outcome: if your endpoint is reached but returns a non-2xx, that comes back as a 200 with delivered: false and the endpoint's response_status — not this code.
The server SDKs (3.6.0 and later) answer this differently: they do not repeat the call automatically, and their help for this code says fix_input, because a repeat usually fails the same way, so check the endpoint and then send the test again yourself.
origin_forbidden
HTTP: 403. The endpoint is internal and only callable from the hosted checkout page; an external caller hit it directly. If you are building a server-side integration, use the public API (e.g. POST /v1/sessions) instead of the internal checkout endpoints.
transaction_verification_failed
HTTP: 403. Transaction could not be verified with the payment processor. Contact support with the X-Request-Id — this is not safely retriable without investigation (a transaction either exists on the processor or it doesn't).
unsupported_media_type
HTTP: 415. The Content-Type header is missing or not application/json. This check is enforced on the browser-facing public endpoints — POST /v1/public/tokens and the public confirm endpoint — so set Content-Type: application/json when calling them. (A trailing ; charset=… parameter is tolerated.)
endpoint_not_implemented
HTTP: 501. The requested payment-intent operation is not available for this merchant's payment provider. Read client.capabilities.get() and check supportedOperations before invoking optional operations like paymentIntents.capture, paymentIntents.void, or refunds.create with partial amounts. Not retriable — the operation will not succeed without a different capability or provider.
endpoint_retired
HTTP: 410. Returned by an endpoint that has been permanently retired: today, only the dashboard's former "Create test session" tool, so no public API call returns it. Do not retry it. To run a test payment, create a checkout session with your sandbox account's test keys.
idempotency_replay_incompatible
HTTP: 422. The same Idempotency-Key was sent with a different request body. For payment-intent creation, the server compares the new request against the original on amount, currency, and capture_method; if any of those differ, the replay is rejected to prevent silent state corruption. Either retry with the original values or generate a fresh idempotency key for the new request.
idempotency_key_required
HTTP: 400. The request carried no Idempotency-Key header where one is required. It has two separate triggers, on two different surfaces, for the same reason — there the header is the only duplicate guard available:
On a refund — it fires only when the payment being refunded was taken on a provider connection whose provider keeps no queryable record of a reversal. On that kind of connection our own record is the only evidence a refund happened, so without the header a repeated call creates a new refund record, a new downstream request, and a second real reversal to the buyer. Every other provider connection still accepts a refund with no key.
On a charge — today it fires on payment routes that keep no queryable record of their own, where our key is the only thing standing between a retry and a second charge to the buyer. From the 28 October 2026 cut-off any keyless charge to POST /v1/payment_intents can be refused with it, before anything is reserved or charged. Until then a keyless charge still succeeds, and its response carries Deprecation: true, Sunset: Wed, 28 Oct 2026 00:00:00 GMT and a Link header pointing at this entry. A Sunset header on your charge responses means that call needs a key. See Idempotency.
Not retryable as sent, on either surface. The identical keyless request fails identically, forever. The header has to be added first.
The fix: send one stable value per logical operation and reuse it verbatim on every retry of that operation. A UUID is fine, as is <order_id>_<operation>. Use a fresh value only for a genuinely different operation — a second, intentional refund of the same payment, or a different charge.
⛔ Do not vary the key to get past an error. Adding a key satisfies this code; changing one between retries is what causes the duplicate both triggers exist to prevent. If you are already sending a key and getting a 409 charge_in_progress, the answer is not a new key. Send a key on every connection and every route, not just where it is enforced — see Refunds.
invalid_transition
HTTP: 409. The payment intent is in a state that forbids the requested operation — e.g., trying to capture an intent that's already succeeded, or void one that's already voided. The error envelope carries current_status (the intent's actual state) + reject_reason — a discriminator the SDK exposes as currentStatus / rejectReason — so you can branch without a follow-up retrieve.
reject_reason takes one of five values:
| Value | What it means |
|---|---|
intent_not_found | No intent with that id for this account |
terminal_state | The intent is in a state the operation cannot act on |
invalid_transition | The operation is not legal from the current state |
concurrent_update | Another capture or void is in flight for this intent right now |
lookup_failed | The intent could not be read to decide |
concurrent_update is the one to add a branch for — it is how you detect a race between two operations on the same payment. Treat it as "unknown outcome, retry the read", never as a terminal state: a payment still in flight read as terminal is how a live payment gets cancelled or refunded. A value the server SDK does not recognise arrives as rejectReason: undefined — treat that the same way. The others are not retriable as-is — fix the request (e.g., call refunds.create instead of paymentIntents.void once the intent has captured).
capture_amount_exceeds_authorized
HTTP: 422. The amount_to_capture you sent is larger than the amount authorized on this payment intent. Nothing was captured.
You can capture less than was authorized — that is a partial capture and it is supported. You cannot capture more.
Decide the amount before you capture: a partial capture is final on that intent. There is no incremental capture. Capturing less moves the intent straight to succeeded with amount set to what you took, and any further capture on it is rejected as invalid_transition — the difference can only be collected as a new charge. If the final amount is not known yet, capture once you know it.
The error response carries authorized_amount, so you can re-issue immediately with amount_to_capture at or below that figure — or omit the field entirely to capture the full authorized amount. Not retriable unchanged; fix the amount first.
refund_intent_not_refundable
HTTP: 422. The payment intent named in payment_intent is not in a refundable state — refunds require the parent intent to have status: "succeeded". An intent in requires_action / authorized / captured / failed / voided cannot be refunded: call /capture or /void instead, or — if the intent failed — no refund applies because no money moved. Check the parent intent's current status before retrying. Not retryable as-is.
The same code refuses a refund on a payment whose settlement records a dispute or chargeback, with current_status reading disputed or chargeback even though the intent itself reads succeeded. The buyer gets the money back through the dispute, so do not refund it another way.
refund_amount_exceeds_remaining
HTTP: 422. The requested refund amount is greater than what's left to refund on this intent. The error envelope carries remaining_refundable (the captured amount minus what's already been refunded) so the SDK / agent can re-request with a valid amount, or you can omit amount to refund the full remaining balance. Fix the input and retry.
refund_before_settlement
HTTP: 422. The payment has not settled yet, so there is nothing to send back and the provider refuses the refund. No money moved and the buyer was not refunded.
Wait for settlement — usually the provider's overnight batch, however long ago the capture succeeded — then retry the same refund request unchanged. The refund is recorded as failed, so no refundable balance was consumed. Retrying immediately fails identically.
- Do not treat it as a completed refund — nothing was refunded.
- Do not void or cancel the payment as a workaround. Any payment that can reach this error has been captured, so the cancel call rejects it with
invalid_transition.
refund_target_is_duplicate
HTTP: 422. The transaction you named is a duplicate record of a payment that is recorded under a different transaction, so refunding it would not reverse the payment the buyer actually made. The response names the transaction to use instead in refund_instead. Refund that one. Not retryable as-is; retrying with the same transaction fails the same way.
refund_target_not_refundable
HTTP: 422. The transaction you named cannot be refunded through the API. This is a property of that payment, not of your request, so a retry will not change it. Contact support with the transaction id; do not refund the buyer another way first, or you risk paying them twice.
refund_currency_mismatch
HTTP: 422. The currency in the refund request differs from the parent intent's currency. Refunds always settle in the original capture currency — either drop the currency field (it defaults to the intent's currency) or supply the matching ISO-4217 code. Fix the input and retry.
refund_declined
HTTP: 422. No money moved and the buyer was not refunded. The provider accepted the request and then resolved it to a terminal failure without raising an error — a synchronous decline.
Read this as the refund did not happen, not as "the refund is pending". The refund is recorded as failed, so the intent's refundable balance is untouched.
Read error first. If it says the payment was disputed, the buyer's bank has already returned the money through the chargeback. Do not retry, and do not refund the buyer any other way: they would be paid twice. Otherwise a corrected retry is possible once the cause below is fixed.
Common causes, in the order worth checking: the processor sub-account has insufficient balance to fund the refund; the original card is closed; or a fraud or compliance hold is blocking refunds. All three are gateway-side — retrying the same call unchanged will decline again. Resolve the cause on the gateway account first, then retry.
buyer_required_for_saved_card
HTTP: 422. Nothing was charged. This session's provider vaults the card as part of taking the payment, and a vaulted card has to be saved against a buyer — but this session has no buyer attached.
Retrying cannot succeed. The buyer is fixed when the session is created and cannot be added afterwards, so the same call will fail the same way however many times you send it. Create a new session with buyerId set, and have the buyer enter the card again.
In the browser SDK this arrives as a thrown frame_buyer_required_for_saved_card — your submit() rejects. Do not confuse it with frame_consent_requires_buyer, the same missing buyer on a gateway that charges anyway: that one never throws and the buyer has been charged. Branch on the code — see the two codes and Recurring & saved cards.
payment_method_required
HTTP: 422. This merchant's payment provider requires a previously-vaulted payment method on every charge, but the request omitted one. Use the two-step flow: (1) POST /v1/tokens with the buyer's payment data to vault it, then (2) POST /v1/payment_intents with payment_method: { id: <vp_pmt_*> }. Some providers also support a browser-side confirmation flow (omit payment_method, then confirm in the buyer's browser) — call GET /v1/capabilities to see whether the merchant's current provider supports that path. Not retriable as-is.
payment_method_not_found
HTTP: 404. The payment_method.id is unknown to this merchant. Verify the id was not truncated, that it was created against the same merchant whose API key you're using, and that the test/live mode matches the key prefix. Payment-method ids are scoped to the merchant; you cannot reference another merchant's token. Not retriable as-is — fix the id and retry.
It is also returned for a card saved single-use (no setup_for_future_use) with no buyer identified, once it is more than 24 hours old: such a card can only be charged in the window right after it was saved. To charge a card again later, save it with setup_for_future_use on a session that identifies the buyer (buyerId, buyerEmail or buyer).
saved_card_buyer_required
HTTP: 422. A request asked to save a card for later use (setup_for_future_use) but identified no buyer, so the card would have no owner. This check is off by default; when it is on, it is returned only where nothing is being charged: POST /v1/tokens, and saving a card without charging it through POST /v1/public/tokens. Nothing was charged and nothing was saved. Where the buyer is charged and the card saved in the same step, the payment is never refused for this: the card is saved for that payment only and setup_for_future_use comes back null.
Identify the buyer: buyerId, buyerEmail or a buyer object on POST /v1/sessions (then collect the card again on that session), or buyer_id / buyer on POST /v1/tokens. If the card is only for this payment, omit setup_for_future_use.
required_fields_missing
HTTP: 422. A card payment (POST /v1/public/sessions/{id}/charge) was missing buyer details your account requires, so the buyer was not charged (charged: false). This check is off by default; when on, it applies to every card payment. missing_fields lists required fields never supplied and invalid_fields lists supplied values that are unusable, such as a phone number with too few digits. Field names are name, email, phone, shipping_address and billing_address.
The requirements are your dashboard's buyer-field settings, fixed onto the session when it was created, and the details are read from the session. Set them at create (buyerName, buyerEmail, and buyerContact for phone and addresses, secret key only), or create a new session with them. A billing address may also be sent on the payment as billing_address. Not retryable as sent.
buyer_not_found
HTTP: 404. The buyer id on GET /v1/buyers/{id} or PATCH /v1/buyers/{id} is unknown to this merchant. The response is opaque — the same body whether the id is missing, belongs to a different merchant, or its test/live mode doesn't match the key (buyer ids are mode-prefixed vp_by_test_* / vp_by_live_*) — so buyer ids cannot be probed across merchants or modes. To find a buyer by your own reference, use GET /v1/buyers?external_id= or ?email=. See Buyers.
buyer_email_conflict
HTTP: 409. A POST /v1/buyers sent an external_id with an email that belongs to a buyer carrying a different external_id, or a PATCH /v1/buyers/{id} tried to set an email that already belongs to another of your buyers. Nothing was changed. On POST, an email owned by a buyer with no external_id is not a conflict: the reference is attached to that buyer (Buyers). Emails are unique per merchant across buyer records, and rejecting beats silently merging two buyer records into one. Look up the existing owner with GET /v1/buyers?email= and update that record, or keep the two buyers distinct by external_id and drop the conflicting email. See Buyers.
service_unavailable
HTTP: 503. The endpoint is temporarily unavailable. Retriable — back off exponentially and retry. Distinct from provider_unavailable (the payment provider is unreachable) and auth_service_unavailable (the key-verification service is down); this one covers the endpoint itself.
On POST /v1/payment_intents it carries a stronger guarantee: a check we run before charging could not complete, so nothing was sent to your payment provider and the buyer was not charged. Retry shortly with the same Idempotency-Key; selfHeal.retryable is true. Branch on the code, not the class: this 503 and charge_reservation_unavailable both mean nothing happened, a 409 charge_in_progress means something already did, and a 400 idempotency_key_required means add a key — never vary one.
feature_unavailable
HTTP: 503. The capability you called is switched off. This is a deliberate configuration, not an outage — retrying the same call will keep returning this response, however long you back off. Branch on it:
service_unavailable(503) means try again shortly — the endpoint is briefly down.feature_unavailable(503) means stop trying — the capability is off and will stay off until it is turned on.endpoint_not_implemented(501) means the operation does not exist for your payment provider at all.
Fall back to your asynchronous path: reconcile the outcome from your webhook rather than looping on the call. The response is not a failed payment — it is a refusal to answer synchronously.
It is returned by the public guest-completion endpoint (POST /v1/public/sessions/{id}/complete). The check runs before your key is authenticated, so a valid publishable key still sees it; it is not a credentials problem.
rate_limit_service_unavailable
HTTP: 503. The rate-limiting service is temporarily unavailable, so the request could not be admitted. Retriable in a few seconds. Not the same as rate_limit_exceeded (429) — you have not exceeded anything, the limiter simply could not answer.
charge_at_submit_not_enabled
HTTP: 422. The session is not eligible for Embedded Fields charge-at-submit. Vault the card with POST /v1/public/tokens and charge server-side with POST /v1/payment_intents instead. Distinct from endpoint_not_implemented (501), which means the capability is off platform-wide or for your gateway rather than wrong for this particular session.
validation_url_scheme_forbidden
HTTP: 400. A successUrl or cancelUrl used a scheme or host that live mode does not allow. Live mode requires HTTPS on a public host; localhost is accepted in test mode only. Fix the URL and retry.
webhook_subscription_not_found
HTTP: 404. The webhook-subscription id is unknown to this merchant. Check the id format and that it belongs to your account — subscription ids are merchant-scoped, so another merchant's id reads as missing rather than forbidden.
webhook_subscription_conflict
HTTP: 409. A webhook subscription already exists for this URL. Update or delete the existing one rather than creating a duplicate — list your subscriptions with GET /v1/webhook_subscriptions to find it.
webhook_subscription_url_immutable
HTTP: 400. A PATCH /v1/webhook_subscriptions/{id} included url. A subscription's delivery URL is fixed once it is created, and nothing was changed by this request. Not retryable as sent.
To deliver to a new URL, create a new subscription for it. It gets a new signing secret: store it and verify deliveries with it. Confirm the new subscription receives events, then delete the old one. To change only the event list, description or status, send the update without url; that keeps the existing URL and signing secret.
unexpected_redirect
Raised by the server SDKs, never sent by the API (Node and Python 3.0.0 and later). The API answered with a redirect, which it never does, so something else is answering: a proxy, a mistyped base URL, or a hijacked connection. The SDK does not follow it. retryable is false and nextAction is check_configuration. Do not retry: check that the SDK's base URL is https://checkout.vonpay.com with no path, then any proxy and the network.
webhook_event_not_found
HTTP: 404. The webhook-event id is unknown, or belongs to a session your account does not own. Same merchant-scoping rule as subscriptions above.
wallet_domain_not_found
HTTP: 404. The wallet-domain id is not registered to this merchant, or has been removed.
wallet_domain_invalid
HTTP: 422. The request body failed validation. Two things can trigger it, and the message does not distinguish them: the domain was not a bare RFC 1123 hostname (letters, digits, hyphens and dots, 253 characters max — no scheme, no path, no port, no IP literal), or wallet_type was something other than apple_pay / google_pay. Check both before assuming it is the hostname.
wallet_domain_wrong_type
HTTP: 404. You asked for the verification file on a Google Pay row. Only Apple Pay registrations host a verification file, so the download endpoint is meaningful for those alone.
wallet_domain_no_binder
HTTP: 409. The account has no active wallet-capable payment gateway, so there is nothing to register a domain against. Complete merchant onboarding first.
wallet_domain_rate_limited
HTTP: 429. Verification re-runs for a single domain are limited to one every 30 seconds. Poll GET /v1/wallet-domains/{id} for status instead of calling verify repeatedly.
wallet_domain_provider_unavailable
HTTP: 503. The upstream provider that performs domain registration is temporarily unreachable. Retriable with exponential backoff.
mirror_settle_only_disabled
HTTP: 400. mirror.settle_only was refused before the card was charged — the buyer was not charged. Either send the standard upsell instead (parent_order_ref with the add-on's line_items), or see Settle-only for the conditions a settle-only request has to meet.
Order-before-charge refusals (order_first_*)
These apply when your account creates the order in the connected store before the card is charged. All eleven refuse before any money moves — on every one of them, the buyer was not charged.
They divide into three responses, and the difference matters:
| Response | Codes |
|---|---|
| Retry with the identical body | order_first_lookup_unavailable, order_first_record_unavailable, order_first_repoint_unavailable, order_first_store_timeout, order_first_store_unavailable |
| Fix the request, then resend | order_first_order_rejected, order_first_amount_mismatch, order_first_currency_mismatch |
| Contact support | order_first_unsupported_destination |
| Do not retry: an earlier payment for this purchase exists | order_first_already_paid, order_first_payment_unresolved |
On order_first_store_timeout and order_first_store_unavailable the store may have created the order already — it simply never told us. An unconfirmed attempt is matched by the purchase (buyer, line items, quantities, prices, voucher): an identical retry adopts that order, a retry with any of those changed creates a second one. If the basket has to change, treat it as a new purchase — the abandoned order is released automatically when no payment completes.
order_first_lookup_unavailable
HTTP: 503. Our record of earlier attempts for this purchase could not be read, so the request was refused rather than risk a duplicate order. Retry the identical body after ~3s.
order_first_record_unavailable
HTTP: 503. The durable record written before contacting the store could not be written, so the request was refused. Retry the identical body after ~3s.
order_first_repoint_unavailable
HTTP: 503. This is a retry, an order for it already stands in your store, and the link from that order to this attempt could not be written. The existing order is untouched and is released automatically if no payment completes. Retry the identical body after ~3s.
order_first_store_timeout
HTTP: 503. The store did not answer in time while creating the order, so the charge was refused rather than billed against an order Von could not confirm. The order may exist. Retry the identical basket after ~5s — see the warning above.
order_first_store_unavailable
HTTP: 503. The store was throttling or temporarily unavailable. It did not reject the order's contents, so there is nothing to correct in your request. The order may exist. Retry the identical basket after ~10s — see the warning above. If it persists for several minutes the store is likely rate-limiting a burst: reduce concurrency rather than retrying harder.
order_first_order_rejected
HTTP: 400. The store refused the order outright, so nothing was created. This is a store-side validation failure about the order's contents — usually a product or variant reference that does not exist in that store, a quantity it will not accept, or a malformed price. It is not a discount problem. Reconcile your line_items against the store's catalogue and resend; an identical retry is rejected identically.
order_first_amount_mismatch
HTTP: 400. The store created the order and priced it at a total that does not equal the amount you are charging, so Von refused rather than record a payment that does not match the order. Charge the order_total returned on this error — the store's number is authoritative. The order is left standing, so a corrected retry attaches to it instead of creating a second one.
⚠️ Do not call POST /v1/mirror/quote to resolve this. Quote prices a cart; order-before-charge exists precisely because a store's cart total and its order total can disagree — a standing site-wide offer can apply to one and not the other.
order_first_currency_mismatch
HTTP: 400. The store priced the order in a different currency than the one being charged. Charge in the currency named by order_currency on this error, or reconfigure the store to price in your intended currency.
This is deliberately separate from an amount mismatch because the numbers can be identical while the money is not — a ¥1000 order and a $10.00 charge are both 1000 in minor units.
order_first_already_paid
HTTP: 409. A new charge arrived for the same purchase (same buyer, items and store) as an earlier payment that already went through, while the store order is still open (usually because marking it paid had not finished). This request did not charge the buyer. The response carries original_payment_intent_id, the earlier payment, whenever it can be read, and the earlier payment is recorded on the store order automatically.
Not retryable. Do not retry with a new Idempotency-Key, a different card or a changed basket: each of those charges the buyer a second time for something already paid. Treat the purchase as paid, read GET /v1/payment_intents/{original_payment_intent_id} and show the buyer their confirmation. If the buyer genuinely wants the same items again, contact support.
order_first_payment_unresolved
HTTP: 409. A new charge arrived for the same purchase as an earlier payment that has not finished (for example, the buyer is still completing a bank check) or whose result has not been confirmed. If that earlier payment goes through, a second charge would bill the buyer twice, so this one was refused. This request did not charge the buyer. original_payment_intent_id is included when the earlier payment has a record, and omitted (not null) when it does not yet.
Not retryable as is. When original_payment_intent_id is present, read it: succeeded means the purchase is paid; failed means the same basket can be retried. Do not get past this with a new key, card or basket. If it persists beyond a few minutes, contact support so the earlier payment can be resolved.
order_first_unsupported_destination
HTTP: 503. Your account is configured for order-before-charge, but the platform named in mirror.destination cannot create an order ahead of the charge, and there is no fallback to charge-first. Not retryable — a configuration mismatch on our side. Contact support.
Decline reasons (failure_code)
A declined charge surfaces two ways:
- Synchronously — the API returns
402provider_charge_failedon the charge routes. ⚠ A declinedPOST /v1/payment_intentsis different: it returns201withstatus: "failed", not a4xx— see A decline is a 201. A client that only reads the body on an error status never sees the decline fields at all. - Asynchronously — a
charge.failedorpayment_intent.failedwebhook fires, carrying seven fields (allstring | null):failure_code— the normalized decline reason to branch on.decline_code— the same value under the API response's name. See the two names.action—soft/hard/fix_and_retry/generic, the retry classification.decline_message— buyer-safe wording for this decline. See Showing a decline to the buyer.failure_reason— a human-readable summary of the decline, written for you, not your buyer. See Whofailure_reasonis for.network_decline_code— the raw issuer code (e.g.05,51); for logging/analytics only.rule_code— which of your own rules refused it, when one did.nullon every other outcome. See Telling your rules apart.
The same value under two names
The normalized decline arrives as failure_code and decline_code on every failed-family webhook payload — the same value under both names, so one handler can read the webhook and the API response by the same key. It is normalized to a fixed vocabulary; the full set and recommended handling follow. Every decline also carries action (soft / hard / fix_and_retry / generic), derived from the same mapping on both surfaces — branch on that for retry or dunning logic rather than maintaining the column yourself.
failure_code | action | Meaning | Retry the same card? | Recommended next step |
|---|---|---|---|---|
card_declined | generic | Declined with no further detail supplied. | Unlikely to help | Prompt for a different payment method. |
insufficient_funds | soft | The card lacks available funds. | Not now | Suggest a different card; the same card may succeed later. |
expired_card | fix_and_retry | The card has passed its expiry date. | No | Ask the buyer to re-enter a valid card. |
incorrect_cvc | fix_and_retry | The CVC/security code was wrong. | Only after correction | Ask the buyer to re-enter the security code. Retrying the same value fails identically. |
incorrect_zip | fix_and_retry | The billing postal code did not match. | Only after correction | Ask the buyer to re-enter the billing ZIP/postal code. Retrying the same value fails identically. |
card_velocity_exceeded | soft | The card hit an issuer velocity limit. | Not now | Suggest a different card or trying again later. |
fraudulent | hard | The issuer or a fraud rule blocked the charge. | No | Show a generic decline message — do not reveal the fraud signal. Suggest a different method or contacting the issuer. |
stolen_card | hard | The card was reported stolen. | No | Show a generic decline message; suggest a different method. |
lost_card | hard | The card was reported lost. | No | Show a generic decline message; suggest a different method. |
do_not_honor | hard | The issuer declined without a specific reason. | Unlikely to help | Prompt for a different payment method or contacting the issuer. |
issuer_unavailable | soft | The issuer could not be reached. | Yes | Safe to retry shortly; if it persists, try a different method. |
processing_error | soft | A transient processing failure. | Yes | Safe to retry; if it persists, try a different method. |
blocked_by_rule | hard | A rule you configured on your own account refused the charge. No issuer was asked. | No | Prompt for a different payment method. Do not suggest contacting the bank — no bank saw this charge. |
generic_decline | generic | An unmapped or unrecognized raw decline. | Unlikely to help | Prompt for a different payment method. |
An unmapped raw code is normalized to generic_decline before it reaches you, so a webhook always carries one of the values above.
Who failure_reason is for
failure_reason is written for you, not your buyer. It is the most informative single string we return about why a charge failed — put it in your logs and your support tooling.
Three of its values state that a card is reported lost or stolen, or that fraud is suspected. Present those to a buyer as an ordinary decline, exactly as you would generic_decline:
failure_code | What failure_reason says | What the buyer should see |
|---|---|---|
fraudulent | "The payment was declined as suspected fraud." | A generic decline |
stolen_card | "The card was reported stolen." | A generic decline |
lost_card | "The card was reported lost." | A generic decline |
fraudulent is a risk score, not a finding. network_decline_code stays out of buyer-facing copy entirely — it is for logging and analytics.
Showing a decline to the buyer
decline_message is the field written for your buyer. It accompanies the decline on the charge response and on the failed-family webhooks, and it is the one you can render or put in a dunning email without leaking a fraud signal.
Show decline_message; log failure_reason. They are not the same string and should not be collapsed into one. Where failure_reason names a fraud, lost or stolen signal, decline_message gives all of those the ordinary decline wording — byte for byte identical to card_declined and generic_decline — so the five cannot be told apart:
The card was declined. Please use a different payment method.
On a successful charge the decline fields are not there to read: the API response returns them explicitly null, and the success webhooks (charge.succeeded and its siblings) omit the keys entirely — so decline_code === null is true on one surface and false on the other. Test that the charge actually failed before rendering decline_message; printing it unconditionally shows a decline to a buyer who just paid.
Codes the buyer can actually act on keep their own wording — insufficient_funds, expired_card, incorrect_cvc and incorrect_zip each name what to fix, because a generic string there costs a sale the buyer was willing to complete.
Decline reasons in the browser
A decline reported to the browser, by the embedded card form or the hosted checkout page, uses a smaller set of codes. It never says which card detail was wrong, or that the card was flagged:
Server decline_code | Browser code |
|---|---|
incorrect_cvc, incorrect_zip, expired_card | incorrect_card_details |
fraudulent, stolen_card, lost_card, blocked_by_rule | card_declined |
| Any other code | Unchanged |
The exact reason still reaches your server on the charge.failed webhook and on secret-key responses.
In vora.js 1.38.1 and earlier, the embedded card form does not pass incorrect_card_details or blocked_by_rule to your page: those declines arrive as "The payment was declined." with no code. Read the exact reason on your server.
blocked_by_rule before rendering it in a browserdecline_message is buyer-safe, but for blocked_by_rule it is not browser-safe: its wording differs from the generic decline, which makes a refusal by your own rule distinguishable from an issuer decline. A scripted caller could use that difference to map which cards you refuse.
Our browser-facing responses do not carry decline_message at all, so this cannot reach a buyer's page by itself — every copy of it you hold came from a secret-key response or a webhook. That makes the coarsening yours to do: if you echo the field into a page, replace it with your generic decline copy when failure_code is blocked_by_rule.
Your own copy keyed off failure_code is the option for tone control or a language other than English — decline_message is English only. Either way, the charge response carries the same fields; do not wait on the webhook to tell someone their card failed.
blocked_by_rule — your own rule, not a bank decline
This is the one value in the list that is not a decline in the usual sense. The charge never reached an issuer: a routing rule on your own account refused it first.
It is not limited to card brands — the rule may key on a brand you have chosen not to accept, a card country you block, prepaid cards, or be a catch-all with no name of its own.
Which of your rules fired is reported as rule_code — but only if you gave the rule a code. See Telling your rules apart below.
Reporting a rule refusal as its own code requires provider support, as rule tags do. On a provider that does not distinguish it, a refusal by your own rule arrives as generic_decline — so the absence of blocked_by_rule is not proof no rule fired. Confirm your provider reports it before building decline-rate reporting on it.
Its action is hard: the same card is refused for as long as the rule stands, so the buyer needs a different payment method.
Telling your rules apart — rule_code
Every rule produces the same decline_code, so a brand rule and a country rule are indistinguishable by that field alone. rule_code carries the code you gave the rule in your provider dashboard — or, on a provider that assigns its own rule identifiers, that identifier — minus any provider prefix. It is what lets you branch on them separately.
{
"status": "failed",
"decline_code": "blocked_by_rule",
"action": "hard",
"rule_code": "amex_not_accepted"
}
You have to name your rules for this to work. Left blank, every rule emits one identical value and rule_code comes back null — set a custom code on each rule before you write any handling. A null on a blocked_by_rule payment means the rule has no custom code, or (see the availability note above) your provider does not report rule refusals separately at all.
- It is echoed back as you typed it, minus any provider prefix. We do not interpret it.
- A code that is not a plain identifier comes back
nullrather than altered. Letters, digits,_and-, up to 64 characters, and the first character must be a letter or a digit —_amex_blockis rejected tonull, which looks identical to never having named the rule.
rule_code appears wherever decline_code does on a secret-key surface: the payment intent, and the charge.failed / payment_intent.failed / session.failed webhooks. Browser-facing responses — every response a buyer's browser can receive, including POST /v1/public/sessions/{id}/charge — report a rule refusal as the generic card_declined and carry no rule_code: a publishable key is readable from your page source, and naming the rule there would let anyone map which cards you refuse. Read the full value from your server, on the payment intent create/confirm response (decline_code) or the charge.failed webhook (failure_code). Retrieving the intent later with GET /v1/payment_intents/{id} does not return the decline — capture it when the charge fails or you cannot recover it from the API afterwards.
For a worked example — configuring the rule, branching on it, and what to tell the buyer — see Handling rule declines.
If you branch on failure_code directly, treat any value you do not recognize as hard and prefer action, which is a closed set.
When the decline block is absent
action is not a classificationaction accompanies the decline on the payment intent create/confirm response and on the charge.failed / payment_intent.failed / session.failed webhooks; the browser charge-at-submit response does not carry it. Where present it can still be null — the decline carried a code we hold no classification for, or the failure never resolved a normalized code at all — and it is deliberately not reported as generic. action, decline_code and decline_message derive from the same code, so in the second case they are all null together. Never treat a missing action as permission to retry.
Trigger the declines above by order total in test mode; the processor's test environment decides by the total, not by the card number. Note that the fraud decline surfaces failure_code as fraudulent on the wire. blocked_by_rule is the exception — no order total produces it, because it depends on a rule configured on your account rather than on the payment. Exercise your handling for it by configuring a rule in your provider dashboard.