Errors

View as Markdown

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

json
{
  "error": {
    "type": "card_error",
    "code": "card_declined",
    "decline_code": "insufficient_funds",
    "message": "The charge was declined."
  }
}
FieldAlways presentNotes
typeyesThe error category. See the table below.
codeyesA stable machine-readable reason within the category.
decline_codesometimesFor 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).
messageyesHuman-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 402 and a failed refund returns HTTP 400, 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#

typeMeaningHTTP
invalid_request_errorBad input, missing/invalid field, raw PAN, missing key, missing scope, unknown resource, idempotency conflict.400 / 401 / 403 / 404 / 409
card_errorThe card was declined by the issuer or the acquirer, or the gateway refused the payment before it reached the card network.402
rate_limit_errorToo many requests.429
api_errorA transient server-side problem. Internal details are never leaked.500 / 502 / 503

HTTP status map#

StatustypecodeWhen
400invalid_request_errorparameter_invalidEdge validation failed (charge amount < 50, capture/refund amount not a whole number ≥ 1, bad currency, missing field)
400invalid_request_errorparameter_unknownA top-level field this route does not accept. Refused, not ignored. Typical: receipt_email / billing / order_reference on POST /v1/charges (see Charges.)
400invalid_request_erroridempotency_key_requiredIdempotency-Key missing on a money-moving endpoint. See Idempotency
400invalid_request_erroridempotency_errorSame key reused with a different request body
400invalid_request_errorcurrency_not_supportedCurrency not enabled for this account
400invalid_request_errortest_mode_onlyEndpoint is test-mode only, called with a live key
400invalid_request_erroremail_required / ip_required / invalid_actionRequired field missing on a fraud endpoint
400invalid_request_errorraw_pan_rejected / pan_encryption_requiredA raw card number reached the API. Send an ev: token
401invalid_request_errorapi_key_requiredNo API key on the request
401invalid_request_errorapi_key_invalidKey unknown, expired, or revoked
401invalid_request_errorhmac_headers_missing / hmac_invalid / hmac_nonce_reuse / hmac_timestamp_skewRequest signing failed. See Authentication
403invalid_request_erroraccount_not_activatedLive key on an account that has not completed onboarding
403invalid_request_errorsandbox_key_requiredTest 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
403invalid_request_errormode_not_allowedKey is fenced to a different execution mode
403invalid_request_errordomain_not_allowedThe 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
402card_errorcard_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
400invalid_request_errorrefund_declined (+ decline_code)The gateway refused the refund. No money moved; message is the gateway's reason
400invalid_request_errorvoid_declined (+ decline_code)The gateway refused the void. The authorization is still held
403invalid_request_errorpermission_deniedValid key, missing scope (or pk_ on a server-only action)
404invalid_request_errorresource_missingUnknown charge / refund id
400invalid_request_errorresource_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
400invalid_request_errorfailed_preconditionThe charge is not in a state that allows this follow-up. See Capture, void and refund errors
400invalid_request_errorfailed_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
400invalid_request_errorinvalid_argumentA follow-up amount the charge cannot satisfy (see the same table)
400invalid_request_errorthree_ds_amount_mismatchA 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
409invalid_request_errorabortedAnother capture or void is already in flight for this charge, retry after it settles
409invalid_request_erroridempotency_in_progressA request with the same Idempotency-Key is still running
402card_errorcard_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
409invalid_request_errorthree_ds_session_incompleteThe 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
503api_errorthree_ds_result_unavailableThe three_ds.session_id session authenticated but its result could not be read just now. Nothing was charged; retry with the same key
400invalid_request_errorparameter_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
429rate_limit_errorrate_limit_exceededRate limit hit. See Retry-After
500api_errorinternal_errorUnexpected 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#

jsonc
// 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#

jsonc
// 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.

CodeCauseWhat to do
risk_decision_token_invalidBad signature, unknown format, or issued to a different accountDo not retry with this token. Call /check again.
risk_decision_token_expiredOlder than 15 minutesCall /check again.
risk_decision_token_amount_mismatchCharge amount or currency differs from the evaluated oneCharge the evaluated amount, or re-evaluate with /check/recheck.
risk_decision_token_card_mismatchA different card than the one evaluatedRe-evaluate for the card you are charging.
risk_decision_token_device_mismatchA different device fingerprintSend the same fingerprint you sent to /check.
risk_decision_token_reusedAlready spent; a token is single useCall /check again.

All of the above are 400 invalid_request_error with param: "risk_decision_token".

Two that are not:

  • A token carrying a deny verdict declines the charge as 402 card_declined (type: "card_error"). You did what the API asked and the answer was no.
  • 503 service_degraded means 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.

codedecline_codeRetry?
card_declinedinsufficient_fundsNew card
card_declineddo_not_honorNew card
card_declinedsecurity_violationNew card
card_declinedpickup_cardNo, do not retry
card_declinedfraudulentNo, 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 answers Bad 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_declined code; the issuer's reason travels in decline_code. Earlier revisions listed expired_card, incorrect_cvc and processing_error as top-level codes. They are not the contract. Branch on code and decline_code: a 2026-08-31 audit still saw do_not_honor in code. incorrect_cvc / expired_card PANs are not simulated in the Testing catalog.

json
{
  "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_codeMeaningRetry?
duplicate_transactionThe gateway already holds an identical recent payment (same card, same amount). The buyer may already have paid.No. Look the payment up first
invalid_merchantThe gateway does not accept this account's request (credentials or account setup).No, contact support
invalid_transactionThe gateway does not allow this transaction.No, contact support
processing_errorAny other gateway refusal. Nothing was charged.Yes, after a short delay, with a new Idempotency-Key
merchant_unavailablePayment 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
fraudulentPayment 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_codeMeaningRetry?
incorrect_cvcThe card's security code did not match.Yes, after the buyer corrects it
incorrect_zipThe card's postal code did not match.Yes, after the buyer corrects it
incorrect_addressThe card's billing address did not match.Yes, after the buyer corrects it
amount_too_large / amount_too_smallThe amount is outside the route's limits.No, change the amount or the route
currency_not_supportedThe route does not accept this currency.No
fraudulentThe 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:

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

json
{
  "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." and outcome { "network_status": "not_sent_to_network", "type": "blocked", "seller_message": "This payment was blocked by fraud screening.", "risk_level": "highest" }. GET /v1/charges lists it with the same values. The fraud score and decision are on GET /v1/charges/{id}/risk, never on the error.
  • Webhooks. charge.failed and transaction.failed fire once for it, with decline.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 same charge included, 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 fraudulent decline 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.failed webhooks fire for a TXN-… 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:

json
{
  "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.

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

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

codemessageMeaningRetry?
resource_missingTRANSACTION_NOT_FOUNDNo charge with that id under this keyNo
failed_preconditionINVALID_STATE_FOR_CAPTURENot an uncaptured authorizationNo
failed_preconditionINVALID_STATE_FOR_VOIDAlready captured, voided or failedNo
failed_preconditionINVALID_STATE_FOR_REFUNDNever captured, so there is nothing to returnNo
failed_preconditionREFUND_WINDOW_EXPIREDPast the acquirer's refund windowNo
failed_preconditionTRANSACTION_DISPUTEDThe issuer is already pulling this money back through a chargeback. Resolve the dispute insteadNo
failed_preconditionALREADY_FULLY_REFUNDEDThe captured total is already returnedNo
invalid_argumentREFUND_EXCEEDS_REMAININGRefund is larger than what is leftFix the amount
invalid_argumentCAPTURE_EXCEEDS_AUTHORIZEDCapture is larger than the authorizationFix the amount
invalid_argumentCAPTURE_AMOUNT_INVALIDNot a positive integer of minor unitsFix the amount
invalid_argumentCAPTURE_CURRENCY_MISMATCHCurrency differs from the authorization'sFix the currency
abortedCAPTURE_IN_PROGRESSAnother capture holds this chargeYes, after it settles
abortedVOID_IN_PROGRESSAnother void holds this chargeYes, after it settles

Branch on code, not on message. The upper-case string is the internal reason and is stable enough to log, but it is not a versioned contract. failed_precondition and resource_missing mean "do not retry this request as sent"; invalid_argument means "the same call works with a corrected amount"; aborted is 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, honoring Retry-After on a 429. These are transient. See Rate limits.
  • processing_error: nothing was charged. Retry after a short delay with a new Idempotency-Key. The same key replays this decline.
  • duplicate_transaction: do not retry. The gateway already holds an identical recent payment. Check GET /charges before charging again.
  • invalid_merchant / invalid_transaction: do not retry unchanged. Contact support with the REFID from message.
  • Hard declines (pickup_card, fraudulent): do not retry the same card; ask the shopper for a different payment method.
  • Always send a stable Idempotency-Key on 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. A pending charge is the one answer that is not replayed: retry it with the same key to learn the outcome. If in doubt, reconcile via GET /charges or 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.