Stripe Decline Codes: The Developer's Guide to Handling Every Payment Failure

Your invoice.payment_failed webhook just fired. Now what? This is the developer's playbook for reading Stripe decline codes, building smart retry logic, and recovering revenue that would otherwise disappear silently.

Gerard Hill — Founder of Dunning Lite

Written by

Gerard Hill

Founder of Dunning Lite. 12+ years in digital marketing and SEO, former CTO, Google Partner, and two Masters in Digital Marketing. Built Dunning Lite after watching his own micro-SaaS lose hundreds in MRR to expired cards — and realizing every recovery tool on the market was built for companies 10x his size.

Executive Summary (TL;DR)

  • Stripe translates raw bank decline codes into human-readable strings like insufficient_funds, expired_card, and generic_decline. You get them in the webhook payload.
  • The decline code tells you exactly what to do: retry (soft decline), email the customer (hard decline), or send a payment link (authentication required).
  • Stripe Smart Retries are free and recover 15-22% of failures automatically — but they only retry. They never contact the customer or handle hard declines.
  • Building decline-code-aware logic on top of Smart Retries can push recovery rates from 22% to 70-85%. This guide shows you how.

How Stripe Handles Declines (The Pipeline)

When a subscription payment fails on Stripe, here's what happens behind the scenes:

  1. Stripe creates a PaymentIntent for the invoice
  2. The charge is sent to the card network (Visa, Mastercard, etc.)
  3. The issuing bank responds with a raw ISO 8583 decline code
  4. Stripe translates that into its own decline code (e.g., insufficient_funds)
  5. Stripe fires the invoice.payment_failed webhook to your app

The translation step is important: Stripe's codes are more specific and more actionable than raw bank codes. Where a bank might return a cryptic "05", Stripe gives you do_not_honor — still vague, but at least you know it's the bank refusing without a reason, not an expired card or fraud flag.

For a full reference of what each code means (including the generic bank codes), see our complete credit card decline codes guide. This article is about what to do with them in your Stripe integration.


Reading the Decline Code from the Webhook Payload

When invoice.payment_failed fires, the decline code lives at:

// invoice.payment_failed webhook payload
{
  "type": "invoice.payment_failed",
  "data": {
    "object": {
      "id": "in_1234...",
      "customer": "cus_5678...",
      "subscription": "sub_9012...",
      "attempt_count": 1,
      "next_payment_attempt": 1714300800,

      // The decline code is HERE:
      "last_payment_error": {
        "type": "card_error",
        "code": "card_declined",
        "decline_code": "insufficient_funds",
        "message": "Your card has insufficient funds."
      }
    }
  }
}

The three fields that matter:

  • decline_code — The specific reason: insufficient_funds, expired_card, generic_decline, etc. This is what you build your logic around.
  • code — The error category. Almost always card_declined for payment failures. Useful for distinguishing decline errors from API errors.
  • attempt_count — How many times Stripe has tried this invoice. Important for deciding whether to take action yourself or let Smart Retries handle it.

Quick note: if last_payment_error is null, the invoice hasn't been attempted yet. If decline_code is null but code exists, it's an API-level error, not a bank decline. For a deeper walkthrough of webhook setup, see our Stripe webhook implementation guide.


The Decision Tree: Retry, Email, or Stop

Every Stripe decline code maps to one of three responses. Get this right and you've built 80% of a dunning system:

ResponseWhenDecline Codes
RetryTemporary issue — likely resolves on its owninsufficient_funds, generic_decline, do_not_honor, processing_error, card_declined, try_again_later
Email customerCard is dead — needs new payment methodexpired_card, incorrect_cvc, incorrect_number, lost_card, stolen_card, card_not_supported, fraudulent
Payment linkCustomer must authenticate (SCA/3DS)authentication_required

Handling Soft Declines: Smart Retry Logic

Soft declines are temporary. The card is valid, the account exists — something just didn't work right now. The trick is retrying at the right time:

Decline CodeRetry AfterMax RetriesWhy This Timing
insufficient_funds3-5 days (near 1st or 15th)3Aligns with common payroll dates
generic_decline24h, then 72h2Often temporary bank-side block
do_not_honor24h, then 72h2Bank may lift the block after review
processing_error2-4 hours2Usually resolves within hours
try_again_later4-6 hours2Stripe is literally telling you to wait

If all retries fail, escalate to an email. Don't keep hammering — Visa allows up to 15 retry attempts, Mastercard allows 35 within 30 days, but exceeding sensible limits triggers penalties and wastes goodwill. For email templates tailored to each decline type, see our dunning email examples.

Handling Hard Declines: Customer Email

Hard declines mean the payment method is permanently broken. No amount of retrying will fix an expired card or a stolen card flag. The only path forward:

  1. Don't retry. Seriously. Mastercard's MAC penalties start at $0.50 per excessive retry on hard declines, and they're expanding in 2026.
  2. Email the customer within hours. Be specific: "Your Visa ending in 4242 has expired" is infinitely more useful than "payment failed."
  3. Include a direct link to your Stripe-hosted billing portal or a custom payment update page.
  4. Follow up on day 3 and day 7 if they haven't updated. See our email sequence timeline for the full cadence.

Handling Authentication Declines (SCA / 3D Secure)

authentication_required is a special case — and it's becoming more common every month, especially for European customers under PSD2. The customer's bank wants them to verify their identity before approving the charge.

You can't automate this. The customer must:

  1. Click a link you send them
  2. Complete the 3D Secure challenge on their bank's page
  3. Return to your app with the payment confirmed

The easiest implementation: Enable Stripe's built-in "Send a Stripe-hosted link for cardholders to authenticate" in your Billing settings. Stripe will automatically email the customer a payment link when SCA is required. If you want to control the email yourself (recommended for brand consistency), use the hosted_invoice_url from the invoice object — it's already there in the webhook payload.


Stripe Smart Retries: What They Do (and Don't Do)

If you're using Stripe Billing for subscriptions, Smart Retries are already working for you (unless you turned them off). Here's what they actually do:

What Smart Retries DO

  • +Use ML to pick optimal retry timing across Stripe's network
  • +Recover ~15-22% of failed subscription payments
  • +Work automatically — zero setup required
  • +Free — included with Stripe Billing

What Smart Retries DON'T do

  • Send any email to the customer
  • Handle hard declines (expired/stolen cards)
  • Handle SCA/3DS authentication
  • Differentiate by decline code — same strategy for all failures

Think of Smart Retries as the floor, not the ceiling. They're great at what they do — silently recovering soft declines in the background. But 78-85% of failed payments still slip through. That's where decline-code-aware logic and customer communication come in. For a full comparison of tools that handle both, check our dunning software comparison.


Building Your Own Retry Logic (Pseudocode)

Here's the pattern we recommend for handling invoice.payment_failed. This isn't production code — it's the decision framework you should implement in whatever language you're using:

// Webhook handler: invoice.payment_failed

function handlePaymentFailed(event) {
  const invoice = event.data.object
  const declineCode = invoice.last_payment_error?.decline_code
  const attemptCount = invoice.attempt_count

  // HARD DECLINES → email immediately, never retry
  const hardDeclines = [
    'expired_card', 'lost_card', 'stolen_card',
    'fraudulent', 'incorrect_number', 'incorrect_cvc',
    'card_not_supported', 'invalid_account'
  ]

  if (hardDeclines.includes(declineCode)) {
    sendDunningEmail({
      customer: invoice.customer,
      type: 'hard_decline',
      declineCode: declineCode,
      updateUrl: invoice.hosted_invoice_url
    })
    return // Do NOT schedule retry
  }

  // AUTHENTICATION → send payment link
  if (declineCode === 'authentication_required') {
    sendPaymentLink({
      customer: invoice.customer,
      invoiceUrl: invoice.hosted_invoice_url
    })
    return
  }

  // SOFT DECLINES → let Smart Retries handle first attempts
  // Only send email after 2-3 failed retries
  if (attemptCount >= 3) {
    sendDunningEmail({
      customer: invoice.customer,
      type: 'soft_decline',
      declineCode: declineCode,
      updateUrl: invoice.hosted_invoice_url
    })
  }
  // else: Smart Retries will handle the next attempt
}

The key insight: let Smart Retries handle soft declines first. Only escalate to customer email after 2-3 failed attempts. For hard declines and authentication, act immediately — there's nothing to wait for.

Need ready-to-use email templates for each path? Check our dunning email templates or use the email generator to create personalized ones.


Stripe-Specific Codes You Won't Find in Bank Responses

Stripe adds its own codes on top of what banks return. These are useful because they surface issues that the bank wouldn't flag:

Stripe CodeWhat It MeansWhat to Do
testmode_declineA test card was used in live modeDev error — check your environment config
merchant_blacklistStripe Radar blocked this paymentReview in Stripe Dashboard → Radar rules
new_account_information_availableCard was reissued — new details existCard updater should handle this automatically
card_velocity_exceededToo many charges on this card recentlyRetry tomorrow. If persistent, check for duplicate charges.
currency_not_supportedCard can't process this currencyCheck your Stripe currency settings. Customer may need different card.
revocation_of_all_authorizationsCustomer told their bank to block ALL recurring chargesTreat as voluntary cancellation. Do NOT retry.

merchant_blacklist is worth special attention — it means your own Stripe Radar rules blocked the payment, not the bank. Check your Radar configuration before contacting the customer.


Monitoring Your Decline Health

Once you've built your decline-handling logic, track these numbers weekly to know if it's actually working:

Decline Rate

Failed charges / Total charge attempts. Healthy SaaS: under 10%. Above 15% means something systemic is wrong.

Recovery Rate

Payments recovered / Total failures. With Smart Retries alone: 15-22%. With full dunning: 70-85%.

Hard Decline %

Hard declines / Total declines. Rising hard decline % means more expired cards — enable card updaters.

Time to Recovery

Average days from first failure to successful charge. Under 5 days is good. Over 10 means your emails aren't working.

To model how these metrics translate to revenue, use our Lost MRR Calculator. For the full picture of dunning strategy beyond decline codes, read our complete dunning management guide.


Frequently Asked Questions

Where do I find the decline code in a Stripe webhook?+
In the invoice.payment_failed webhook event, the decline code is at data.object.last_payment_error.decline_code. For PaymentIntent failures, it's at payment_intent.last_payment_error.decline_code. The charge object also contains it at charges.data[0].failure_code. Always check last_payment_error first — it has the most detailed information.
What does Stripe's generic_decline code mean?+
generic_decline means the issuing bank refused the transaction but didn't tell Stripe why. It's the most common and most frustrating decline code. It could be fraud suspicion, spending limits, or bank-side risk rules. The best approach is to retry once after 24 hours, then contact the customer if it fails again — they may need to call their bank to authorize the charge.
Should I retry a Stripe payment that was declined?+
It depends entirely on the decline code. Soft declines (insufficient_funds, processing_error, generic_decline) should be retried with smart timing. Hard declines (expired_card, stolen_card, fraudulent) should never be retried — the customer needs to provide a new payment method. Retrying hard declines wastes API calls and can trigger Mastercard's excessive authorization penalties ($0.50 per violation).
How do Stripe Smart Retries work?+
Stripe Smart Retries use machine learning to determine the optimal time to retry a failed subscription payment. They analyze patterns across Stripe's network — time of day, day of week, decline history — to pick the moment most likely to succeed. They're free, automatic for Stripe Billing subscriptions, and recover roughly 15-22% of failed payments. The limitation: they only retry the charge. They never email the customer or handle hard declines.
How do I handle authentication_required declines in Stripe?+
authentication_required means the customer's bank requires 3D Secure / SCA verification. You cannot retry this automatically — the customer must complete the authentication step. Create a new PaymentIntent or use Stripe's hosted invoice page, then send the customer the payment link. Stripe Billing can send these automatically if you enable 'Send a Stripe-hosted link for cardholders to authenticate' in your billing settings.
What is the difference between Stripe decline codes and error codes?+
Decline codes come from the issuing bank (via Stripe) and explain why a card payment was refused — things like insufficient_funds or expired_card. Error codes come from Stripe itself and indicate API-level issues — things like invalid_request_error or rate_limit. Decline codes require customer-facing responses (retry or email). Error codes require developer-facing fixes (code changes or configuration).
How many times should I retry a failed Stripe payment?+
For soft declines, 2-3 retries over 7-14 days is the sweet spot. Visa allows up to 15 retry attempts; Mastercard allows 35 within a 30-day window. But more retries doesn't mean better recovery — after 3-4 attempts, you're better off emailing the customer. For hard declines, the answer is zero. Never retry a hard decline.

Skip the webhook wiring. Let us handle it.

Dunning Lite reads every decline code from your Stripe webhooks and responds automatically — smart retries for soft declines, targeted emails for hard declines, payment links for SCA. Two clicks to connect, zero code to maintain. $29/month flat.

Join The Dunning Letter

Tactical SaaS strategies to reduce churn and recover revenue. 1 email per month.