Stripe Decline Codes: What Each One Means and What I Plan to Do About It
I spent two weeks reading Stripe's decline-code documentation and the network reason codes underneath it. Here's the full reference, plus the recovery strategy I'm encoding into RecoverStack, and the parts I'm still unsure about.
I'm Yarin. I'm the solo founder of RecoverStack, a flat-fee dunning tool for bootstrapped SaaS on Stripe. I'm pre-launch. I currently have zero customer recovery data of my own, and I'm writing this guide while working on the product, not after running it for years.
What I do have is a couple of weeks of reading. Stripe's decline-code reference, the network-reason mapping, the Smart Retries documentation, the card-updater explainer, the Radar risk insights, and a stack of public engineering posts from Stripe and Recurly. This post is my pass at making it all readable in one sitting, and at being honest about which strategies are well-supported and which are the ones I'm still working out.
If you spot something I got wrong, ping me at [email protected] or on LinkedIn. I would rather be corrected on a public post than ship the wrong logic into the product.
Why decline codes are the whole game
A failed Stripe charge is not a single event. It comes back with metadata that tells you why the charge failed and where the failure originated from. Stripe collapses that metadata into two short strings on the PaymentIntent's last_payment_error:
code- the higher-level Stripe error category, likecard_declinedorexpired_card.decline_code- the specific bank-side reason underneath, likedo_not_honororinsufficient_funds. Not always present, falls back tocodewhen missing.
The older Charge object also exposes the failure_code and failure_message properties as legacy fields with similar semantics, but for new integrations the modern field names are code and decline_code on the last_payment_error object.
Underneath those, depending on the card network, you also get the network-level reason for the failure. Stripe documents the mapping in the doc page linked above. The decline code is the element most dunning logic operates on, because it's consistent across networks and has stable semantics.
Why does the code matter? Because the optimal recovery action is fully determined by the code: Retrying an expired_card will never succeed - the customer simply needs to provide a different card for the transaction to pass. Retrying a processing_error almost always succeeds because usually it's caused by a temporary technical problem. Sending a customer email over a transient try_again_later will alarm them for nothing. Skipping an email for a lost_card failure will probably lose you the customer. There is no general-purpose "handle a failed payment" flow that works well across the codes, there is only the per-code playbook.
Stripe's built-in Smart Retries tries to get partway there with an ML model that picks retry timing. It's a real improvement over a naive 3-day fixed retry algo, though Stripe doesn't publish a head-to-head benchmark vs a fixed schedule. Their published number is that Stripe Billing recovers around 55% of failed payments on average across all their recovery tools combined. But Smart Retries doesn't branch on the decline code in the way that matters most: it still treats every failure as a retry problem, and retrying is not the right move for somewhere between 30% and 40% of decline codes by volume.
That's the gap I'm building RecoverStack to close. Below is the full code-by-code reference I've been pulling out of Stripe's docs and turning into the engine's strategy table.
How I'm organizing this guide
There are around 60 distinct values of failure_code that show up in Stripe's public docs and the wider community-collected lists (Stripe's reference table, and the more permissive network-reason index). Walking through 60 codes one at a time would be useless prose because most of them collapse into four behavioral categories that determine the recovery action:
- Transient: The charge will probably work if retried at the right moment, so no customer action required for the most part.
- Card update required: The card on file is permanently invalid, and no retry will ever succeed. The customer has to provide us with a new card.
- Bank block: The bank declined the transaction for its own reasons. May or may not resolve on its own. One careful retry, then customer email.
- Fraud or security flag: Do not retry again! Reach out manually, or risk additional flags on the customer account.
I'll go through the codes by category, with the recovery action I'm encoding into RecoverStack.
A note on numbers: When I cite a recovery rate, I link the source. The numbers below come from public Stripe benchmarks, ProfitWell research, Recurly's engineering posts, and a few academic papers on payment retry. None of them come from RecoverStack's own customer base, because there isn't one yet. When the cohort is up and running, I'll publish actual recovery rates.
Category 1: Transient failures (retrying will probably work)
These are the codes where the card & the customer are fundamentally fine, and the failure was a moment-in-time kind of problem. The right action is an automated retry with appropriate timing, no customer email on the first attempt, and (sometimes) a pre-emptive heads up if you have one to give it.
processing_error
What it means: Something went wrong on Stripe's side or in the network during the charge, not the bank/card.
What RecoverStack is doing: Silent retries at 1 hour, 6 hours, and 24 hours. Stripe's docs explicitly recommend a quick retry in this case, because the underlying issue is almost always transient infrastructure. I don't email the customer on any of these attempts. If all three retries fail, then I escalate to the "bank block" flow, since the original transient hypothesis is starting to fall apart at this point.
What I'm unsure about: What is the right delay between the silent retries? Stripe's Smart Retries appears to use sub-hour windows for some of the processing_error cases. My instinct tells me that starting at 1 hour is reasonable enough for a third-party operator who can't observe the network state directly, but if the cohort data shows that a faster first retry recovers better, I'll tighten the timing.
try_again_later
What it means: This is a case where the bank is explicitly telling you that something is wrong on their side and that the charge should be retried later. This is one of those rare codes where the bank has actually labeled the failure as transient.
What I'm doing: Retry once after 4 hours, and no need for a customer email. Skip Stripe's default 3-day retry window for this code, because waiting that long against an explicit "try later" message feels like leaving free recovery on the table.
Why I trust this: Industry estimates put recovery for this code in the 40% to 60% range if the timing is reasonable, and Stripe's own product copy treats it as a clear case for retrying. The risk of a false positive (sending a customer an email on what was actually a transient bank issue) outweighs the benefit here.
reenter_transaction
What it means: The bank wants the same transaction submitted again, exactly as it was. Sometimes this is a session-level issue, and sometimes it's due to a temporary lock on the card.
What I'm doing: Retry within 30 minutes. This is one of the few codes where the bank is asking for a near-immediate resubmission, so waiting for too long actually reduces the chance that the bank's session-level state is still in the right shape.
insufficient_funds
What it means: The customer's account doesn't have enough balance at the moment of the charge. Common with debit cards, prepaid cards, and customers who run their checking close to zero on purpose.
What I'm doing in RecoverStack: This is the code I've put the most thought into. Stripe's Smart Retries is decent at this one, but I think you can do meaningfully better by routing retries to common payday cycles. The strategy I'm encoding:
- First retry: the next 1st/15th of the month, or the next Monday, whichever comes first.
- Second retry: the other of those two anchors, plus a small offset (typically 1 day later, so banks have settled overnight payroll).
- Third retry: 7 days after the second, in case the customer was paid mid-cycle.
I'm skipping the customer email on the first retry. On the second, I send a short note ("your last payment didn't go through, the next attempt is on the [date]") so the customer has the option to top up rather than be surprised. On the third, the email is more direct and includes a card-update link.
Why I think this beats default Smart Retries: Stripe's ML model is great in aggregate but it's optimizing across the whole population, not for the specific subset of insufficient_funds declines. The 1st-and-15th anchoring matches common US payday cycles. Most salaried and many hourly workers in the US get paid bi-monthly. It's pattern-recognition from payment processor practice, not academically rigorous. International cohorts likely don't fit, and that's a known limitation I'll address once I have international cohort data.
What I'm unsure about: Whether to skip the second retry entirely if the first one succeeded against a partial balance and only captured part of the charge. Stripe handles partial-auth on some integrations, but it's not their default.
withdrawal_count_exceeded
What it means: The customer's card has hit a daily or weekly transaction limit. Most often seen with debit cards in Europe and Asia.
What I'm doing: Retry after 24 hours, then again after 48 hours. The limit usually rolls over daily for credit cards and weekly for debit. I send no email on the first retry because the customer hasn't actually done anything wrong, they just used their card a lot. If the 48-hour retry also fails, a card-update email goes out asking the customer to switch to a different payment method.
Category 2: Card update required (retry will never work)
These codes mean one thing: the card on file is permanently dead in the current configuration, and that retrying is pointless. The recommended action here is to email the customer with a direct payment-update link before they emotionally disengage.
The single biggest mistake I see in publicly-available dunning logic is treating these codes the same as bank blocks. When you retry an expired_card four times over six days, you waste six days during which the customer could have been emailed and you could have updated their card. Recovery rates drop sharply with elapsed time after the failure (Baremetrics' analysis of a million dunning emails shows recovery rates per email peak at day 0 around 13%, stay near 11% through day 7, then drop sharply to about 4% by day 15), so speed & timing are real levers.
expired_card
What it means: The card's expiration date has passed, and there is nothing to retry. The customer will either update the card or churn.
What I'm doing in RecoverStack: Send the dunning email immediately, with a one-click update link. If you have Stripe's automatic card updater enabled, this code also tells you the updater currently doesn't have the new card, which usually means the issuing bank doesn't participate in the network update service - worth knowing as a signal.
Pre-failure email is the real win here: The cleanest version of this strategy isn't responding to expired_card, it's noticing 30 days ahead that a card's expiration is approaching and emailing the customer before it fails. RecoverStack supports this as a premium feature, because it's the single biggest action you can take for predictable failures.
incorrect_cvc
What it means: The CVC code submitted with the charge doesn't match what the issuer has on file. Almost always means the customer mistyped their CVC at signup, or the card was reissued with a new CVC and the old one is still recorded on the saved card.
What I'm doing: Skip retries, and email the customer asking them to re-enter their card. Retrying with the same CVC will obviously fail again, and Stripe doesn't expose CVC after the initial collection anyway.
BTW: This code is also a flag for what looks like card-testing activity if you see a sudden spike. Stripe's Radar should catch most of that, but if your own monitoring shows a burst of incorrect_cvc failures on new accounts, look at it before assuming it's normal customer behavior.
incorrect_number
What it means: The card number doesn't pass the issuer's lookup. Either mistyped, or (rarely) reused after the card was reissued with a new number that didn't come through Stripe's updater.
What I'm doing: Skip retries. Email immediately.
lost_card
What it means: The cardholder reported that the card is lost, and the bank has invalidated it. A new card has likely already been issued, but it does have a different number and CVC.
What I'm doing: Skip retries, and send an email with a card-update link. The single card-update email is the full recovery path for this code, because retrying a lost card always fails and additional emails on the same dead card waste customer attention.
stolen_card
What it means: Same as lost_card mechanically, but with a fraud connotation that I need to handle carefully. The card was reported to be stolen, which sometimes correlates with a chargeback risk on prior charges from the same card.
What I'm doing: Skip retries. Send a careful, neutral email (not "your card was stolen!", just "your payment method needs an update"), because the customer might be in the middle of dealing with the actual fraud and adding more stress into the mix is the last thing I want.
invalid_account
What it means: The account behind the card has been closed or otherwise made invalid. The card itself might appear active in Stripe's system, but the underlying relationship with the bank is gone.
What I'm doing: Skip retries. Email asking for a new payment method.
card_not_supported
What it means: The card type doesn't support whatever we're trying to do with it. Most common case is that it's a prepaid debit card/single-use virtual card used for a recurring subscription.
What I'm doing: Skip retries. Email asking for a different card, with a one-line explanation that some prepaid and virtual cards can't be used for recurring billing. The explanation matters because the customer often has no idea their card has that limitation.
currency_not_supported
What it means: The card can't be charged in your billing currency. Usually means a card is from a region whose issuer doesn't support cross-border charges to your country.
What I'm doing: Skip retries. Email asking for a card that supports the billing currency, or (if you have multi-currency billing) prompt the customer to switch their billing currency.
new_account_information_available
What it means: The bank is telling Stripe that the card has been reissued with a new number or expiry. If you have the automatic card updater enabled, Stripe will pick this up automatically on the next attempt. If you don't, you're leaving recovery on the table.
What I'm doing in RecoverStack: Stripe does this part for you if the updater is enabled. RecoverStack will check the customer's Stripe account configuration on connection and explicitly recommend enabling the updater if it's off. If the updater is on and still failing, I treat it like expired_card (immediate email).
pickup_card
What it means: The bank wants the card physically collected. This is unusual in online charges, and when it shows up it usually means a previously stolen or compromised card has been flagged.
What I'm doing: Skip retries and email for a new payment method. Treat similarly to stolen_card tone-wise.
restricted_card
What it means: The card has been restricted by the issuing bank, often because of suspected fraud or unusual activity.
What I'm doing: Skip retries and email asking for a new card.
do_not_honor
What it means: Mechanically similar to card_declined, but with a slightly stronger "we don't want this charge to go through" signal from the bank. Often means an internal fraud check at the bank tripped, but not severely enough to outright flag the card.
What I'm doing: The bank's signal here is direct enough that the engine schedules a single 1-day retry (much shorter than the multi-step bank-block sequence) paired with the card-update email type. The card-update email fires immediately on the failure, alongside the retry rather than after it, because the underlying signal here is "this specific card-bank combo is the problem." The 1-day retry covers the rare case where the bank lifts the block overnight; the email gives the customer something to act on in case the retry also fails.
Category 3: Bank blocks (three retries on a decay schedule, then dunning)
These codes are the murky middle. The bank declined the charge, but the reason is vague enough that the failure might or might not resolve on its own. The right strategy here is to try a small fixed retry schedule (6 hours/1 day/7 days) that tries to catch the common bank-side resolution windows without burning through the dunning timeline. If the third retry fails, the 4-step email sequence takes over the campaign.
card_declined
What it means: This is a generic decline error. The bank rejected the charge but didn't share a specific reason. Could be an internal spending limit, a fraud rule, a temporary block, or just bank-specific weirdness.
What I'm doing in RecoverStack: Three retries on a 6-hour, 1-day, 7-day schedule. The 6-hour first attempt covers session-level blocks and gives the customer time to act on whichever bank prompt they received (some banks & business combinations send a push notification asking the cardholder to confirm the charge). The 1-day attempt picks up overnight bank settlement. The 7-day attempt catches cases where the customer dealt with the bank in the intervening week. If the third retry fails, route to the 4-step email sequence with an escalating urgency.
Recovery rate: Stripe doesn't publish code-specific recovery rates, but Stripe Billing's overall recovery rate runs around 55% across all decline codes combined. For card_declined specifically, I'll publish actual cohort numbers once cohort data lands.
generic_decline
What it means: Even less specific than card_declined. The bank declined and didn't even bother with the soft hint. Often this is a catch-all case when the bank's actual reason code didn't map cleanly onto Stripe's standard set.
What I'm doing: Same as card_declined. Three retries on a 6-hour, 1-day, 7-day schedule. If the third retry fails, route to the 4-step dunning email sequence. Since the bank's reason is unknown either way, the calibration cohort will tell me if a softer cadence pulls better numbers for generic_decline specifically.
A note on call_issuer and withdrawal_count_exceeded
These are two codes that often look like bank blocks but route elsewhere in the engine. call_issuer ends up in Category 2 (card-update) rather than here, because the customer has to actually talk to the bank before a retry could ever succeed, and a blind retry runs the risk of triggering additional fraud flags. The engine sends a card-update email with a "call your bank" variant and skips the retry calendar entirely. Stripe sometimes maps call_issuer to retryable in its Smart Retries logic. I think that's wrong for this code, because the failure mode is specifically "the bank needs human verification." If cohort data contradicts me on this one, I'll flip it later.
withdrawal_count_exceeded lives in Category 1 (transient) because the limit usually rolls over on its own. It can occasionally show up as a soft bank block, but the 24 to 48 hour retry window strategy still applies and the engine doesn't branch.
Category 4: Fraud and security flags (manual handling)
These codes mean that Stripe, the bank, or the card network has flagged the charge as suspicious. Retrying at this point is dangerous, because it can trigger additional flags on the customer's account. The right action is manual intervention, ideally with direct customer outreach.
fraudulent
What it means: Stripe's Radar or the bank's fraud system has flagged this charge as fraudulent.
What I'm doing in RecoverStack: Do not retry! Instead, surface a record on the payment's timeline in the dashboard, with the customer Id and the failure context, so the product operator (you, the founder) can review it (via a dedicated fraud-alert banner). RecoverStack does not auto-email the customer on this code, because if it really is fraud, you don't want to alert the perpetrator. If it's a false positive, you want to handle it manually with real human-to-human interactions.
card_velocity_exceeded
What it means: The card has been used too many times in too short of a window. This is a common card-testing pattern, and it's also what happens when a real customer gets stuck in a retry loop.
What I'm doing: Pause all retries on this customer for 24 hours, and surface the situation as a record on the payment's timeline in the dashboard (a dedicated fraud-alert banner is planned). If the customer is genuinely a real customer caught in a retry storm, my system's pause prevents you from making it worse.
merchant_blacklist
What it means: The card has been previously flagged in connection with disputes or fraud against your specific merchant account.
What I'm doing: Do not retry or email, and surface this to the operator immediately. This is one of the few cases where the appropriate action might be to cancel the subscription rather than try to recover.
A note on incorrect_pin and pin_try_exceeded
PIN-related declines are almost never relevant for online subscription billing (which is card-not-present), but they do show up occasionally with debit cards in regions where PIN is mandatory. When they do, the engine treats them as card-update codes rather than fraud flags. The rare CNP cases that produce them are almost always a wrong-card situation, not a fraud attempt, so the right action is the same card-update email any other unrecoverable card gets.
A code-by-code summary table
This is the same content as above, condensed into one place for reference. I keep it open in a tab while reviewing the engine config.
| Code | Category | Action | Notes |
|---|---|---|---|
processing_error |
Transient | Silent retry at 1h, 6h, 24h | Stripe-side glitch. Almost always self-resolves. |
try_again_later |
Transient | Single retry at 4h | Bank explicitly says it's transient. |
reenter_transaction |
Transient | Single retry within 30 minutes | Bank wants a near-immediate resubmission. |
insufficient_funds |
Transient | Payday-anchored retries (1st, 15th, Monday) | The big one - strategy detail above. |
withdrawal_count_exceeded |
Transient | Retry at 24h, 48h | Limit rolls over daily or weekly. |
expired_card |
Card update | Skip retries, email immediately | Pre-failure heads up is the real win here. |
incorrect_cvc |
Card update | Skip retries, email immediately | Might be a false-positive w/ card-testing spikes. |
incorrect_number |
Card update | Skip retries, email immediately | |
lost_card |
Card update | Skip retries, email immediately | |
stolen_card |
Card update | Skip retries, careful neutral email | Customer may be dealing with actual fraud. |
invalid_account |
Card update | Skip retries, email immediately | Underlying bank relationship is gone. |
card_not_supported |
Card update | Skip retries, explanatory email | Often a prepaid debit/virtual card limitation. |
currency_not_supported |
Card update | Skip retries, email about currency | |
new_account_information_available |
Card update | Stripe updater handles, fall back to email | Recommend updater is enabled. |
pickup_card |
Card update | Skip retries, careful email | Treat like stolen_card for tone. |
restricted_card |
Card update | Skip retries, send email | |
do_not_honor |
Card update | Card-update email immediately, plus a single 1-day retry | Bank signal strong enough to email and retry in parallel, beats the bank-block cadence. |
call_issuer |
Card update | Skip retry, email asking customer to call bank | Stripe's default treats it as retryable. The engine doesn't, because the customer has to talk to the bank first. |
incorrect_pin, pin_try_exceeded |
Card update | Skip retries, card-update email | Rare in online billing. Treated as wrong-card, not fraud. |
card_declined |
Bank block | Retries at 6h, 1d, 7d, then dunning sequence | The classic ambiguous decline. |
generic_decline |
Bank block | Retries at 6h, 1d, 7d, then dunning sequence | Catch-all. Same cadence as card_declined. |
fraudulent |
Fraud flag | Do not retry, surface to operator | No automated email. |
card_velocity_exceeded |
Fraud flag | Pause customer retries 24h, surface to operator | Can be a real customer in a retry storm. |
merchant_blacklist |
Fraud flag | Do not retry, surface to operator | Sometimes the right call is to cancel. |
How the email sequence is structured
For codes that route to dunning, the email sequence I'm starting with looks like this: Every email comes from Yarin at recoverstack.dev (no "noreply" address, because I want customers to be able to actually reply). The templates are structured HTML rendered with your merchant logo, a brand-color accent bar, and a CTA button in your brand color, all pulled from your dashboard settings. The 4-step standard dunning sequence is editable directly from the dashboard. The card-update variants ship as carefully tuned defaults.
Day 0 (failure detected): Friendly heads-up for card-update codes ("your card needs an update, here's the link"), and silence for bank-block codes that are still inside the initial retry window. The split exists because card-update codes have no retry that could succeed, so waiting for it is just lost time.
Day 3: More direct approach. "Heads up that I tried again and it's still failing. The most common fix is a card update. Here's the link." Mention the specific decline category if it's informative ("your bank declined the charge"), avoid jargon. The Day 3 timing reflects the early-window recovery weight that Baremetrics' analysis of over a million dunning emails shows: per-email recovery is around 13% on Day 0, 11% on Day 3, 11% on Day 7, then drops to about 4% by Day 15. Most of the recovery is at the first week.
Day 7: "If I can't process the next attempt, your subscription will pause on [date]. Update your card here to avoid an interruption." This is the email that does most of the late-stage work, because it names a real consequence with a real date.
Day 14: Final notice - "I wasn't able to process your payment. Your subscription has been paused. Reply to this email or update your card here, and I'll restore access." This is also the email where I personally read every reply, because the reply rate spikes at this stage and the conversations are usually worth having.
The 0/3/7/14 cadence is based on industry consensus rather than personal preference. Baremetrics' dunning analysis showed the recovery weight concentrated in the first week, with most per-email recovery happening in the first three emails, then a long tail through two weeks. The real work is going to be tuning these intervals to actual cohort data.
Five things I think most operators get wrong
This is the section I'm least sure about, because it's where I'm extrapolating from documentation rather than reporting on observed customer behavior. Take these as my current beliefs, which are subject to revision once I have the data.
1. Treating decline codes as opaque: Most subscription apps log the failure and run their generic retry logic. The decline code is right there in the webhook payload. Reading it adds maybe 50 lines of routing code, and the recovery rate improvement is large.
2. Using the same email template for everyone: A lost_card email and an insufficient_funds email need different copy. The first is "your card got cancelled, give us a new one." The second is "your card didn't have enough funds, do you want us to retry tomorrow or update your card now?" Generic copy is a missed lever.
3. Retrying card-update codes: This one's in the table above but it bears repeating. Retrying expired_card is wasting attempts and time. The recovery curve drops sharply with elapsed time, so each day spent retrying instead of emailing is real revenue lost.
4. Skipping the pre-emptive email on upcoming card expirations: Stripe surfaces card-expiration data on the customer object. You can email a customer 30 days before their card expires, with an update link. Recovery rate on that pre-emptive flow is meaningfully higher than catching the failure after the fact, because the customer hasn't emotionally disengaged yet.
5. Trusting Stripe's failure_message as customer-facing copy: It's often technical ("the customer's issuing bank could not process this transaction"). Translate it. The customer doesn't need the network message, they just need to know what they should do next.
What I don't know yet (and what the cohort will teach me)
I want to be specific about the parts of this guide that are hypotheses, not observations.
Whether 0/3/7/14 is the right dunning cadence: I picked it from Baremetrics' analysis plus industry-consensus reading. The cohort will tell me whether 0/2/5/12 is better, 0/4/10/21, or something I haven't considered yet.
Whether the call_issuer "skip retry" rule is right: Stripe's default treats it as retryable. If cohort data shows that a 24-hour retry on call_issuer recovers more than my no-retry approach, I'll flip the strategy.
Whether do_not_honor's 24-hour wait is calibrated correctly: It's informed by the bank-emphasis-of-the-decline argument, but it's a guess. The cohort will tell me.
Whether the pre-failure email on expiring cards has a meaningfully higher recovery rate than post-failure dunning: Public sources suggest so, and I'll measure it.
Whether international customers respond differently: Most of my reading has been dedicated to US payments behavior. The 1st-and-15th payday anchoring almost certainly doesn't generalize to all geographies.
I'll publish the answers as the data comes in. If you have data from your own dunning setup, I would genuinely like to hear about it. Either reply to one of the founder-cohort emails or hit me at [email protected].
What the webhook payload actually looks like
Operators who haven't worked with Stripe's webhooks before sometimes assume the decline code arrives in a separate, structured failure event. It doesn't. The decline code is a field on the charge object that arrives inside an invoice.payment_failed event (for subscription billing) or a charge.failed event (for one-off charges). Here is the shape I'm parsing in RecoverStack, trimmed down to the fields the engine actually uses.
{
"id": "evt_1ABC...",
"type": "invoice.payment_failed",
"data": {
"object": {
"id": "in_1XYZ...",
"customer": "cus_1...",
"subscription": "sub_1...",
"amount_due": 4900,
"currency": "usd",
"attempt_count": 2,
"next_payment_attempt": 1714521600,
"charge": "ch_3...",
"last_payment_error": {
"code": "card_declined",
"decline_code": "do_not_honor",
"message": "Your card was declined.",
"type": "card_error"
}
}
}
}
A few things are worth flagging.
code and decline_code are different fields: code is the higher-level Stripe error category (card_declined/expired_card/incorrect_cvc/etc...). decline_code is the more specific bank-side reason (do_not_honor/insufficient_funds/lost_card/etc...). The decline-code-aware logic in this guide is keyed on decline_code first, falling back to code when decline_code is missing. A surprising number of webhook events have a code of card_declined and no decline_code, which is the "the bank said no but didn't say why" case I'm treating as generic_decline.
attempt_count is on the invoice, not the charge: This is what tells you whether you're looking at the first failure or the third in a Stripe retry sequence. RecoverStack's engine layers its own retries on top of Stripe's, so it reads this carefully to avoid double-retrying when Stripe is already going to attempt again at next_payment_attempt.
next_payment_attempt is when Stripe will retry, not when you should retry: If RecoverStack's strategy says "retry on the 1st" but Stripe's schedule says "retry tomorrow," we got a coordination problem. The cleanest fix is to turn Stripe's own retries off for managed customers and let RecoverStack own the retry calendar end-to-end. Stripe doesn't give you an API to flip that on a connected account, so for v1 it's a switch the operator throws in their own Stripe dashboard. RecoverStack detects when Stripe's retries are still on, warns you at connection time, and reads next_payment_attempt so it never double-charges while both schedules are running.
charge is the underlying charge ID, useful for fetching the full failure detail: The last_payment_error summary on the invoice is convenient, but for fraud-flag handling I want the full charge object with Radar evaluation data. RecoverStack does a follow-up fetch for charges in the fraud_flag category to get that context.
International cards: where my US-centric defaults break
Most public guidance on dunning, including most of what I've been reading, is implicitly US-centric. The 1st-and-15th payday anchoring assumes a US-style monthly payroll. The 24-hour bank-block retry assumes a bank that processes overnight and reissues authorization in the morning. None of those assumptions hold uniformly across the world, and an operator with a meaningful international customer base needs to know where the defaults break.
Europe: Most European banks pay on the last working day of the month, not the 1st. SEPA-area cards have higher rates of authentication_required failures because of PSD2 strong customer authentication. Smart Retries already handles SCA-required charges by routing them through 3D Secure, but operator awareness matters: a sudden spike in authentication_required after a Stripe API update usually means Stripe has tightened SCA enforcement, not that something is wrong with your customers.
UK: Similar to Europe on payday timing, with the additional wrinkle that UK debit cards have a noticeably higher rate of withdrawal_count_exceeded declines because some banks still impose tight daily transaction limits on debit accounts.
India: Indian cards operate under the RBI's 2026 E-Mandate Framework (effective 2026-04-21 per RBI Circular RBI/DPSS/2026-27/396), which requires explicit cardholder authorization for every recurring charge over ₹15,000 and a pre-debit notification sent to the cardholder at least 24 hours before each charge. Below the ₹15,000 threshold, charges can run on a registered e-mandate without per-charge authorization, but the threshold catches a lot of mid-priced SaaS subscriptions. This makes the retry strategy in this guide largely inapplicable for above-threshold Indian cards. RecoverStack's v1 will detect Indian cards on connection and route above-threshold customers to a dedicated flow that surfaces the authorization requirement to the operator, rather than retrying blindly. That flow sends its pre-debit notice about 26 hours before each charge, which clears the 24-hour regulatory minimum with a couple of hours to spare.
Brazil: Brazilian cards have high rates of currency_not_supported and card_not_supported declines for cross-border subscription billing, because many Brazilian-issued cards are domestic-only by default. The recovery action here is almost always to email the customer asking them to use a different card or to switch to a Brazilian-currency billing flow if you offer one.
Asia-Pacific (Japan, Korea, Singapore): Generally well-behaved, with payment patterns close to Stripe's defaults. The exception is Korea, where some domestic cards behave like Indian cards and require per-charge authorization for recurring billing.
I have no firsthand customer data in any of these geographies yet. The above is from Stripe's region-specific docs and a small number of public engineering posts. Cohort customers in any of these regions will teach me much more than the docs do.
What an operator actually wants to see in a dashboard
Reading decline codes is one thing, but acting on them at the operator level is a separate question, and one I'm thinking about a lot while building RecoverStack's dashboard.
The four numbers I want every operator to see at a glance:
1. MRR at risk: The dollar value of subscriptions that have failed at least once in the last 30 days and are not yet recovered. This is the number that says "here is the revenue at risk."
2. Recovery rate, last 90 days: The percentage of failed charges that have been recovered, broken down by category. A healthy account looks something like 70% on transient, 50% on card update, 35% on bank block, near zero on fraud flag. Wide deviations from those baselines are a signal that something is off in the current overall strategy.
3. Top 3 decline codes by volume: Tells you which failure mode is dominating your pipeline this month. Operators with very different customer bases will see very different top 3 lists, and that should turn out to be informative.
4. Cards expiring in the next 30 days: This is the pre-failure email queue. Every card on this list is a recoverable revenue stream if you act before the failure.
Numbers I'm deliberately not surfacing prominently: total dollars recovered all-time (vanity metric, not actionable), per-customer detail (operators don't need to drill into individuals on the main dashboard page), week-over-week change in recovery rate (too noisy at small volumes to be useful).
What an operator wants to do from the dashboard:
- Pause retries on a specific customer (because they messaged support and asked you to).
- Manually trigger a card-update email for a customer flagged as
fraudulentwho you've verified is real. - See the email cadence in-flight for a specific failed charge, and pause or accelerate it.
- Export the last 30 days of failures with codes and outcomes, for finance reconciliation.
These four actions cover most of the operator workflow I'm hearing about in early conversations. v1 will have all of them.
How RecoverStack encodes this
The categories above are exactly the structure of RecoverStack's decline engine. Each Stripe decline code is mapped to one of the four categories at startup, and each category has a strategy class that owns retry timing, customer email logic, and operator surfacing.
Here's the categorization the engine uses. The per-code timings and email variants live in production and aren't reproduced here.
type DeclineCategory = 'transient' | 'card_update' | 'bank_block' | 'fraud_flag';
const DECLINE_CATEGORY: Record<string, DeclineCategory> = {
// Transient. Silent retries, customer email only if retries exhaust.
processing_error: 'transient',
try_again_later: 'transient',
reenter_transaction: 'transient',
insufficient_funds: 'transient',
withdrawal_count_exceeded: 'transient',
// Card-update. Skip retries (mostly), email the customer immediately with an update link.
expired_card: 'card_update',
incorrect_cvc: 'card_update',
incorrect_number: 'card_update',
lost_card: 'card_update',
stolen_card: 'card_update',
invalid_account: 'card_update',
card_not_supported: 'card_update',
currency_not_supported: 'card_update',
new_account_information_available: 'card_update',
pickup_card: 'card_update',
restricted_card: 'card_update',
do_not_honor: 'card_update',
call_issuer: 'card_update',
incorrect_pin: 'card_update',
pin_try_exceeded: 'card_update',
// Bank block. A small fixed retry schedule, then dunning sequence.
card_declined: 'bank_block',
generic_decline: 'bank_block',
// Fraud / security. No retry, operator alert.
fraudulent: 'fraud_flag',
card_velocity_exceeded: 'fraud_flag',
merchant_blacklist: 'fraud_flag',
};
A few non-obvious things in this map: insufficient_funds falls through to a payday-aware scheduler (next 1st/15th/Monday) rather than a fixed retry array, because real-world cohort data shows the card empties on payroll day and refills 1-3 days later. do_not_honor lives in card_update rather than bank_block because the bank's signal is strong enough that a careful retry plus an immediate card-update email outperforms a multi-step bank-block sequence. call_issuer also lives in card_update because the customer cannot do anything from the operator side until they've talked to the bank, so a retry calendar is wasted motion. incorrect_pin and pin_try_exceeded are categorized as card_update rather than fraud_flag because the rare CNP cases that produce them are almost always a wrong-card situation rather than a fraud.
Each category has its own strategy with its own specific timing, email sequence, and operator-alerting rules. The categories are deliberately a small fixed set, because the value isn't in adding more categories, it's in tuning the strategy of each one against the real cohort data.
What this looks like if you build it yourself
Some operators will read a guide like this and decide to roll their own dunning instead of paying for a tool. That's a valid choice, and I've thought about which case justifies it. Here's the rough scope of work, based on what RecoverStack's engine looks like internally so far.
The webhook handler: A reliable endpoint that receives invoice.payment_failed, charge.failed, and customer.subscription.updated events from Stripe, dedupes them on event ID, and stores the relevant fields. Stripe's webhook docs cover signature verification and event-ID deduplication. Idempotency at the application level and 5xx-retry behavior fall to you. Plan on a couple of days of engineering for a robust handler, including the test fixtures.
The decline-code router: The DECLINE_CODE_MAP from the section above, plus a strategy class per category. About 800 lines of Typescript or Python in my version, including the unit tests that cover each category. Maybe two days of focused work, or a week of part-time work, if you've never built a state-machine-style router before.
The retry scheduler: A queue (BullMQ in my case, but Sidekiq, Redis Streams, or a SQL-backed queue all work too) that holds future retry attempts and fires them at the right times. The non-obvious part is coordinating with Stripe's own retry schedule so you don't double-retry. Plan on a week of engineering, plus another week of careful testing against Stripe's test mode.
The email pipeline: A transactional email integration (Resend, Postmark, or SES) plus the four-step sequence templates. The hard part isn't sending the emails; it's the cadence pause/resume logic when a retry succeeds mid-sequence and you need to cancel the queued emails. Two days of engineering for a basic version, longer if you want analytics and click tracking.
The operator dashboard: A web UI that shows the four numbers I outlined above, plus the per-customer drill-down and the manual-action affordances. This is usually where build-it-yourself estimates blow up, because dashboards always look smaller than they are. Plan on at least two weeks for something usable, longer if you also want charts+graphs.
Customer-update flows: A hosted card-update page (Stripe Checkout in payment-method-update mode handles this) plus the deep link from the dunning emails. Roughly a day of engineering, mostly because Stripe Checkout does the heavy lifting here.
Operations and monitoring: Alerting when retries fail at unusual rates/when the email queue backs up/when Stripe webhooks stop arriving. Logging this lets you reconstruct what happened to a specific failed charge. This is an open-ended task and can easily take another week of work the first time you set it up.
The honest total is something like 4 to 8 weeks of focused engineering for an operator with prior subscription-billing experience, plus ongoing maintenance. For a solo founder or a small team, that's 4 to 8 weeks not spent on the product. Whether that's a good trade or not depends on your stage, your MRR, and your engineering bandwidth.
The case for buying instead of building, in my (obviously biased) read: if your time-to-recovery is under $10K MRR, build something minimal and move on. If your MRR is between $10K and $100K, the engineering time is hard to justify against a $49 to $99 monthly tool. Above $100K MRR, the per-percentage-point recovery improvement starts to compound enough that custom dunning with operator-specific tuning starts to look attractive again.
A small ask
If you've read this far and it was useful to you, one thing would help me a lot.
Tell me what I got wrong. I would much rather be corrected before launch than after. Reply to any RecoverStack email or hit me directly at [email protected] or on LinkedIn.
About the author: I'm Yarin Goldstein. 24, based in Israel, building RecoverStack solo as my first real business after 5 years of professional dev. RecoverStack is flat-fee dunning for bootstrapped Stripe SaaS, and the founding cohort is free through July 2026. The full story is at /about. The waitlist (40% off for life) is at /early-access.