Errors
View as MarkdownEvery error from https://api.worthypayments.com/v1 is returned as a single, predictable
envelope, and the HTTP status always matches the error. A declined charge is a 402,
not a 2xx with a hidden error. Branch on the HTTP status and code; for card declines,
also read decline_code.
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "The charge was declined."
}
}
| Field | Always present | Notes |
|---|---|---|
type | yes | The error category. See the table below. |
code | yes | A stable machine-readable reason within the category. |
decline_code | sometimes | For card declines: the issuer's specific reason when the issuer returned one, or the gateway's reason when the gateway refused the payment (see Gateway refusals). |
message | yes | Human-readable. Safe to show (or adapt) for the shopper. On a gateway refusal it is the gateway's own text, with its reference (REFID:…): log it, and show the shopper your own wording. |
A declined charge returns HTTP
402and a failed refund returns HTTP400, both with the envelope above. You can branch on the status code alone; you don't need to inspect a success body for a hidden decline.
Error types#
type | Meaning | HTTP |
|---|---|---|
invalid_request_error | Bad input, missing/invalid field, raw PAN, missing key, missing scope, unknown resource, idempotency conflict. | 400 / 401 / 403 / 404 / 409 |
card_error | The card was declined by the issuer or the acquirer, or the gateway refused the payment before it reached the card network. | 402 |
rate_limit_error | Too many requests. | 429 |
api_error | A transient server-side problem. Internal details are never leaked. | 500 / 502 / 503 |
HTTP status map#
| Status | type | code | When |
|---|---|---|---|
400 | invalid_request_error | parameter_invalid | Edge validation failed (charge amount < 50, capture/refund amount not a whole number ≥ 1, bad currency, missing field) |
400 | invalid_request_error | parameter_unknown | A top-level field this route does not accept. Refused, not ignored. Typical: receipt_email / billing / order_reference on POST /v1/charges (see Charges.) |
400 | invalid_request_error | idempotency_key_required | Idempotency-Key missing on a money-moving endpoint. See Idempotency |
400 | invalid_request_error | idempotency_error | Same key reused with a different request body |
400 | invalid_request_error | currency_not_supported | Currency not enabled for this account |
400 | invalid_request_error | test_mode_only | Endpoint is test-mode only, called with a live key |
400 | invalid_request_error | email_required / ip_required / invalid_action | Required field missing on a fraud endpoint |
400 | invalid_request_error | raw_pan_rejected / pan_encryption_required | A raw card number reached the API. Send an ev: token |
401 | invalid_request_error | api_key_required | No API key on the request |
401 | invalid_request_error | api_key_invalid | Key unknown, expired, or revoked |
401 | invalid_request_error | hmac_headers_missing / hmac_invalid / hmac_nonce_reuse / hmac_timestamp_skew | Request signing failed. See Authentication |
403 | invalid_request_error | account_not_activated | Live key on an account that has not completed onboarding |
403 | invalid_request_error | sandbox_key_required | Test mode only: a call that reaches the gateway (charge, intent confirm, saved card, capture, void, refund) on an account with no NMI sandbox key of its own. Add it in the dashboard (Settings > Integrations, NMI card, in test mode); no sandbox account yet: create one at guide.nmi.com. The refusal is not stored under the Idempotency-Key, so retry the same request once the key is saved. See Testing |
403 | invalid_request_error | mode_not_allowed | Key is fenced to a different execution mode |
403 | invalid_request_error | domain_not_allowed | The request's Origin/Referer is not on the key's allowlist, or a browser key arrived with no Origin at all. Update the list on the key without rolling the secret; see Quickstart step 2 |
402 | card_error | card_declined (+ decline_code) | The card was declined, or the gateway refused the payment (Gateway refusals). Also a capture the gateway refused: the authorization is still held |
400 | invalid_request_error | refund_declined (+ decline_code) | The gateway refused the refund. No money moved; message is the gateway's reason |
400 | invalid_request_error | void_declined (+ decline_code) | The gateway refused the void. The authorization is still held |
403 | invalid_request_error | permission_denied | Valid key, missing scope (or pk_ on a server-only action) |
404 | invalid_request_error | resource_missing | Unknown charge / refund id |
400 | invalid_request_error | resource_missing (param: customer) | POST /v1/payment_intents named a customer this key's merchant and mode do not hold (unknown, deleted, or another account's or mode's). message is No such customer: '<the id you sent>'. The same Idempotency-Key replays this 400; correct the id and use a new key |
400 | invalid_request_error | failed_precondition | The charge is not in a state that allows this follow-up. See Capture, void and refund errors |
400 | invalid_request_error | failed_precondition (FRAUD_BLOCKED) | Our fraud screening (pre-screen velocity or the scored fraud screen) refused the charge before the issuer. A scored block also names the failed charge in charge. See Fraud blocks |
400 | invalid_request_error | invalid_argument | A follow-up amount the charge cannot satisfy (see the same table) |
400 | invalid_request_error | three_ds_amount_mismatch | A 3-D Secure resubmit whose amount or currency differs from what the cardholder authenticated (param: three_ds.session_id). Nothing was charged; run a new challenge. See 3-D Secure |
409 | invalid_request_error | aborted | Another capture or void is already in flight for this charge, retry after it settles |
409 | invalid_request_error | idempotency_in_progress | A request with the same Idempotency-Key is still running |
402 | card_error | card_declined (decline_code three_ds_failed / three_ds_card_not_enrolled) | The three_ds.session_id session did not authenticate (param: three_ds.session_id). Nothing was charged; the refusal is stored under the key. See 3-D Secure |
409 | invalid_request_error | three_ds_session_incomplete | The three_ds.session_id challenge has not finished. Nothing was charged and the key is not used up: resubmit with the same key once it does |
503 | api_error | three_ds_result_unavailable | The three_ds.session_id session authenticated but its result could not be read just now. Nothing was charged; retry with the same key |
400 | invalid_request_error | parameter_invalid (param: three_ds.request) | An unknown three_ds.request, or a request on an instrument 3DS cannot run on (saved card, gateway vault id, wp: envelope, wallet). See 3-D Secure |
429 | rate_limit_error | rate_limit_exceeded | Rate limit hit. See Retry-After |
500 | api_error | internal_error | Unexpected server error |
Cloudflare 1010 | — | — | No browser User-Agent. Not this JSON envelope. An HTML interstitial. See Cloudflare 1010 |
HTML 403 (loopback in the URL) | — | — | ?origin=http://localhost… or ?parent=http://localhost… on the public edge. Not this JSON envelope. Local keys still work, send Origin and omit the query. See Loopback in the URL |
Common authentication & permission errors#
// 401 — missing key
{ "error": { "type": "invalid_request_error", "code": "api_key_required",
"message": "Missing API key" } }
// 401 — invalid / expired / revoked / empty principal
{ "error": { "type": "invalid_request_error", "code": "api_key_invalid",
"message": "Invalid API key" } }
// 403 — missing scope (same shape for secret without the grant, or pk_ on a server-only route)
{ "error": { "type": "invalid_request_error", "code": "permission_denied",
"message": "API key missing scope checkout_links:create" } }
Branch on code, not on the exact message string, prose may be tightened without a
version bump.
See Authentication for the scope rules.
Common request errors#
// 400 — raw card sent instead of an ev: token
{ "error": { "type": "invalid_request_error", "code": "parameter_invalid",
"message": "Card number and CVV must be Evervault tokens (ev:...). Tokenize in-page with the browser SDK; raw card data is never accepted." } }
// 404 — unknown charge
{ "error": { "type": "invalid_request_error", "code": "resource_missing", "message": "No such charge." } }
// 400 — POST /v1/payment_intents named a customer this key's merchant and mode do not hold
{ "error": { "type": "invalid_request_error", "code": "resource_missing", "param": "customer",
"message": "No such customer: 'cus_5d87e946-1c2b-4d3e-8f40-a1b2c3d4e5f6'" } }
// 409 — concurrent retry with the same Idempotency-Key
{ "error": { "type": "invalid_request_error", "code": "idempotency_in_progress",
"message": "Idempotency key is currently processing" } }
Risk decision token#
These appear only when you sent a token. A charge without the field never produces them.
| Code | Cause | What to do |
|---|---|---|
risk_decision_token_invalid | Bad signature, unknown format, or issued to a different account | Do not retry with this token. Call /check again. |
risk_decision_token_expired | Older than 15 minutes | Call /check again. |
risk_decision_token_amount_mismatch | Charge amount or currency differs from the evaluated one | Charge the evaluated amount, or re-evaluate with /check/recheck. |
risk_decision_token_card_mismatch | A different card than the one evaluated | Re-evaluate for the card you are charging. |
risk_decision_token_device_mismatch | A different device fingerprint | Send the same fingerprint you sent to /check. |
risk_decision_token_reused | Already spent; a token is single use | Call /check again. |
All of the above are 400 invalid_request_error with param: "risk_decision_token".
Two that are not:
- A token carrying a
denyverdict declines the charge as402 card_declined(type: "card_error"). You did what the API asked and the answer was no. 503 service_degradedmeans token verification is temporarily unavailable. Retry without the token: the charge still runs the full fraud engine, so this costs you provenance, never the sale.
Common card declines#
Returned with type: "card_error" and HTTP 402 on a charge. decline_code is the
issuer's specific reason when available.
code | decline_code | Retry? |
|---|---|---|
card_declined | insufficient_funds | New card |
card_declined | do_not_honor | New card |
card_declined | security_violation | New card |
card_declined | pickup_card | No, do not retry |
card_declined | fraudulent | No, do not retry |
In test mode, decline with the amount, not the card. The sandbox accepts only its own whitelist of card numbers, and all of them approve; the
4000…decline family answersBad Request. An amount below 1.00 ("amount": 99) declines. There is no per-reason decline card. See Testing, "Test cards".
The contract is a single
card_declinedcode; the issuer's reason travels indecline_code. Earlier revisions listedexpired_card,incorrect_cvcandprocessing_erroras top-level codes. They are not the contract. Branch oncodeanddecline_code: a 2026-08-31 audit still sawdo_not_honorincode.incorrect_cvc/expired_cardPANs are not simulated in the Testing catalog.
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "do_not_honor",
"message": "The charge was declined."
}
}
Gateway refusals#
Sometimes the payment gateway refuses the request itself, before it ever reaches the card
network, for example because an identical payment just went through. That is still a
402 card_error with code: "card_declined", returned at once (the charge does not sit
pending; duplicate_transaction has one exception, below), and decline_code is one of:
decline_code | Meaning | Retry? |
|---|---|---|
duplicate_transaction | The gateway already holds an identical recent payment (same card, same amount). The buyer may already have paid. | No. Look the payment up first |
invalid_merchant | The gateway does not accept this account's request (credentials or account setup). | No, contact support |
invalid_transaction | The gateway does not allow this transaction. | No, contact support |
processing_error | Any other gateway refusal. Nothing was charged. | Yes, after a short delay, with a new Idempotency-Key |
merchant_unavailable | Payment intents and hosted checkout: the account cannot take payments right now (not activated, on hold, or its gateway setup is unusable). Nothing was charged. | No, contact support |
fraudulent | Payment intents and hosted checkout: our fraud screening (the pre-screen or the scored screen) refused the payment. Nothing was charged. See Fraud blocks. | No |
Payment Workflow rules#
A route in your Payment Workflow can refuse a payment before it reaches the gateway: a route
that requires a CVV match, an AVS or postal-code match, a minimum or maximum amount, a currency,
or that blocks anonymous IPs (proxy / VPN). That is the same 402 card_error,
code: "card_declined", on every surface (POST /v1/charges, payment intents, hosted
checkout), with one decline_code and a message the buyer can act on:
decline_code | Meaning | Retry? |
|---|---|---|
incorrect_cvc | The card's security code did not match. | Yes, after the buyer corrects it |
incorrect_zip | The card's postal code did not match. | Yes, after the buyer corrects it |
incorrect_address | The card's billing address did not match. | Yes, after the buyer corrects it |
amount_too_large / amount_too_small | The amount is outside the route's limits. | No, change the amount or the route |
currency_not_supported | The route does not accept this currency. | No |
fraudulent | The route blocks anonymous IPs and this one looked anonymized. | No |
Nothing was charged. When several rules fail, the code names one: an anonymous IP first, then
the field the buyer can fix, then a limit. Retry with a new Idempotency-Key: the same key
replays the same refusal. A payment intent stays usable, so the buyer corrects the field and pays
again on it; the Payment Element shows the matching sentence.
message carries the gateway's own text, including its reference:
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "duplicate_transaction",
"message": "Duplicate transaction REFID:3154897712"
}
}
Do not retry duplicate_transaction. Check GET /charges (or your
charge.succeeded webhook) for the payment it duplicates before you charge again. Retrying
under a new key is how a buyer is charged twice. In test mode the sandbox treats the same
card and amount as a duplicate for about 20 minutes: vary the amount between test runs.
Before it answers duplicate_transaction, WorthyPayments asks the gateway once whether it
already holds this very charge (a copy of your own request that reached it first). If it
does, you get that payment (succeeded, or requires_capture for a manual capture)
instead of the refusal. If the gateway cannot say yet, the charge is pending: retry with
the same Idempotency-Key (see Idempotency).
The same decline reads differently on the charge object: its outcome.network_status is
not_sent_to_network (an issuer decline reads declined_by_network). processing_error is
also an issuer answer, so tell the two apart by outcome.network_status, not by the code. See
Charges.
A gateway that cannot be reached, or does not answer, is not a refusal: that stays a
502 api_error with generic wording, or a pending charge whose outcome is still being
established. See Idempotency.
Fraud blocks (FRAUD_BLOCKED)#
Our fraud screening can refuse a charge before the issuer sees it: first the fraud
pre-screen (velocity rules), then the scored fraud screen. On POST /v1/charges either one
is HTTP 400, type: invalid_request_error, not a 402 card decline. Through a payment
intent (the Payment Element) or the hosted checkout it is a decline instead, with
decline_code: "fraudulent", and the buyer reads only "declined".
The Connect reason is FRAUD_BLOCKED. The public code is failed_precondition. A
pre-screen block names its rule in message. A scored block's message is FRAUD_BLOCKED: decision=BLOCK provider=… and never names a rule or reason code.
A scored block is a recorded failed charge#
When the scored fraud screen refuses a charge, the charge is recorded as a failed charge
and the 400 names it in charge:
{
"error": {
"type": "invalid_request_error",
"code": "failed_precondition",
"message": "FRAUD_BLOCKED: decision=BLOCK provider=internal",
"charge": "TXN-01JABCDEFGHJKMNPQRSTVWXYZ0"
}
}
- Read it back with
GET /v1/charges/{charge}:status: failed,failure_code: fraudulent,failure_message: "Your card was declined."andoutcome{ "network_status": "not_sent_to_network", "type": "blocked", "seller_message": "This payment was blocked by fraud screening.", "risk_level": "highest" }.GET /v1/chargeslists it with the same values. The fraud score and decision are onGET /v1/charges/{id}/risk, never on the error. - Webhooks.
charge.failedandtransaction.failedfire once for it, withdecline.declineOrigin: "PLATFORM". See Webhooks. They fire for a scored block from a payment intent, the hosted checkout or a subscription charge too; those flows still answer with their own decline and do not name the charge. - Retries. The 400 is stored under your
Idempotency-Key. A retry with the same key returns the same body, the samechargeincluded, and records nothing new. See Idempotency. - No 3-D Secure first. The block is decided before any 3-D Secure step-up, so a charge the screen blocks is refused at once, never first sent through a challenge.
- A block can still follow a challenge. The charge a completed challenge resumes is
screened again, so velocity, a re-score or a block added while the buyer was in the
challenge can refuse it. Through a payment intent or the hosted checkout it is the same
fraudulentdecline as on the first submit.
A 400 without charge is otherwise identical, and nothing was charged. It is one of:
- a fraud pre-screen block (below), which never carries one;
- a rare scored block for which the payments service returned no charge id. Its record
may not have been written, or it was written and only the answer naming it was lost. In
that second case the failed charge exists, and its
charge.failed/transaction.failedwebhooks fire for aTXN-…the 400 never named.
Either way the 400 is stored under your Idempotency-Key and replayed exactly for 24
hours, without charge.
Pre-screen velocity#
Live-verified test-mode rule: about 5 distinct emails / same PAN / 1440 minutes. The 6th distinct email returns:
{
"error": {
"type": "invalid_request_error",
"code": "failed_precondition",
"message": "FRAUD_BLOCKED: pre-screen velocity_emails_per_card"
}
}
Reuse audit+0005@example.com (and Amex 341111111111111) to stay under the cap. See
Testing, velocity. Do not treat this as
“banned/spam cards are always blocked” in the Payment Element. That copy is not a verified
contract.
Cloudflare 1010#
A request that never reaches the API (typically a missing or empty User-Agent) is
answered by Cloudflare as 1010. That is an HTML interstitial, not { "error": { … } }. There is no code to branch on. Curl's default UA and a Firefox UA both pass; Chrome
is not required. An empty UA dies.
curl https://api.worthypayments.com/v1/ping \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: sk_test_xxx"
The examples still send -A so a copy-paste cannot lose the header. The browser SDK
already sends a real UA.
Loopback in the URL#
A second public-edge HTML 403, unrelated to User-Agent: a query string that
contains http://localhost (or http://127.0.0.1). Typical probes:
GET /v1/element-frameable?origin=http://localhost:3000
GET /element?parent=http://localhost:3000
The body is <h1>403 Forbidden</h1>, server: cloudflare, no JSON error.
The API never runs. Local development is allowed. List localhost on the
publishable key and send the origin as a header, with no query:
curl https://api.worthypayments.com/v1/element-frameable \
-A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" \
-H "x-api-key: pk_test_xxx" \
-H "Origin: http://localhost:3000"
200 {"object":"element_frameable","allowed":true} means that origin may frame the
element. The hosted SDK already sends Origin and does not put a raw loopback URL in the
iframe query. You do not have to encode anything.
A 403 JSON envelope with code: "domain_not_allowed" is a different
failure: the request reached the API and the origin is not on the key. Add
the hostname to Allowed domains. HTML 403 means the request never got
that far.
See Embedded SDK (local development and Testing) loopback in the URL.
Capture, void and refund errors#
A follow-up on an existing charge (POST /charges/:id/capture, /void, or POST /refunds) can fail for reasons the charge itself already settled. The code tells you
whether it is worth retrying; the reason travels in message.
code | message | Meaning | Retry? |
|---|---|---|---|
resource_missing | TRANSACTION_NOT_FOUND | No charge with that id under this key | No |
failed_precondition | INVALID_STATE_FOR_CAPTURE | Not an uncaptured authorization | No |
failed_precondition | INVALID_STATE_FOR_VOID | Already captured, voided or failed | No |
failed_precondition | INVALID_STATE_FOR_REFUND | Never captured, so there is nothing to return | No |
failed_precondition | REFUND_WINDOW_EXPIRED | Past the acquirer's refund window | No |
failed_precondition | TRANSACTION_DISPUTED | The issuer is already pulling this money back through a chargeback. Resolve the dispute instead | No |
failed_precondition | ALREADY_FULLY_REFUNDED | The captured total is already returned | No |
invalid_argument | REFUND_EXCEEDS_REMAINING | Refund is larger than what is left | Fix the amount |
invalid_argument | CAPTURE_EXCEEDS_AUTHORIZED | Capture is larger than the authorization | Fix the amount |
invalid_argument | CAPTURE_AMOUNT_INVALID | Not a positive integer of minor units | Fix the amount |
invalid_argument | CAPTURE_CURRENCY_MISMATCH | Currency differs from the authorization's | Fix the currency |
aborted | CAPTURE_IN_PROGRESS | Another capture holds this charge | Yes, after it settles |
aborted | VOID_IN_PROGRESS | Another void holds this charge | Yes, after it settles |
Branch on
code, not onmessage. The upper-case string is the internal reason and is stable enough to log, but it is not a versioned contract.failed_preconditionandresource_missingmean "do not retry this request as sent";invalid_argumentmeans "the same call works with a corrected amount";abortedis the only one worth repeating unchanged.
Amount errors read as 400, not 402: nothing was presented to a card, so there is no
decline to report.
Retry guidance#
429/5xx/ network errors: retry with exponential backoff, honoringRetry-Afteron a 429. These are transient. See Rate limits.processing_error: nothing was charged. Retry after a short delay with a newIdempotency-Key. The same key replays this decline.duplicate_transaction: do not retry. The gateway already holds an identical recent payment. CheckGET /chargesbefore charging again.invalid_merchant/invalid_transaction: do not retry unchanged. Contact support with theREFIDfrommessage.- Hard declines (
pickup_card,fraudulent): do not retry the same card; ask the shopper for a different payment method. - Always send a stable
Idempotency-Keyon charges and refunds (same key for the same logical payment, not a new UUID every click). A charge that returns a network error may still have been processed. Retrying with the same key replays the original result instead of charging twice. Apendingcharge is the one answer that is not replayed: retry it with the same key to learn the outcome. If in doubt, reconcile viaGET /chargesor your webhook. invalid_request_error(api_key_required/api_key_invalid/permission_denied/parameter_invalid): never retry blindly; fix the request, key, or scope first.
