← Back to Blog
·16 min read·Yarin Goldstein

Dunning for Bootstrapped SaaS: The Recovery Setup I'm Building for One Person

A dunning setup sized for a solo founder with no billing team. 46 Stripe decline codes, five action buckets, and when to turn something on, build it, or pay for it.

I'm Yarin, and I'm building RecoverStack on my own, and this post is about whether a bootstrapped founder should run their own dunning setup or pay to somebody for one. I sell one of those, so keep that in mind the whole way down. The way I'd like to earn the read is by being specific about the parts where the answer is not a simple "pay me", and there are a few of them through the post.

The constraint I design against isn't technical. It's that, in my perceived case, there's nobody to escalate to. Every dunning tool I looked at was built with the assumption that eventually some human opens a queue and works it out, and when you're that whole company, you are probably also shipping features and answering support tickets that same afternoon. The recovery flow has to make its decisions in advance and only interrupt you for the cases where a person has a real say regarding the outcome.

Which is why the organizing model below isn't "here's what went wrong." It's "here's what you're allowed & should do about it."

What dunning means when you're the whole team

"Dunning is the sequence of retries and emails that runs after a subscription charge fails". That's the whole definition, but it's not where the difficulty lives. Most of the advice written about it quietly assumes a billing operation exists on the team.

When I sat down to map this, I stopped grouping Stripe's decline codes by cause and started grouping them by recommended actions, then the whole thing got simpler. There are 46 codes in my engine right now and they fall into five categories:

  • Retry quietly
  • Ask the customer for a card
  • Wait, then nudge
  • Don't touch it
  • ...And one regional one I didn't know existed until late

Anything unmapped falls back to a 1-day / 3-day / 7-day retry schedule with the standard email sequence, because a sensible default beats a complete inaction, or a crash. Stripe's catalog has 50 card decline codes in it. 46 of them are mapped and the remaining 4 sit on that default on purpose.

Two of those five buckets are allowed to reach me as a person, and they do it differently. The other three decide for themselves and I find out later, if at all. If you count the codes instead of the buckets, that's 7 out of the 46, and holding it at 7 is the actual design goal.

Bucket 1: the failures you should never hear about

These are the transient ones - the charge failed for a reason that tends to resolve on its own, so simply retrying is the entire strategy and the customer never needs to learn it happened.

processing_error:                { retries: ['1h', '6h', '24h'], emailSequence: 'none' },
issuer_not_available:            { retries: ['1h', '6h', '24h'], emailSequence: 'none' },
approve_with_id:                 { retries: ['1h', '6h', '24h'], emailSequence: 'none' },
no_action_taken:                 { retries: ['6h', '24h'],       emailSequence: 'none' },
try_again_later:                 { retries: ['4h'],              emailSequence: 'none' },
reenter_transaction:             { retries: ['30min'],           emailSequence: 'none' },
withdrawal_count_limit_exceeded: { retries: ['24h', '48h'],      emailSequence: 'card_update_on_2nd_fail' },

Note the emailSequence: 'none'. A network glitch that clears in an hour does not deserve an email that makes your customer go find their wallet. The customer panic is redundant here.

The first three share a ladder because Stripe's own docs give them the same next step, which is some version of "attempt the payment again". no_action_taken gets a shorter one, since Stripe says nothing about retrying it and I would rather be slower on a code I'm guessing at.

withdrawal_count_limit_exceeded is the odd one in here. It gets two silent retries a day apart like the others, but if the second one also fails it stops being a transient problem and the customer gets a card-update email. A bank that keeps refusing on a frequency limit is telling you something a third retry will not fix.

duplicate_transaction is the other odd one, and it's in this bucket while getting no retry at all. Stripe hands it to you when an identical charge went through very recently, so retrying either repeats the rejection or double-charges someone whose payment already worked. It doesn't email either, because telling a customer their payment failed when it probably didn't is the worse of the two mistakes.

Then there's insufficient_funds, which sits in the same category but behaves nothing like the rest of its siblings. Money coming back into an account isn't random, it's happens on paydays, so a fixed interval is the wrong shape entirely. That code runs on an anchor policy instead of a delay list:

insufficient_funds: {
  retries: [],
  anchorBased: true,
  emailSequence: 'insufficient_funds_attempt_aware',
  category: 'transient',
},

My anchor steps target whichever of the 1st or the 15th arrives first, then the other one of the two plus a day, then 7 days after that. And the attempt-aware sequence means the first retry sends nothing at all, so only the second and third carry an email. I wrote about the payday reasoning in depth in the insufficient_funds post.

For a solo founder, this bucket should be completely hands-free (No alerts and no dashboard to check regularly). If you find yourself looking at it, something is wrong with the setup and not with the payment failure.

Bucket 2: nothing recovers until the customer acts

This is the biggest portion, amounting to 20 of the 46 codes, and every single entry has no retries involved.

expired_card, incorrect_cvc, incorrect_number, lost_card, invalid_account, new_account_information_available, restricted_card, incorrect_pin and pin_try_exceeded all route to the same card-update email sequence, because there is no version where retrying fixes a card the issuer will keep rejecting. So do incorrect_address, incorrect_zip, invalid_cvc, invalid_number, invalid_pin, invalid_expiry_month and invalid_expiry_year, which are the same failure wearing seven different field names: something in the card data I'm holding is wrong, and sending it again unchanged fails the same way every time. expired_card has its own post too and it's the clearest case in the whole category.

The most delicate part for this group is writing out the exact emails per code. stolen_card and pickup_card route to a neutral-tone variant, and the reason for that is that naming the decline reason loses either way. Tell a customer their bank flagged the card as stolen and one of two things is true. It really was, in which case they cancelled it days ago and already know more about it than you do. Or the issuer got it wrong, and you have just told a paying customer their card is stolen when it wasn't.

What's the correct approach here? The neutral variant doesn't name the reason at all. It simply says the renewal didn't go through, gives the update link, and stops there. That's the way out when both options are bad - the customer has to do the same thing either way, which is to put a different card on, and specifying the reason it failed is information overload that only creates a problem for you. card_not_supported gets an explanatory variant instead, because on that one the reason IS the message: that card can't do recurring billing at all and a different one is needed. currency_not_supported gets its own variant too.

So that's four tone variants across one bucket, all of them essentially ask a customer to update their card, but none of them say it the same way. That's the kind of thing that's easy to skip when you're one person, and it's also the kind of thing that indicates a sub-par dunning system that has gaps in knowledge.

Bucket 3: bank blocks, where one retry is the most I'll risk

generic_decline is the bank rejecting you without telling why, and it falls into the standard dunning sequence (6h -> 1d -> 7d). Sometimes the block clears on its own, sometimes it doesn't, and sometimes you just can't tell from the outside which one you're in. One reasonable retry attempt, then a real email is about the best I can do with that at the scale of one person.

There are 10 codes in this bucket and only two of them retry at all. do_not_honor gets exactly one retry after a day, and then a card-update email rather than the standard dunning one. call_issuer gets no retry at all and an email that tells the customer to call their bank, which is the only thing that (indicatively) will resolve it. Same story goes for transaction_not_allowed, not_permitted and invalid_amount, each one is a standing limit on the card rather than a bad moment, and the only person who can lift it is the cardholder talking to their issuer.

do_not_honor is where I'd bet on being wrong, if I'm wrong about any of this. It's a sort of catch-all bucket rather than a reason - the issuer uses it for several unrelated situations and doesn't tell you which one you got. I've split the difference but I'm not confident it's right.

Then there are four I'd rather not have sitting in this bucket at all. revocation_of_authorization, revocation_of_all_authorizations, stop_payment_order and do_not_try_again are the customer, or their bank acting on their behalf, withdrawing permission for me to charge. Nothing about the card is broken. Retrying those is useless, and in a few jurisdictions it's specifically the thing the revocation forbids, so all four get zero retries. What they don't have yet is their own email. They borrow the neutral card-update copy, which has the merit of not claiming the card is broken, but the honest message is "your bank withdrew authorization for this subscription, re-authorize or use a different method" and I haven't written that variant yet. I'd rather flag that here than let the closest available email pass for the intended one.

Bucket 4: the one I found late

authentication_required appeared to be a category all by itself, and it exists primarily because of India.

Recurring payments there run on an e-mandate regime the RBI rewrote in 2026, consolidating a stack of earlier circulars into one framework. When a customer's mandate needs re-authorization, the charge fails and no amount of retrying will help. Stripe's own Smart Retries documentation is direct about it and lists India-issued cards among the payments it doesn't retry, so the retry lever isn't even available to you. That code gets zero retries, surfaces to the operator the same way a fraud flag does, and puts the customer onto the card-update email sequence so they can re-authorize.

What may sound counter-intuitive is that the routing isn't gated on the customer's country. The decline code is the entire trigger, so an authentication_required coming from anywhere at all lands in the same branch. India is the reason the branch exists, but its not the filter of who it applies to.

Two more codes ended up in here with it, and neither has anything to do with India. authentication_not_handled is Stripe telling me the charge went ahead without the authentication step it needed, and mobile_device_authentication_required wants the cardholder to approve it on their phone. Both indicate that the person has to be physically present to authenticate, and an off-session retry can't produce that. That's the property the bucket is actually built on, and the India mandate is the case that made me notice it rather than the whole of it.

Bucket 5: the small & loud ones

fraudulent, merchant_blacklist, card_velocity_exceeded & security_violation.

Retrying here is worse than doing nothing, because repeated attempts against a card the network has already flagged put flags on your account rather than theirs. So the flow should stop and a notice should be forwarded to the operator directly, which on a bootstrapped SaaS means it hands it to you, whenever it happens.

Four codes out of 46 land here, and the three mandate codes in the previous bucket reach me too, so 7 of the map can page a person and that's a bit over 15% of it. The difference between the two groups is that a mandate failure still emails the customer on its own, and a fraud flag does not. Keeping that combined number small is most of what makes the other three buckets worth building. An alert you get twice a month gets read, but if it pops up twice a day it becomes a filter rule and then it may as well not exist at all.

All of this indicates a broader trend, and the India code is what made me notice it. A regulator in a country I don't operate in changes a rule, and the consequence is renewals quietly failing in a way that looks identical to every other decline in your Stripe dashboard. An alert you filter and a failure you never see end up costing exactly the same. You might not get a say on how to optimally handle these cases, because you might not see them at all when they happen.

What to turn on, build, or buy?

I have the most to gain from this section, so I'll give you the numbers first and then my own bracket later, and you can infer the second one however you like.

What to turn on: Stripe Smart Retries is free, bundled into Billing, and the recommended default is 8 tries within 2 weeks, with the timing chosen per payment by a model rather than a fixed interval. For a lot of bootstrapped founders that + a working card-update email is where the effort should stop. Stripe publishes that businesses using Stripe recover 55% of failed payments on average and that $8.2B was recovered in 2025, and while those are aggregates across every merchant and every tool rather than a benchmark for your account, they're not nothing.

What Smart Retries doesn't do though is change its strategy by the code. Everything retryable gets the same retry-it treatment. It does refuse to retry a set of hard declines, and that list comprises of nine codes: incorrect_number, lost_card, pickup_card, stolen_card, revocation_of_authorization, revocation_of_all_authorizations, authentication_required, highest_risk_level and transaction_not_allowed. Note that expired_card isn't on that list, so by the default behavior it will retry a card that can never work.

What to build: Worth knowing before you start that Stripe's custom, non-Smart schedules cap at 3 retries with configurable gaps, so a bespoke cadence will buy you fewer attempts, not more. The build is not hard in the sense of being algorithmically difficult - It's just a mapping table and a queue. It's hard in the sense that it's 46 codes worth of decisions, five different card-update tones, an anchor policy, and edge cases that are being added and modified over time, and none of that represents the core product your customers pay you for.

What to buy: When I last checked the incumbents in May 2026: Churn Buster starts at $149/mo scaled by your MRR, and every tier routes through a booked call rather than a signup button. Churnkey's Starter tier is $250/mo billed yearly with a self-serve trial. Baremetrics Recover is an MRR-scaled add-on with its own price tag, around $499/mo at $300k MRR per their public calculator, and it needs a Baremetrics account underneath it first.

RecoverStack is $49-149/mo flat, and I want to be straight what it means: flat, public pricing is not my main differentiator. Other tools have done flat and public for years, and there are newer ones sitting under my floor. If a published number and no sales call is the only thing you're shopping for, I wasn't first and I'm not cheapest.

However, what I am betting on is the bucket map above, keeping up with new regulations in this space, and on publishing what it actually recovers once there's data to publish.

Here is my honest bracket: under roughly $10k MRR, just turn on Smart Retries, write one decent card-update email, and go back to building your own thing. When going over that and up to $100K-$150K MRR, that's when the codes start being worth routing individually. However that's also the band where a month of your own engineering time costs more than a year of anybody's tool. Above that, those are enterprise options I'm not the right person to advise on just yet.

What I'm still unsure about

  • Whether five buckets is the right number, or whether do_not_honor is two different failures wearing one code and I skimmed over it.
  • The silent first retry on insufficient_funds - Sending nothing on attempt 0 is deliberate for now, and it's also a bet that the quiet retry will land. If it won't, I've spent the most valuable slot on the Baremetrics recovery curve, where day 0 is worth about 13% and day 15 is worth about 4%.
  • What a fraud alert should actually look like in a one person show. Emails get filtered and a dashboard badge gets missed. Is a phone notification at 3am too much? I don't know yet where the line sits.
  • Whether under-$10k founders should buy anything at all, including from me. I keep gravitating towards no and I keep noticing that's an inconvenient answer for someone selling that kind of tool.

I have 0 customers and 0 recovery data as of writing this, so every mapping above is reasoning from Stripe's documentation and not from outcomes. If you're running dunning on Stripe today and any of my claims contradicts what you actually see, particularly the do_not_honor split or the silent-first-retry bet, I would much rather have your numbers than my own logic. I'd also rather be corrected on a public post than find out from live customers. Reply to any RecoverStack email or hit me at yarin@recoverstack.dev.

Yarin


The full code-by-code reference underneath all five buckets is the pillar, Stripe Decline Codes: What Each One Means and What I Plan to Do About It. If you're deciding whether Stripe's built-in retries are enough on their own, that comparison is Stripe Smart Retries vs Custom Dunning. The two per-code deep dives are insufficient_funds retries and expired_card recovery, and for the financial framing of why any of this is worth the time, see How Much MRR Is Your Stripe Account Losing to Failed Payments?.

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 for your first 90 days. The full story is at /about. Join the waitlist to get 40% off for life at /early-access, or run a free Stripe audit to get your own failure profile report first.

dunningbootstrappedsaaspayment-recoverystripesolo-founder