When a card payment fails in Stripe, you get a short string back — insufficient_funds, do_not_honor, authentication_required. Most teams glance at it and let Smart Retries hammer the card on a schedule.
That's the mistake. The right move depends entirely on the code:
- Some failures clear themselves if you retry in a few days.
- Some will never clear — and retrying them can get you fined by the card networks.
- Many need the customer to act, so retrying the same card is pointless.
A big slice of "failed" subscription payments are recoverable — but only if you act on the reason. Here's every code that matters and what to do about it.
First: code vs decline_code
Stripe returns two fields, and they're not the same:
-
code— the high-level category, usuallycard_declined. Tells you almost nothing. -
decline_code— the granular reason from the issuing bank (insufficient_funds,lost_card,do_not_honor). This is the one you act on.
Caveat: issuers don't always report the true reason — anti-fraud blocks often hide behind a vague do_not_honor. The granular code is a strong hint, not gospel.
The mental model: five buckets
Every decline maps to one action: retry, email the customer, stop, or fix your checkout.
1 — Auto-recoverable (retry will likely work)
Timing problems. The card is fine; the money wasn't there that moment, or the network hiccuped.
decline_code |
Meaning | Action |
|---|---|---|
insufficient_funds |
No money right now | Retry — time it near payday |
try_again_later |
Temporary issuer issue | Retry in a few hours |
processing_error |
Transient error | Retry shortly |
issuer_not_available |
Couldn't reach the bank | Retry — clears fast |
This is the only bucket where blind retries earn their keep.
2 — Needs customer action (retrying is pointless)
The card can't complete the charge as-is. Email the customer with the specific ask.
decline_code |
Meaning | Ask the customer to |
|---|---|---|
expired_card |
Card expired | Update their card |
incorrect_cvc |
Wrong CVC | Re-enter card details |
incorrect_number |
Wrong number | Re-enter card details |
authentication_required |
Bank needs 3DS / SCA | Confirm the payment (see below) |
The fix lives with the customer, not in your retry logic. A retry-only tool fails every one of these silently.
3 — Lost cause (stop retrying)
Card is dead, blocked, or flagged. Retrying won't help — and retrying network-flagged cards can trigger Visa/Mastercard penalties.
decline_code |
Meaning |
|---|---|
lost_card / stolen_card
|
Reported lost or stolen |
pickup_card |
Bank wants it seized — never retry |
fraudulent |
Flagged as fraud |
revocation_of_authorization |
Customer told their bank to stop |
Action: stop. Ask for a different card, or let it churn.
4 — Hard declines (the ambiguous middle)
The bank declined without saying why — often deliberately.
decline_code |
Meaning |
|---|---|
do_not_honor |
Catch-all decline; often anti-fraud |
generic_decline / card_declined
|
No reason given |
call_issuer |
Customer must call their bank |
card_velocity_exceeded |
Too many charges, short window |
Action: a couple of retries max, then email: "Your bank blocked this — a quick call to them, or a different card, usually fixes it." Many do_not_honor blocks lift in 30 seconds — if the customer knows to ask.
5 — Structural (fix checkout, not the payment)
Not about one customer — a recurring leak.
decline_code |
Meaning | Fix |
|---|---|---|
currency_not_supported |
Card can't be charged in that currency | Offer a supported currency |
card_not_supported |
Card type unsupported | Check your Stripe capabilities |
transaction_not_allowed |
Transaction type blocked | Often regional — review where you sell |
Fix it once, recover every future customer who'd hit the same wall.
The European trap: SCA and authentication_required
If you sell in Europe, this is what most US-built dunning tools get wrong.
- Under PSD2 / SCA, many recurring EU payments need the customer to authenticate via 3D Secure.
- When they don't, Stripe returns
authentication_required. - Most tools retry the card — which can never succeed without authentication.
- The fix: send a confirm-payment link (Stripe hosts the flow), not a retry.
For EU-facing SaaS, authentication_required is often a large, fully recoverable chunk that's silently written off.
One-page playbook
| Bucket | Do this |
|---|---|
| Auto-recoverable | Time-aware retry (near payday) |
| Needs customer action | Email the specific ask; for SCA, send a confirm link |
| Lost cause | Stop retrying; ask for a new card |
| Hard decline | A few retries, then "call your bank / try another card" |
| Structural | Fix checkout config once |
Takeaway
Blind retries only fix one of these five buckets. The rest need an email, a config fix, or a hard stop — so "just turn on Smart Retries" quietly leaves money on the table.
Try this: pull your last few months of failed payments and group them by decline_code. The split usually surprises people — especially how much is recoverable customer-action, not dead cards.
I'm building a tool that does this bucketing and routing automatically. Got war stories about declines, or opinions on what actually drives recovery? Drop them in the comments.
Top comments (2)
The authentication_required section is the most important part of this post. In my experience, that single decline code is responsible for more recoverable revenue loss than all the "card_declined" variants combined, at least for products with European customers.
One thing worth adding to the playbook: the timing of your dunning email matters almost as much as what it says. For insufficient_funds, sending the "please update your card" email at 9am on a weekday gets roughly double the response rate compared to sending it at the moment the webhook fires (which is often 3am in the customer's timezone, when the renewal ran). Stripe's Smart Retries handle some of this, but your email layer probably doesn't.
The other pattern I've hit repeatedly: do_not_honor on the first charge after a trial ends. The customer's bank has never seen your merchant descriptor before, so it flags the charge as suspicious. The customer has no idea this happened because they never check their bank alerts. Two practical fixes: (1) run a $0 or $1 auth-and-void during the trial so the bank sees your descriptor before the real charge, (2) send the customer a "your trial ends tomorrow, here's what you'll be charged" email so they're not surprised and can whitelist the charge if their bank asks.
The five-bucket model is a clean way to think about it. Most teams I've seen just dump everything into "retry 3 times and then send a generic email," which is basically optimizing for bucket 1 and ignoring the other four.
Thanks for your reply @mihirkanzariya — the auth-and-void trick to warm up the merchant descriptor before the first real charge sounds pretty new and interesting… Makes a lot of sense. The customer has zero visibility into why their bank flagged it — from their side it's "card declined for no reason," from the merchant's side it's just unexplained churn, until someone actually looks at the decline_code distribution.
Completely agree regarding authentication_required too — I called it out because it's the one bucket where "just retry harder" is actively useless, and most dunning advice is written from a US-centric view that doesn't deal with SCA at all.
The send-time point is a good one too. I don't have data on the 9am-vs-3am gap myself, but it tracks with general email behaviour, and it's an easy lever since it's just delaying a job — no payment logic to touch.
What ties all of this together for me: none of these fixes are visible if you're just retrying blindly and logging "failed" / "retrying" / "gave up." They only show up once someone's actually monitoring why things fail, not just whether they did.
Appreciate the detail — this is exactly the kind of stuff that doesn't show up in the Stripe docs.