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.

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:
- Stripe creates a
PaymentIntentfor the invoice - The charge is sent to the card network (Visa, Mastercard, etc.)
- The issuing bank responds with a raw ISO 8583 decline code
- Stripe translates that into its own decline code (e.g.,
insufficient_funds) - Stripe fires the
invoice.payment_failedwebhook 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 alwayscard_declinedfor 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:
| Response | When | Decline Codes |
|---|---|---|
| Retry | Temporary issue — likely resolves on its own | insufficient_funds, generic_decline, do_not_honor, processing_error, card_declined, try_again_later |
| Email customer | Card is dead — needs new payment method | expired_card, incorrect_cvc, incorrect_number, lost_card, stolen_card, card_not_supported, fraudulent |
| Payment link | Customer 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 Code | Retry After | Max Retries | Why This Timing |
|---|---|---|---|
| insufficient_funds | 3-5 days (near 1st or 15th) | 3 | Aligns with common payroll dates |
| generic_decline | 24h, then 72h | 2 | Often temporary bank-side block |
| do_not_honor | 24h, then 72h | 2 | Bank may lift the block after review |
| processing_error | 2-4 hours | 2 | Usually resolves within hours |
| try_again_later | 4-6 hours | 2 | Stripe 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:
- Don't retry. Seriously. Mastercard's MAC penalties start at $0.50 per excessive retry on hard declines, and they're expanding in 2026.
- Email the customer within hours. Be specific: "Your Visa ending in 4242 has expired" is infinitely more useful than "payment failed."
- Include a direct link to your Stripe-hosted billing portal or a custom payment update page.
- 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:
- Click a link you send them
- Complete the 3D Secure challenge on their bank's page
- 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 Code | What It Means | What to Do |
|---|---|---|
| testmode_decline | A test card was used in live mode | Dev error — check your environment config |
| merchant_blacklist | Stripe Radar blocked this payment | Review in Stripe Dashboard → Radar rules |
| new_account_information_available | Card was reissued — new details exist | Card updater should handle this automatically |
| card_velocity_exceeded | Too many charges on this card recently | Retry tomorrow. If persistent, check for duplicate charges. |
| currency_not_supported | Card can't process this currency | Check your Stripe currency settings. Customer may need different card. |
| revocation_of_all_authorizations | Customer told their bank to block ALL recurring charges | Treat 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?+
What does Stripe's generic_decline code mean?+
Should I retry a Stripe payment that was declined?+
How do Stripe Smart Retries work?+
How do I handle authentication_required declines in Stripe?+
What is the difference between Stripe decline codes and error codes?+
How many times should I retry a failed Stripe payment?+
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.