Decline codes
When a card payment fails, the failed charge carries a decline code describing why. Frame normalizes the raw codes from card networks and issuing banks into a developer-friendly enum — so you don't have to learn dozens of issuer-specific strings to know whether to prompt the customer to retry or surface a generic error.
The decline code lives on the failure_code field of the underlying Charge record (which the transfer wraps). The parent Transfer carries a more general failure_reason string that points at the broader failure category. For programmatic handling of decline scenarios, read the Charge's failure_code and branch on the categorical reason.
Soft vs hard declines
The first cut on every decline code: was the failure transient (try again, maybe with a tweak) or permanent (this card isn't going to work)?
- Soft decline — temporary, retryable. Insufficient funds, issuer reachability problems, requires 3D Secure authentication. The customer can take an action (top up the card, complete an authentication step) and a retry may succeed.
- Hard decline — permanent, do not retry. Stolen card, fraud, expired card, invalid account. The customer needs to use a different payment method.
Frame's recommended response for each code is summarized in the table below, but the merchant judgement is: soft codes are worth one retry attempt with appropriate UX; hard codes should immediately route the customer to a different payment method.
Categories worth knowing
Behind the long enum is a smaller set of categories that drive the right merchant response:
| Category | Behavior | Representative codes |
|---|---|---|
| Authentication required | Soft. Try again with 3DS challenge. | authentication_required |
| Insufficient funds | Soft. Customer needs to top up or use different card. | insufficient_funds, card_velocity_exceeded, withdrawal_count_limit_exceeded |
| Data error | Soft. Customer mis-entered card data; retry with corrected info. | incorrect_number, incorrect_cvc, incorrect_zip, invalid_expiry_month, invalid_expiry_year, invalid_amount |
| Card-state error | Hard. Expired, invalid, or restricted card. | expired_card, invalid_account, restricted_card, card_not_supported, currency_not_supported, new_account_information_available |
| Fraud / risk signal | Hard. Don't disclose the reason to the customer — surface as generic_decline. | fraudulent, lost_card, stolen_card, pickup_card, merchant_blacklist, security_violation |
| Issuer-side unknown | Soft-ish. Issuer flagged the charge without telling Frame why; customer should contact their bank. | call_issuer, do_not_honor, generic_decline, no_action_taken, try_again_later, issuer_not_available |
| Processing error | Soft. Transient platform-side failure; retry. | processing_error, reenter_transaction |
| Test-mode | N/A in production. | testmode_decline |
The merchant rule of thumb: anything in the "fraud / risk signal" row should be surfaced to the customer as a generic error. Telling the customer "your card was reported stolen" is bad for two reasons: it's customer-hostile (they may not know, and your platform isn't the right place to find out), and it tips off actual fraudsters about which signals tripped them.
Full code list
The complete enum, with Frame's recommended next step:
| Decline code | Description | Next step |
|---|---|---|
account_closed | Customer's bank account has been closed | Customer uses a different payment method |
authentication_required | Transaction requires 3DS authentication | When using frame-js, the auth flow typically triggers automatically — let the customer authenticate and retry |
approve_with_id | Payment can't be authorized | Retry once; if still failing, customer contacts issuer |
call_issuer | Declined for an unknown reason | Customer contacts their card issuer |
card_not_enrolled | Card failed 3DS authentication enrollment | Customer uses a different payment method, or completes 3DS challenge if available |
card_not_supported | Card doesn't support this purchase type | Customer contacts issuer to verify capabilities |
card_velocity_exceeded | Customer exceeded a velocity limit | Customer contacts issuer |
cardholder_name_mismatch | Cardholder name doesn't match the name on the account | Retry with corrected name |
currency_not_supported | Card doesn't support the specified currency | Customer should verify currency support with issuer |
debit_not_authorized | Customer notified their bank the payment was unauthorized | Customer must use a different payment method; do not retry |
do_not_honor | Declined; reason undisclosed by issuer | Customer contacts issuer |
do_not_try_again | Declined; do not retry | Customer must use a different payment method |
duplicate_transaction | Identical recent transaction detected | Check for an existing recent payment before retrying |
expired_card | Card has expired | Customer uses a different card |
fraudulent | Suspected fraud | Surface as generic_decline to customer; do not disclose |
generic_decline | Declined for an unknown reason | Customer contacts issuer |
incorrect_number | Card number entered incorrectly | Retry with correct number |
incorrect_cvc | CVC entered incorrectly | Retry with correct CVC |
incorrect_pin | Incorrect PIN (card-reader payments only) | Retry with correct PIN |
incorrect_zip | Postal code incorrect | Retry with correct billing postal code |
insufficient_funds | Card has insufficient funds | Customer uses alternative payment method |
invalid_account | Card or account invalid | Customer contacts issuer |
invalid_amount | Amount invalid or exceeds limit | If amount is correct, customer checks with issuer |
invalid_cvc | Incorrect CVC | Retry with correct CVC |
invalid_expiry_month | Invalid expiration month | Retry with correct expiration |
invalid_expiry_year | Invalid expiration year | Retry with correct expiration |
invalid_number | Incorrect card number | Retry with correct number |
invalid_pin | Incorrect PIN | Retry with correct PIN |
issuer_not_available | Card issuer unreachable | Retry payment; persistence means customer contacts issuer |
lost_card | Card reported lost | Surface as generic_decline |
merchant_blacklist | Matches a value on the platform's block list | Surface as generic_decline |
new_account_information_available | Card or account invalid; new info exists | Customer contacts issuer |
no_account | Bank account couldn't be located | Customer uses a different payment method |
no_action_taken | Declined; reason undisclosed | Customer contacts issuer |
not_permitted | Payment not permitted | Customer contacts issuer |
offline_pin_required | Card requires PIN | Customer retries with card inserted + PIN |
online_or_offline_pin_required | Card requires PIN | If reader supports online PIN, prompt without new transaction; else retry with PIN |
pickup_card | Card may be reported lost/stolen | Customer contacts issuer; surface as generic_decline |
pin_try_exceeded | PIN attempts exceeded | Customer uses different payment method |
processing_error | Error processing the card | Retry; if persistent, try again later |
reenter_transaction | Issuer couldn't process for unknown reason | Retry once; persistence means customer contacts issuer |
restricted_card | Card may be reported lost/stolen | Customer contacts issuer; surface as generic_decline |
revocation_of_all_authorizations | Declined; reason undisclosed | Customer contacts issuer |
revocation_of_authorization | Declined; reason undisclosed | Customer contacts issuer |
security_violation | Security check failed | Customer contacts issuer |
service_not_allowed | Service type not allowed | Customer contacts issuer |
stolen_card | Card reported stolen | Surface as generic_decline |
stop_payment_order | Stop-payment order on the account | Customer contacts issuer |
testmode_decline | Test card used outside test mode | Use a real card |
transaction_not_allowed | Transaction type not allowed | Customer contacts issuer |
try_again_later | Declined for an unknown reason; retry possible | Retry; persistence means customer contacts issuer |
unsupported_card_network | Card network not supported by the merchant's processor | Customer uses a different card |
withdrawal_count_limit_exceeded | Balance or credit limit exceeded | Customer uses alternative payment method |
Customer-facing display
When a decline involves fraud or card-loss signals (fraudulent, lost_card, stolen_card, pickup_card, merchant_blacklist, restricted_card), always show the customer a generic error message like "We couldn't process your card. Please try a different payment method." Don't expose the underlying decline code or its specific reason. Two reasons:
- Customer experience — telling a customer their card is reported stolen via your checkout flow is harsh and likely wrong context (they may not know yet, or the report may be erroneous).
- Fraud prevention — fraudsters use decline reasons to learn which signals to evade. Generic errors are slightly more friction for them.
For all other decline codes, the description is safe to surface directly — "insufficient funds," "invalid CVC," "card expired" — these help the customer understand what to fix.
Reading the code
Decline codes appear on the underlying Charge record (which the failed transfer wraps):
{
"id": "charge_...",
"status": "failed",
"failure_code": "insufficient_funds",
"failure_message": "Your card has insufficient funds."
}
The parent Transfer surfaces a coarser failure_reason string for general categorization; the Charge's failure_code is the specific normalized decline.
Listen for transfer.failed and charge.failed webhook events to react to declines in real time. The payload references both the transfer and the parent charge so you can pull failure_code from the latter without an extra round-trip.
The full enum lives in code at app/models/concerns/charge/failure_messages.rb — it carries the canonical list (currently ~53 entries; the set evolves as Frame's processors normalize new issuer behavior). The table above covers the most commonly encountered codes; consult the openapi spec or the Charge schema for the current complete list.
Gotchas
Symptom: you keep retrying a charge after do_not_honor and the customer is getting frustrated. Why: do_not_honor is the issuer's way of saying "we're not telling you why, but stop." Persistent retries against a do_not_honor decline can land the merchant on the issuer's velocity-limit watchlist or trigger a fraud flag. Fix: one retry is reasonable; multiple retries on the same card with the same code is not. Route the customer to an alternative payment method.
Symptom: the decline code is authentication_required but you don't have 3DS configured. Why: some issuers require 3D Secure for higher-risk transactions and will decline non-3DS attempts. Frame.js handles this automatically when you're using its Card element — without frame-js, you'd need to integrate 3DS yourself. Fix: use frame-js for card collection, or accept that some transactions will fail without 3DS configured. See 3D Secure.
Symptom: sandbox transactions return testmode_decline. Why: you're using a sandbox key against a non-test card number, or a production key against a test card. Fix: match the key environment to the card. Sandbox key + test card numbers from Testing; production key + real cards.
Reference
For charge-flow troubleshooting, see POST/v1/transfers and Accept a payment for the integration-side patterns.