Handling Declined Payments in Agent Workflows: Recovery Flows That Learn
Stripe decline codes map to specific recovery actions — trigger 3DS, retry a bounded number of times, request a new payment method, or stay silent.
Published on • October 7, 2026
AI Assistant

An autonomous workflow that charges cards will eventually hit a decline. What separates a resilient system from a embarrassing one is which decline it was and what the agent does next. A card that needs 3-D Secure is a very different event from a card reported stolen — and Stripe’s decline codes tell you exactly which you’re dealing with.
This is a practical map from Stripe’s decline codes reference to agent branching logic.
How Stripe Represents a Decline
When a charge fails, the PaymentIntent carries last_payment_error.decline_code. Stripe’s codes expand on the underlying issuer/network codes:
network_decline_code— 2–4 digits, meaning depends on the card brandnetwork_advice_code— Mastercard’s Merchant Advice Codes (MAC)advice_code— Stripe’s own next-step hint (may be absent)
All three can be null. Your branching should key off decline_code first, and treat the others as enrichers.
The Recovery Map
Group Stripe’s codes by the action an agent should take:
1. Trigger authentication (3DS/SCA)
| Code | Meaning | Action |
|---|---|---|
authentication_required | Needs 3DS/SCA | On-session Stripe front ends trigger auth automatically. Off-session: ask the customer to retry, or collect/prepare authentication on-session first. If it recurs post-auth → customer contacts issuer. |
authentication_not_handled | Customer skipped required auth | Run the EMV 3DS/SCA flow; off-session, authenticate on-session first, then fall back. |
mobile_device_authentication_required | Contactless auth needed | Ask the customer to tap their mobile device again. |
This is the one category where retry after a specific intervention is the correct move — a soft decline resolved by authentication usually succeeds on the next attempt.
2. Retry a bounded number of times
| Code | Meaning | Action |
|---|---|---|
issuer_not_available | Issuer unreachable | Attempt the payment again. |
processing_error, reenter_transaction, approve_with_id | Processing issue | Retry; if still failing, customer contacts issuer. |
try_again_later (advice code) | Declined but retryable | Ask customer to retry; if still declined, contact issuer. |
Stripe’s guidance: a maximum of eight retries for charges that permit retries. Card networks limit reattempt counts, and excessive retries pattern-match as fraud. Stripe Billing’s Smart Retries handles subscription schedules.
3. Request a new payment method
| Code | Meaning | Action |
|---|---|---|
insufficient_funds | Not enough funds/credit | Customer uses an alternative payment method (Stripe suggests BNPL to reduce these declines). |
expired_card | Card expired | Another card required. |
incorrect_cvc / invalid_cvc | Wrong CVC | Retry with correct CVC. |
incorrect_number / invalid_number | Wrong number | Retry with correct number. |
incorrect_address, incorrect_zip | Wrong address/postal code | Retry with correct details. |
confirm_card_data (advice code) | Some info incorrect | Customer validates card info. |
duplicate_transaction | Identical amount+card recently submitted | Check whether a recent payment already succeeded before retrying. |
card_velocity_exceeded, withdrawal_count_limit_exceeded | Limits exceeded | Alternative method or contact issuer. |
On-session: prompt for a new card directly. Off-session: notify the customer (email/in-app) to return — under SCA, that return trip may also fail with authentication_required, so design for the two-step.
4. Say nothing specific
| Code | Meaning | Action |
|---|---|---|
lost_card, stolen_card | Reported lost/stolen | Do NOT reveal specifics — present as generic_decline. |
fraudulent, merchant_blacklist | Fraud suspicion / block list | Present as generic_decline. |
This is a security requirement, not UX pedantry. Telling a caller “that card is reported stolen” confirms information they shouldn’t have. Map these to your generic message in the agent’s response layer.
5. Contact the issuer
| Code | Meaning | Action |
|---|---|---|
do_not_honor | Unknown reason | Customer contacts issuer. |
generic_decline | Unknown reason, or Stripe Radar / Adaptive Acceptance blocked it | Customer contacts issuer. |
do_not_try_again (advice code) | Don’t reuse this card for the same transaction | Stop retrying. |
currency_not_supported, card_not_supported, not_permitted, invalid_amount, pin_try_exceeded, pickup_card, restricted_card | Restrictions | Contact issuer / different card. |
testmode_decline | Test card number | Use a genuine card. |
The Agent Branch
async def recover_decline(intent, session):
code = (intent.last_payment_error or {}).get("decline_code")
if code in {"authentication_required", "authentication_not_handled"}:
return await start_3ds_challenge(intent) # intervention, then retry
if code in {"issuer_not_available", "processing_error", "reenter_transaction"}:
if session.retry_count < 8: # Stripe: max 8 retries
session.retry_count += 1
return await retry_charge(intent)
return await notify_customer(intent, "contact_issuer")
if code in {"lost_card", "stolen_card", "fraudulent", "merchant_blacklist"}:
return await notify_customer(intent, "generic_decline") # never reveal specifics
if code in {"insufficient_funds", "expired_card", "incorrect_cvc",
"incorrect_number", "incorrect_zip"}:
return await request_new_payment_method(intent, code)
return await notify_customer(intent, "contact_issuer") # do_not_honor, generic, restrictions
Every branch returns a next state — the workflow never dead-ends on a failure.
Instrumentation: Where the Learning Happens
“Recovery flows that learn” requires data, and Stripe gives you the hooks:
- Webhook:
payment_intent.payment_failedfires with the decline details — the canonical trigger for an async recovery workflow. - Inspect in code: read
last_payment_error.decline_codeand iterate attempted charges to checkfailure_message. - Fingerprint, don’t charge-ID: use
Card.fingerprintto detect repeat attempts on the same card across charges. Retrying the same failing card is the pattern that gets flagged. - Analyze rates: Stripe Sigma for decline-rate analysis by code, issuer, geography.
- Collect up front: CVC + postal code at checkout, and 3D Secure, reduce declines before they happen.
- Local accounts: geo-mismatch declines (card issued in a different country than your Stripe account) are addressable with local Stripe accounts.
Local payment methods have their own code space worth knowing: partner_generic_decline, customer_declined, code_expired (BLIK codes expire after 2 minutes — retry timing matters), invalid_billing_agreement (“retries won’t succeed”), recurring_not_supported_by_bank, and lost_or_stolen_card (present as partner_generic_decline).
Design Principles
- Branch on
decline_code, never on generic failure. One string decides between authentication, retry, new-card, and silence. - Bound retries at eight. Networks cap reattempts; unbounded retry loops are both a fraud signal and a support ticket generator.
- Treat SCA as a first-class state.
authentication_requiredisn’t a failure — it’s a pause awaiting an intervention. - Suppress specifics for fraud-adjacent codes. Lost, stolen, and fraudulent →
generic_decline, always. - Close the loop with telemetry. Track recovery rate by branch; the branches that never succeed should be simplified, not expanded.
The decline code is the issuer talking to you. Your workflow’s job is to translate it into exactly one correct next action.