Handle declines
When a charge fails, Frame sets failure_code on the resulting Charge record and emits a charge.failed webhook. The full code list (~53 entries) lives in the decline codes concept — this guide is the action playbook: for each common category, what should your application actually do.
The high-level rule: never tell the customer the specific reason a fraud-related charge declined. Disclosing fraud signals to bad actors trains them; offending false-positive customers loses you trust. For non-fraud declines (insufficient funds, expired card), be specific — the customer can fix those.
The decision tree
When charge.failed fires, classify by failure_code:
// Webhook handler
if (event.type !== 'charge.failed') return;
const failureCode = event.data.failure_code;
if (FRAUD_CODES.includes(failureCode)) {
// Don't disclose. Generic message + log internally for review.
logFraudFlag(event.data);
notifyCustomer('We couldn\'t process this payment. Please contact your card issuer or try a different card.');
} else if (RETRYABLE_CODES.includes(failureCode)) {
// Customer can fix. Surface a specific message.
notifyCustomer(MESSAGES[failureCode]);
} else if (failureCode === 'authentication_required') {
// 3DS challenge — different flow, see below.
promptForAuthentication(event.data.charge_intent_id);
} else {
// Generic decline — issuer-side. Customer should contact their bank.
notifyCustomer('Your card was declined. Please contact your card issuer.');
}
The four categories that cover ~90% of the volume:
Category 1 — fraud signals (don't disclose, don't auto-retry)
| Code | Source |
|---|---|
fraudulent | Sonar flagged the transaction |
merchant_blacklist | Matches your platform's block list |
lost_card | Network flagged the card as reported lost |
stolen_card | Network flagged the card as reported stolen |
pickup_card | Network requested the card be retained (severe) |
What to do:
- Surface a generic "we couldn't process this payment" message to the customer. Don't say "fraud detected" — even if true, you're leaking signal to bad actors and offending legitimate customers caught in false positives.
- Don't auto-retry. Retrying a
fraudulent/stolen_card/lost_carddecline is a strong fraud signal in itself and degrades your platform's reputation with networks. - Log internally for review. If you're seeing patterns (specific email domains, specific IPs, specific velocity), feed them to your fraud-ops team.
- For
merchant_blacklist, that's your platform's own block list — review it if you suspect a false entry.
See set up Sonar fraud protection for the upstream prevention work that reduces the rate of fraud-flagged declines.
Category 2 — customer-fixable (be specific, allow retry)
| Code | What to tell the customer |
|---|---|
insufficient_funds | "Your card was declined for insufficient funds. Please try a different card or payment method." |
expired_card | "Your card has expired. Please update your card details." |
incorrect_cvc | "The security code (CVC) was incorrect. Please re-enter it." |
incorrect_zip | "The billing ZIP code didn't match. Please re-enter it." |
invalid_number | "The card number was invalid. Please re-enter it." |
invalid_expiry_month / invalid_expiry_year | "The expiration date was invalid." |
card_velocity_exceeded | "Your card has reached a transaction limit. Please contact your card issuer or try a different card." |
What to do:
- Surface a specific message — the customer can resolve these. Generic "card declined" hides the action they need to take.
- Allow retry. Customer fixes the input, calls
createTransfer(orcreateChargeIntent) again with the corrected data. - For
card_velocity_exceeded, retry with a different card; the original is rate-limited. - For
expired_card/invalid_*, the right next step is the customer updating their card on file (if you've stored one) or re-entering at checkout.
Category 3 — issuer-side (generic message, optional retry)
| Code | Source |
|---|---|
do_not_honor | Issuer declined without specifying a reason (common — issuer fraud-side decline) |
generic_decline | Catch-all issuer decline |
transaction_not_allowed | Issuer policy block (e.g., card not authorized for this MCC) |
card_not_supported | Card type or region not supported |
try_again_later | Issuer-side transient problem |
What to do:
- Surface a generic "card declined; please contact your card issuer or try a different card" message. The specific reason isn't actionable from the customer's side — they have to call their bank.
- Don't auto-retry
do_not_honororgeneric_declineimmediately. These are typically issuer-side fraud-or-policy decisions and a fast retry looks like a card-testing pattern. try_again_lateris the one exception — it's a transient signal. Wait 30 seconds to a few minutes, then retry once. If it fails again, treat as a hard decline.
Category 4 — authentication required (run 3DS)
authentication_required means the issuer requested 3D Secure authentication before approving the charge. This isn't a hard decline — it's a step-up signal.
What to do:
- Run the 3DS challenge via frame-js. See handle 3D Secure for the integration.
- After the customer completes authentication, the charge proceeds. If they abandon or fail authentication, surface a generic decline message.
Retry mechanics
Frame doesn't expose a retry helper — retrying is creating a new charge:
# Original charge failed; merchant + customer remain valid.
# Retry by calling createTransfer (or createChargeIntent) again.
curl --request POST \
--url https://api.framepayments.com/v1/transfers \
--header "Authorization: Bearer $FRAME_SECRET_KEY" \
--header 'Content-Type: application/json' \
--data '{
"amount": 5000,
"currency": "USD",
"account_id": "<account_id>",
"payment_method_id": "<corrected_payment_method_id>",
"sonar_session_id": "<sonar_session_id>"
}'
Each retry is a fresh charge — new charge ID, fresh Sonar evaluation, fresh issuer authorization. There's no "reuse the failed charge intent" path on the Transfer surface.
A few retry rules of thumb:
- Wait between retries on issuer-side declines. Burst-retrying the same card looks like card testing to both Frame's risk surface and the issuer's. Spread retries over minutes, not seconds.
- Cap retry count. A retry budget of 1-2 attempts per session is plenty; anything more burns Sonar score and customer patience.
- Don't retry fraud-flagged declines. Going back to Category 1 — repeated fraud-flagged attempts make the fraud signal stronger, not weaker.
- Handle network-timeout ambiguity. If you retry because of a network timeout (you didn't get a response and don't know if the original succeeded), there's a real risk of double-charging — Frame's transfer-create surface doesn't accept a client-supplied idempotency key today. Before retrying, query the customer's recent charges (
GET /v1/charges?account=<id>) to check whether the prior attempt landed. The safe order: query first, retry only if no recent matching charge is found.
What failure_code looks like in practice
The Charge record carries failure_code as a categorical enum. The same code can come from different layers — Sonar, the processor, the issuer — and they all surface as the same Charge field. The webhook payload includes both the code and a human-readable message; surface the code to your logs, the friendly message to the customer (when appropriate per category above).
{
"type": "charge.failed",
"data": {
"id": "ch_...",
"amount_cents": 5000,
"status": "failed",
"failure_code": "insufficient_funds",
"failure_message": "Your card was declined due to insufficient funds.",
...
}
}
failure_code is on Charge, not Transfer. The Transfer model has a coarser failure_reason that aggregates across the underlying charge's failure detail — for granular decline handling, read latest_charge.failure_code.
Operational patterns
Build a decline dashboard. Bucket failures by failure_code over time. Spikes in any single code point at upstream issues — a spike in card_velocity_exceeded may indicate a card-testing attack; a spike in do_not_honor may indicate an issuer is suspicious of your traffic and warrants Frame ops involvement.
Bin decline codes by category at log time. Tag each log with decline_category (fraud / customer_fixable / issuer / auth_required) so downstream analysis groups the right things. The raw code list is too granular to query against by hand.
Sonar tuning is a decline-rate lever. If Category 1 (fraud) rates feel too high, look at Sonar configuration with Frame's risk team — false-positive declines hurt conversion. If Category 3 (issuer) rates are too high, the upstream lever is generally billing-descriptor clarity + 3DS adoption.
Gotchas
Symptom: you retried an insufficient_funds decline 30 seconds later and got the same code back. Why: the customer's funds didn't materially change in 30 seconds. Fix: don't auto-retry insufficient_funds. Surface the message and let the customer decide what to do (different card, wait until payday, etc.). Auto-retrying is just burning your retry budget.
Symptom: you got authentication_required and gave up. Why: authentication_required is the issuer asking for 3DS, not a final decline. Fix: run the 3DS challenge per handle 3D Secure. After successful authentication, the charge proceeds.
Symptom: the failure_code you saw isn't in the documented enum. Why: the enum evolves; Frame may add codes for new decline categories. Fix: treat unknown codes as Category 3 (issuer-side, generic message) by default. Log them and check the decline codes concept for additions.
Symptom: the customer complains "I called my bank and they said the charge wasn't even attempted." Why: the decline may have happened before the issuer authorization — Sonar block, network-level rejection, or Frame-side validation failure. Issuer-side logs reflect issuer-side attempts; pre-issuer declines don't show up there. Fix: if the customer is insistent, share the failure_code value with them privately (only for non-fraud codes). For fraud codes, hold the line on the generic message.
Next steps
- Decline codes concept — the full ~53-entry enum with per-code detail
- Set up Sonar fraud protection — upstream prevention work
- Handle 3D Secure — the
authentication_requiredflow