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
auth_service_unavailable503Authentication service is temporarily unavailable
session_not_found404Session ID does not exist
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; 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_integrity_error500Internal session state mismatch — contact support
validation_error400Request body failed schema validation
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 live payment provider configured and is not in sandbox mode — complete onboarding or use a test-mode key
capability_not_supported422The merchant's payment provider does not support the requested operation — check GET /v1/capabilities
rate_limit_exceeded429IP-axis rate limit — retry after the Retry-After interval
rate_limit_exceeded_per_key429Per-API-key rate limit (30 session-creates/min) — contact support if you need a higher ceiling
provider_unavailable502Upstream payment provider is not responding
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
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)
idempotency_replay_incompatible422Idempotency key was reused with a different request body
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
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
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_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 update tried to set an email that already belongs to another of your buyers
mirror_amount_below_line_items400The connected-store mirror's line_items + shipping exceed the amount being charged. Refused before the charge — the store would create the order at the higher figure and then refuse to mark it paid. One-sided: charging more than the lines itemise is allowed. See Connected Platforms
refund_declined422The provider declined the refund — no money moved, the buyer was not refunded. Recorded as failed, so the refundable balance is unchanged and 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_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

This table covers the error codes you are most likely to encounter; the full catalog is larger and continues to grow.

Rate-limit buckets are documented 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#required-fields",
"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 is always required. amount is required for payment-mode sessions (the default); a setup-mode (card-on-file) session may omit it.

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

Each error code emitted in a response body's docs field links to its section below. Each section gives the HTTP status, the cause, and the fix.

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. They exist so the self-heal envelope steers you toward a publishable key: telling a developer to put a secret key in browser code would be a security mistake, so the browser-facing codes never do.

One difference worth knowing for auth_key_expired_publishable: a publishable key reaches the browser inside your built bundle, so replacing it takes a redeploy. Swapping an environment variable on a running server doesn't move it.

auth_invalid_key_publishable

HTTP: 401. Check the environment before the key — rotating first is the expensive mistake here.

A publishable key is scoped to the host that issued it. The browser SDK's apiBaseUrl option selects that host, and it defaults to production — it is not inferred from the key. So if your server creates the session against one host while the browser leaves apiBaseUrl on its default, every browser call goes to a host that never issued that key and fails with this code, even though the key is perfectly valid where it came from.

The tell is a split: server-side calls succeed, browser calls fail 100% of the time, and the key shows no recorded use on the host you expected.

That is why this code carries nextAction: "fix_request" rather than rotate_key. Rotating discards a working key, costs you a redeploy, and leaves the actual misconfiguration in place. Rule out the environment first; only then consider copy-paste truncation or a key revoked from the dashboard.

Full diagnostic walkthrough: 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. Also fires on sandbox/live-mode mismatches — for example, a sandbox key attempting to create a live payment session. 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, but your merchant account has not yet completed onboarding review, so live-key issuance is blocked. Test-mode keys (vp_sk_test_*) remain self-serve at any time via /dashboard/developersCreate sandbox; live keys can only be minted once your account reaches the ready-to-transact (or live) state. If review is still in progress, check /dashboard/settings for your application status and any outstanding requirements; if your application was declined, contact Von Payments support with the details provided in your denial notice. Distinct from auth_merchant_inactive (401), which fires on a previously-approved account that has since been deactivated.

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.

session_expired

HTTP: 410. The session expired (TTL configurable at create — default 30 minutes, range 5 minutes to 7 days), or it reached a terminal state with no successful charge (failed / cancelled). 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 …/confirm) — or a session retrieve — hit a session that already succeeded. The buyer was charged exactly once and it's recorded. This is a duplicate or late call (a retry, double-submit, or page reload). Do not retry, and do not create a new session for the same purchase — either would charge the buyer again. Treat it as success: read the result from your payment_intent.succeeded / charge.succeeded webhook or GET /v1/public/sessions/:id (status succeeded). This is a distinct 409 Conflict (the session is in a terminal succeeded state) — not session_expired's 410 Gone — so even a status-only client won't "start over" into a re-charge. Branch on the code for the remediation.

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. Rare; indicates session metadata in the database no longer matches an invariant the runtime expects. Capture the X-Request-Id and contact support — this is not safely retriable without investigation.

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.

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. (It used to reach callers essentially only from POST /v1/sessions, so older integration code that assumes "I must have been creating a session" is wrong.)

Unknown fields are refused, never ignored. Silently dropping one is how a request looks accepted while the value it carried is lost.

Two causes, in rough order of frequency:

  1. Casing. The session surface is camelCase — success_urlsuccessUrl, line_itemslineItems, unit_amountunitAmount. 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 units1499 = $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 live payment provider configured and is not in sandbox mode, so the requested operation cannot be routed to a provider. The merchant (not the integrator) must complete onboarding to attach a payment provider. To keep building in the meantime, switch to a sandbox API key (vp_sk_test_*) — test mode runs without a configured live provider. Not retriable as-is.

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; an operation supported on one provider may not be on another, and switching providers requires re-onboarding. Not retriable without a different capability or provider.

rate_limit_exceeded

HTTP: 429. The generic rate-limit code, emitted on the IP-axis limiter (and the global primary limiter). Retry after the Retry-After interval. The SDK auto-retries up to maxRetries times. The per-API-key axis has its own distinct code, rate_limit_exceeded_per_key.

rate_limit_exceeded_per_key

HTTP: 429. Per-API-key bucket exceeded on POST /v1/sessions (30 session creates/min). 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 — the SDK auto-retries with backoff.

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 now covers provider rejections during vaulting. Previously those surfaced as provider_unavailable (502, retryable: true) — which was wrong and actively harmful: it instructed clients to retry a request that could never succeed. If you built a retry loop on the old behaviour, it should branch on this code and stop. 422 here is permanent; correct the request instead.

HTTP: 422. Your server attempted a merchant-initiated charge (or a cardholder-initiated one outside the originating intent) against a token whose setup_for_future_use is null or "on_session" — the buyer never consented to off-session reuse.

The existing token cannot be repaired

Consent is write-once. It is recorded at vault time and there is no endpoint that updates it — no PATCH, no PUT, no "promote consent" call. A retry loop that re-sends the same token, or that tries to re-stamp it, cannot succeed no matter how many attempts it makes.

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 (typically a "Save my card for future purchases" checkbox); PSD2/SCA stored-credential rules require the consent record to 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. An inbound provider webhook arrived without the provider's expected signature header. This error is internal to Von Payments' inbound webhook handling, not something your endpoint emits. (The signature on webhooks Von Payments sends to you is the x-vonpay-signature header — verify it with the SDK's webhooks.verifySignature; see below.)

webhook_invalid_signature

HTTP: 401. Webhook signature does not match the expected HMAC. When verifying webhooks Von Payments delivers to you, HMAC the raw request body (not the parsed JSON), using your endpoint or subscription signing secret (whsec_…) — not your API key. See Webhook Signature Verification.

webhook_not_configured

HTTP: 503. Webhook verification secret is not configured on the Von Payments server side. This is an infra-level misconfiguration, not a merchant-side issue. Capture the X-Request-Id and contact support.

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. This is retriable: 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_statusnot this code.

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 yet available for this merchant's payment provider. Capability-gated — read client.capabilities.get() and check supportedOperations before invoking optional operations like paymentIntents.capture, paymentIntents.void, or refunds.create with partial amounts. The capability matrix is provider-dependent; an operation that works on one provider may not work on another. Available in SDK 0.5.0+. Not retriable — the operation will not succeed without a different capability or provider.

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. Available in SDK 0.5.0+.

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, with values such as already_captured, already_voided, not_authorized, and terminal_state — so the SDK / agent can branch without a follow-up retrieve. Available in SDK 0.6.0+. 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 model. 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 — so the difference cannot be collected through this payment intent at all. Collecting it means charging the buyer again on a new intent, which needs them to be reachable and willing. If the final amount is not known yet, capture once you know it rather than capturing low and intending to top up.

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.


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.

A captured card payment does not actually move money until the provider's settlement batch runs — usually overnight. Until that happens a refund is invalid, however long ago the capture succeeded.

The fix is to wait. Once the payment settles, retry the same refund request unchanged and it goes through. The refund is recorded as failed, so no refundable balance was consumed and the retry is clean. Retrying immediately fails identically.

Two things not to do:

  • Do not treat it as a completed refund. Nothing was refunded. If you mark the order refunded on this response, your records and the buyer's card disagree.
  • Do not void or cancel the payment as a workaround. Cancelling is only possible while a payment is still authorized and not yet captured. Any payment that can reach this error has necessarily been captured, so the cancel call rejects it with invalid_transition — you end up no further forward, with two failed calls.

If the buyer has to be made whole before settlement completes, that is an out-of-band decision on your side, not an API call.


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 and a corrected retry is still possible once the underlying cause 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.

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.


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 PATCH /v1/buyers/{id} tried to set an email that already belongs to another of your 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.

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.

That distinction is the whole point of the code, and it is worth branching on:

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

What to do instead: fall back to your asynchronous path. If your integration was relying on this endpoint to tell you the outcome synchronously, 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.

Today this is returned by the public guest-completion endpoint (POST /v1/public/sessions/{id}/complete). That check runs before your key is authenticated, so you will see it with a valid publishable key and it is not a sign your credentials are wrong. It is also deliberately distinct from session_not_found (404): a 404 means that specific session is unknown, which is a real anomaly worth logging, while this means the whole capability is off, which is not.

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_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. It was rejected, not ignored.

settle_only records a payment against a Next Commerce order you built the line for yourself, and it is not available on every request. The reliable alternative, which this code never applies to, is the standard upsell: send parent_order_ref together with the add-on's line_items and Von appends the line and charges for it in one call, amount-matched automatically.

If you specifically need to add the line on your own side, see Settle-only for the conditions that 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 nine 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
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. Von matches an unconfirmed attempt by the purchase: buyer, line items, quantities, prices, voucher.

An identical retry adopts that order. A retry with any of those fields changed creates a second order the buyer never asked for. If the basket genuinely 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. Von could not read its own record of earlier attempts for this purchase, so it refused rather than risk creating a duplicate order. Not a problem with your request, and unrelated to discounts or pricing. Retry the identical body after ~3s.


order_first_record_unavailable

HTTP: 503. Before contacting the store, Von writes a durable record so a lost response can still be recovered. That write failed, so the request was refused rather than calling the store with no way to trace the result. Retry the identical body after ~3s.


order_first_repoint_unavailable

HTTP: 503. This is a retry, and an order for it already stands in your store. Von re-links that order to this attempt so later reconciliation can tell which payment settled it — and that link 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_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. Von refused rather than silently falling back to charge-first — that fallback is the behaviour the account was configured to avoid.

Not retryable. Nothing about the request changes the outcome; this is 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.
    • actionsoft / 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. They are the same value — both are read from one expression, so they cannot drift apart — and both are sent deliberately so that nothing breaks for anyone already reading failure_code.

Branch on whichever you prefer. failure_code is the original name and is not going away; decline_code matches what the API response calls it, so one handler can read both surfaces by the same key.

Branch on failure_code. It is normalized to a fixed vocabulary of values. The full set and recommended handling:

You don't have to maintain this table

Every decline also carries a machine-readable actionsoft / hard / fix_and_retry / generic — derived from one published mapping, on both the API response and the webhook payload. If you're building retry or dunning logic, branch on that rather than re-implementing the column below. The two agree; action is just the version you don't have to keep in sync.

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

Repeating those back at checkout confirms to a card thief that the card is flagged, and tells a legitimate cardholder something alarming that we cannot stand behind — 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.

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.

Writing your own copy keyed off failure_code and the recommended-next-step column above remains a good option when you want control of tone or serve a language other than English — decline_message is English only. Either way, do not wait on an asynchronous webhook to tell someone their card failed; the charge response carries the same fields.

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, the same way rule tags do. On a provider that does not distinguish it, a refusal by your own rule arrives as generic_decline instead — indistinguishable from any other unmapped decline.

Do not treat the absence of blocked_by_rule as proof no rule fired. If you are building decline-rate reporting that excludes your own refusals, confirm your provider reports this code before relying on it.

Its action is hard. The same card will be refused for as long as the rule stands, so the buyer needs a different payment method — not another attempt with the same one.

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 custom code you set on the rule in your provider dashboard, which 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, your provider emits one identical value for every rule on the account, there is nothing to tell them apart with, and rule_code comes back null. That is the single thing that decides whether this is useful to you — set a custom code on each rule before you write any handling.

A null on a blocked_by_rule payment means one of two things, and the first is far more likely:

  • The rule has no custom code. Set one.
  • Your provider does not report rule refusals separately at all — the same condition described in the availability note above, in which case you would be seeing generic_decline rather than blocked_by_rule anyway.

Two properties worth knowing before you build on it:

  • 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. A code that opens with _ or - (_amex_block) is rejected to null, which looks identical to never having named the rule at all. Anything outside the set is refused rather than trimmed to fit, because two rules whose codes differ only in rejected characters would otherwise collapse into one value — and reporting rule A as rule B is worse than reporting nothing.

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. It is not in browser-facing responses, for the reason in the note below.

For a worked example — configuring the rule, branching on it, and what to tell the buyer — see Handling rule declines.

The browser charge response returns a narrower set

POST /v1/public/sessions/{id}/charge — the elements charge-at-submit call — is authenticated with your publishable key, which is public by design and readable from your page source. On browser-facing responses, a rule refusal is reported as the generic card_declined instead of being named. That applies to every response a buyer's browser can receive, not just this one.

Naming it there would let anyone with that key try cards in a loop and map which brands or card ranges your rules refuse, in order to route around them. Most other values in the set describe the attempt itself — the card, the network, or the issuer; this one describes your configuration, which is a different thing to disclose.

The buyer's next step is identical either way: use a different card. To tell a rule refusal apart from a genuine issuer decline, read it from your server on either of the two surfaces that carry it: decline_code on the payment intent create/confirm response, or failure_code on the charge.failed webhook. Both are secret-key authenticated and carry the full value.

Retrieving the intent later with GET /v1/payment_intents/{id} does not return the decline: that endpoint serves persisted state only, and the decline detail is not among it. Capture the value when the charge fails — from the response or the webhook — or you cannot recover it from the API afterwards.

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 it is present it can still be null, for either of two reasons: the decline carried a code we hold no classification for, or the failure arrived on a path that never resolved a normalized code at all. It is deliberately not reported as generic, which would assert the processor gave no reason.

action, decline_code and decline_message are all derived from the normalized decline code, so when one is null for the second reason they are all null together — the whole decline block is absent, not just a field. Never treat a missing action as permission to retry, and do not build retry logic that only works when the block is populated. blocked_by_rule is hard: retrying it re-charges nothing and fails identically for as long as your rule stands.

Trigger the card-based declines above deterministically with the test cards. Note that the 3-D Secure / fraud decline test card surfaces failure_code as fraudulent on the wire. blocked_by_rule is the exception — it cannot be produced by any test card, because it depends on a rule configured on your account rather than on a property of the card. Exercise your handling for it by configuring a rule in your provider dashboard.