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)

CodeSource
fraudulentSonar flagged the transaction
merchant_blacklistMatches your platform's block list
lost_cardNetwork flagged the card as reported lost
stolen_cardNetwork flagged the card as reported stolen
pickup_cardNetwork 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_card decline 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)

CodeWhat 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 (or createChargeIntent) 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)

CodeSource
do_not_honorIssuer declined without specifying a reason (common — issuer fraud-side decline)
generic_declineCatch-all issuer decline
transaction_not_allowedIssuer policy block (e.g., card not authorized for this MCC)
card_not_supportedCard type or region not supported
try_again_laterIssuer-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_honor or generic_decline immediately. These are typically issuer-side fraud-or-policy decisions and a fast retry looks like a card-testing pattern.
  • try_again_later is 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