Skip to main content

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​

CodeMeaningCommon Causes
400Bad RequestInvalid request body, missing required fields, validation failure
401UnauthorizedMissing or invalid Authorization: Bearer token
404Not FoundSession ID doesn't exist
409ConflictSession is in the wrong state (e.g., already completed)
410GoneSession has expired (configurable TTL — 30-minute default)
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server error

Error Codes Reference​

CodeHTTPDescription
auth_missing_bearer401No Authorization: Bearer header provided
auth_invalid_key401API key is malformed or does not exist
auth_key_expired401Key has rotated past its grace window
auth_missing_bearer_publishable401No Authorization: Bearer header on a browser-callable /v1/public/* route
auth_invalid_key_publishable401Publishable key not recognized on this host — most often an environment mismatch, not a bad key. See below before rotating anything
auth_key_expired_publishable401Publishable key is past its rotation grace window. Publishable keys reach the browser by redeploy, not by swapping an environment variable
auth_key_type_forbidden403Publishable key used on a secret-only endpoint, or sandbox/live mode mismatch
auth_merchant_inactive401Merchant account is disabled or suspended
merchant_not_onboarded403Live-key creation is blocked until merchant-onboarding review completes
live_keys_need_processor403The account is approved, but no payment processor is connected to it yet
test_keys_are_sandbox_only403Test keys were requested on a live business; they come from its sandbox
live_keys_not_on_sandbox403A live key was requested on a sandbox
processor_check_unavailable503We could not confirm the account's payment setup. Temporary, and not a problem with your request; retry shortly
auth_service_unavailable503Authentication service is temporarily unavailable
session_not_found404Session ID does not exist
checkout_address_not_found404The request reached a {name}.vonpay.com address that is not serving checkout. Use the checkoutUrl from POST /v1/sessions exactly as returned
session_expired410Session expired (TTL elapsed), or a terminal session with no successful charge — create a new one
session_already_completed409A 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_state409Session is in the wrong state for this operation
session_not_expirable409The 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_modifiable409PATCH /v1/sessions/{id} could not change the total and nothing changed. reason says why. See below
charge_in_progress409A 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_flight409Another 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_unavailable503The 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_mismatch409The 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_error500Internal session state mismatch — contact support
validation_error400Request body failed schema validation
currency_not_supported422The currency is not accepted for new payments yet. Nothing was charged, saved or created. Use a different currency
validation_missing_field400A required field is missing from the request body
validation_invalid_amount400Amount is not a positive integer or exceeds maximum
validation_unknown_field400The 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_configured422Merchant is missing required configuration (e.g., payment provider credentials)
binder_unavailable409Merchant has no payment provider configured for this operation — complete onboarding. A test key does not avoid it
sandbox_account_required422A 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_supported422The merchant's payment provider does not support the requested operation — check GET /v1/capabilities
rate_limit_exceeded429A per-IP (or route) rate limit was reached. Wait Retry-After (1 to 60 seconds), then retry with backoff
rate_limit_exceeded_per_key429Per-API-key rate limit (30 session-creates/min). Wait Retry-After, or contact support if you need a higher ceiling
provider_unavailable502Upstream payment provider is not responding
payment_outcome_unknown502A 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_failed403Payment provider rejected the session-bound attestation
provider_charge_failed402Card declined or charge rejected by the upstream provider
provider_request_rejected422Payment provider rejected the request as invalid before any money moved (not a decline, not an outage) — fix the offending field and retry
internal_error500Unexpected server error
webhook_missing_signature401Inbound provider webhook is missing its signature header
webhook_invalid_signature401Webhook signature does not match the expected value
webhook_not_configured503Webhook verification secret is not configured on the server
webhook_test_delivery_failed502A synchronous webhook test-send probe could not be delivered. Retriable on the raw API; the 3.6.0+ SDKs do not repeat it
origin_forbidden403Internal endpoint called from outside the checkout page
transaction_verification_failed403Transaction could not be verified with the payment provider
unsupported_media_type415Content-Type header is missing or not application/json
endpoint_not_implemented501A payment-intent operation is not yet implemented for this merchant's provider (capability gate)
endpoint_retired410A permanently retired endpoint. No public API call returns it today
idempotency_replay_incompatible422Idempotency key was reused with a different request body
unexpected_redirectn/aRaised 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_required400An 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_transition409Payment intent is in a state that forbids the requested operation (e.g. capture on succeeded)
capture_amount_exceeds_authorized422amount_to_capture is larger than the amount authorized on the intent. The response carries authorized_amount
refund_intent_not_refundable422The parent payment intent is not in a refundable state (refunds require status: "succeeded")
refund_amount_exceeds_remaining422Requested refund amount is greater than the remaining refundable balance
refund_before_settlement422The payment has not settled yet, so it cannot be refunded — nothing moved. Wait for settlement, then retry the same request
refund_currency_mismatch422Refund currency does not match the parent intent's currency
refund_target_is_duplicate422The 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_refundable422The transaction cannot be refunded through the API. Contact support with its id
payment_method_required422This merchant's payment provider requires a vaulted payment method on the request
payment_method_not_found404The 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_required422A card was to be saved for later use with no buyer identified. Nothing was charged or saved
required_fields_missing422A card payment lacked buyer details your account requires. The buyer was not charged
payment_method_consent_missing422The 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_card422The 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_found404The 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_conflict409A 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_items400The 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_declined422The 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_unavailable503The endpoint is temporarily unavailable — retry with backoff
feature_unavailable503The capability is deliberately switched off, not briefly down. Retrying will not clear it — degrade to your webhook path. See below
rate_limit_service_unavailable503The rate limiter itself is unavailable — you have not exceeded a limit
charge_at_submit_not_enabled422This session is not eligible for charge-at-submit — vault the card and charge server-side
validation_url_scheme_forbidden400successUrl / cancelUrl must be HTTPS on a public host in live mode (localhost is test-mode only)
webhook_subscription_not_found404The webhook-subscription id is unknown to this merchant
webhook_subscription_conflict409A subscription already exists for this URL — update or delete it instead
webhook_subscription_url_immutable400An update sent url. A subscription's URL cannot change; nothing was changed. Create a new subscription and delete the old one
webhook_event_not_found404The webhook-event id is unknown to this merchant
wallet_domain_not_found404The wallet-domain id is not registered to this merchant
wallet_domain_invalid422Body failed validation — either domain is not a bare hostname, or wallet_type is not apple_pay / google_pay
wallet_domain_wrong_type404Only Apple Pay registrations host a verification file
wallet_domain_no_binder409No wallet-capable gateway on the account yet
wallet_domain_rate_limited429One verification re-run per domain every 30 seconds — poll for status instead
wallet_domain_provider_unavailable503The upstream domain-registration provider is unreachable — retry with backoff
mirror_settle_only_disabled400mirror.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.

CodeHTTPMeaning
mirror_malformed400The 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_large400The 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_ref400A mirror line item has no external_product_ref — every line needs the connected store's variant id
mirror_shop_not_authorized400That 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_scopes422The store connection exists but lacks the permissions to write orders and customers. Re-grant the connection, then retry
mirror_upsell_unsupported400Upsell 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_unsupported400Raw 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_unsupported400Raw 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_disabled400Store-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_mismatch400The 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_rejected400The connected store refused the discount code — check it exists and is active there, or drop the voucher and charge the undiscounted total
mirror_voucher_unverifiable503The store could not price the discount right now. Retryable — unlike every other voucher refusal on this list
order_total_mismatch400The 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_conflict400Both the order / mirrorTo model and a raw mirror block were sent. They describe the same store order two ways — keep one
order_model_disabled400The order / mirrorTo model is not enabled for this account. Send a mirror block instead, or ask support to enable it
upsell_duplicate409This 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's error is a written sentence naming the offending fields, and the body carries an unknownFields array alongside it. If you wrote a parser that assumes error is always a serialized issue array, it will not handle this one — read unknownFields instead.

{
"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:

FieldRule
amountMust be a positive integer (1–99,999,999)
currencyMust be exactly 3 characters
countryMust be exactly 2 characters
successUrlMust be HTTPS (localhost exempt in sandbox/test mode)
lineItemsMax 100 items
metadata valuesMax 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 a captureMethod: "manual" session, authorised exactly once: read charged on 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:

SurfaceWhat the code is keyed onRead this to resolve it
Hosted checkout / Embedded Fieldsthe sessionRetrieve the session, or wait for the charge.* webhook
POST /v1/payment_intentsyour Idempotency-Key for this merchantRead the original payment's status
Do not retry, do not create a new session, do not reissue with a fresh Idempotency-Key

On 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:

CauseWhat the error message tells you
Your Idempotency-Key was already used for a chargeWhether 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 yetThe 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 resolvingNothing 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_flightcharge_in_progress
Retry?Yes — same body, same keyNo — do not retry, do not reissue
What to doWait the advertised interval, send it againRead 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: the expected_amount sent with the payment (the total shown to the buyer) differs from the session's current total, usually because it was changed with PATCH /v1/sessions/{id} after the buyer saw it.
  • request_start: no expected_amount was 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 echoed session can still read pending and unpaid for a short while. Do not take payment for the same order another way while a payment is in progress.
  • integration_mode: only sessions created with integrationMode: "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:

  1. Casing. The session surface is camelCase — success_url → successUrl, line_items → lineItems, unit_amount → unitAmount. Where a suggestions map is present it gives the canonical name for each offending key.
  2. Right field, wrong endpoint. The field is real but belongs on a different call, and the error string says where. The most common instance: setup_for_future_use sent to POST /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 metadata object, 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 sentOnDo this
A test key (vp_sk_test_*, vp_pk_test_*)A live merchant accountUse your sandbox account's test keys. A live account's test keys cannot take a test payment
A test keyA sandbox whose processor test account is not set up yetOpen 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 a refund, this is "state unknown" — not "it didn't happen"

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.

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 flowSend it on
Embedded fields, charging at submitPOST /v1/public/sessions/{id}/charge
Embedded fields, vault without chargingPOST /v1/public/tokens
Server-to-server, already holding a provider-side card handlePOST /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:

ValueWhat it means
intent_not_foundNo intent with that id for this account
terminal_stateThe intent is in a state the operation cannot act on
invalid_transitionThe operation is not legal from the current state
concurrent_updateAnother capture or void is in flight for this intent right now
lookup_failedThe 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:

ResponseCodes
Retry with the identical bodyorder_first_lookup_unavailable, order_first_record_unavailable, order_first_repoint_unavailable, order_first_store_timeout, order_first_store_unavailable
Fix the request, then resendorder_first_order_rejected, order_first_amount_mismatch, order_first_currency_mismatch
Contact supportorder_first_unsupported_destination
Do not retry: an earlier payment for this purchase existsorder_first_already_paid, order_first_payment_unresolved
Retry with the identical basket, or you create a second order

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 402 provider_charge_failed on the charge routes. ⚠ A declined POST /v1/payment_intents is different: it returns 201 with status: "failed", not a 4xx — 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.failed or payment_intent.failed webhook fires, carrying seven fields (all string | 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 Who failure_reason is 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. null on 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_codeactionMeaningRetry the same card?Recommended next step
card_declinedgenericDeclined with no further detail supplied.Unlikely to helpPrompt for a different payment method.
insufficient_fundssoftThe card lacks available funds.Not nowSuggest a different card; the same card may succeed later.
expired_cardfix_and_retryThe card has passed its expiry date.NoAsk the buyer to re-enter a valid card.
incorrect_cvcfix_and_retryThe CVC/security code was wrong.Only after correctionAsk the buyer to re-enter the security code. Retrying the same value fails identically.
incorrect_zipfix_and_retryThe billing postal code did not match.Only after correctionAsk the buyer to re-enter the billing ZIP/postal code. Retrying the same value fails identically.
card_velocity_exceededsoftThe card hit an issuer velocity limit.Not nowSuggest a different card or trying again later.
fraudulenthardThe issuer or a fraud rule blocked the charge.NoShow a generic decline message — do not reveal the fraud signal. Suggest a different method or contacting the issuer.
stolen_cardhardThe card was reported stolen.NoShow a generic decline message; suggest a different method.
lost_cardhardThe card was reported lost.NoShow a generic decline message; suggest a different method.
do_not_honorhardThe issuer declined without a specific reason.Unlikely to helpPrompt for a different payment method or contacting the issuer.
issuer_unavailablesoftThe issuer could not be reached.YesSafe to retry shortly; if it persists, try a different method.
processing_errorsoftA transient processing failure.YesSafe to retry; if it persists, try a different method.
blocked_by_rulehardA rule you configured on your own account refused the charge. No issuer was asked.NoPrompt for a different payment method. Do not suggest contacting the bank — no bank saw this charge.
generic_declinegenericAn unmapped or unrecognized raw decline.Unlikely to helpPrompt 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_codeWhat failure_reason saysWhat 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_codeBrowser code
incorrect_cvc, incorrect_zip, expired_cardincorrect_card_details
fraudulent, stolen_card, lost_card, blocked_by_rulecard_declined
Any other codeUnchanged

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.

Coarsen blocked_by_rule before rendering it in a browser

decline_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.

Availability depends on your payment provider

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 null rather than altered. Letters, digits, _ and -, up to 64 characters, and the first character must be a letter or a digit — _amex_block is rejected to null, 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​

An absent action is not a classification

action 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.