Skip to content
Blog

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 brand
  • network_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)

CodeMeaningAction
authentication_requiredNeeds 3DS/SCAOn-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_handledCustomer skipped required authRun the EMV 3DS/SCA flow; off-session, authenticate on-session first, then fall back.
mobile_device_authentication_requiredContactless auth neededAsk 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

CodeMeaningAction
issuer_not_availableIssuer unreachableAttempt the payment again.
processing_error, reenter_transaction, approve_with_idProcessing issueRetry; if still failing, customer contacts issuer.
try_again_later (advice code)Declined but retryableAsk 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

CodeMeaningAction
insufficient_fundsNot enough funds/creditCustomer uses an alternative payment method (Stripe suggests BNPL to reduce these declines).
expired_cardCard expiredAnother card required.
incorrect_cvc / invalid_cvcWrong CVCRetry with correct CVC.
incorrect_number / invalid_numberWrong numberRetry with correct number.
incorrect_address, incorrect_zipWrong address/postal codeRetry with correct details.
confirm_card_data (advice code)Some info incorrectCustomer validates card info.
duplicate_transactionIdentical amount+card recently submittedCheck whether a recent payment already succeeded before retrying.
card_velocity_exceeded, withdrawal_count_limit_exceededLimits exceededAlternative 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

CodeMeaningAction
lost_card, stolen_cardReported lost/stolenDo NOT reveal specifics — present as generic_decline.
fraudulent, merchant_blacklistFraud suspicion / block listPresent 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

CodeMeaningAction
do_not_honorUnknown reasonCustomer contacts issuer.
generic_declineUnknown reason, or Stripe Radar / Adaptive Acceptance blocked itCustomer contacts issuer.
do_not_try_again (advice code)Don’t reuse this card for the same transactionStop retrying.
currency_not_supported, card_not_supported, not_permitted, invalid_amount, pin_try_exceeded, pickup_card, restricted_cardRestrictionsContact issuer / different card.
testmode_declineTest card numberUse 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_failed fires with the decline details — the canonical trigger for an async recovery workflow.
  • Inspect in code: read last_payment_error.decline_code and iterate attempted charges to check failure_message.
  • Fingerprint, don’t charge-ID: use Card.fingerprint to 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

  1. Branch on decline_code, never on generic failure. One string decides between authentication, retry, new-card, and silence.
  2. Bound retries at eight. Networks cap reattempts; unbounded retry loops are both a fraud signal and a support ticket generator.
  3. Treat SCA as a first-class state. authentication_required isn’t a failure — it’s a pause awaiting an intervention.
  4. Suppress specifics for fraud-adjacent codes. Lost, stolen, and fraudulent → generic_decline, always.
  5. 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.

Further Reading