Why Your Dunning Emails Are Losing You Customers (And How to Fix Them
Most churn conversations start with the product. Wrong place to look. Stripe's numbers put 24% of B2C churn and 16% of B2B on failed payments, not failed products. Nobody canceled anything. Their card did.
43% of founders admit they don't know what that's costing them.
At $5,000 MRR, a typical 5–12% failure rate per billing cycle puts $250 to $600 at risk every month. Over a year, that's up to $7,000 leaking through a hole nobody's measured.
A recovered subscription keeps running for another seven months on average. Every failure you miss isn't a $50 loss — it's seven months of a customer you already had.
Automatic retries recover about 38% of failures. The other 62% need something Stripe can't provide: the right message, sent at the right time, based on why the payment actually failed.
That's the gap worth closing — routing by decline reason instead of blasting the same three emails at everyone. A customer five dollars short this week needs different handling than one whose card expired in March. Most dunning flows don't make that distinction, and it shows in the numbers.
Stop routing on decline codes
A failed Stripe payment comes back with a decline code — insufficient_funds, expired_card, processing_error, dozens of them. Most teams either ignore the codes or try to build a rule for each one. Neither holds up.
The individual codes aren't the useful part. What matters is which of three buckets a failure falls into.
Soft declines. insufficient_funds, processing_error, try_again_later. The card is fine. Stripe's Smart Retries will probably clear it within a week or two without any help from you. The customer doesn't need to do anything, other than not be surprised when the retry happens.
Hard declines. expired_card, stolen_card, lost_card, invalid_account. The card is dead. No amount of retrying fixes that. Only a new payment method does, and every day of delay makes cancellation more likely.
Unknown. Anything that doesn't map cleanly. Treat it as hard. Asking someone to update a card that was already fine costs you nothing. Telling someone their card is fine when it isn't costs you the customer.
Run this classification in your backend or transform layer, before any of it reaches Klaviyo. Klaviyo should only ever see a clean decline_category field on the Payment Failed event — never a raw Stripe code.
Get this wrong and you'll send "no action needed, we'll try again" to someone whose card expired months ago. That email doesn't recover a payment. It tells the customer their problem doesn't exist, which is its own way of losing them.
Build two flows, not one with a split
Once the categories exist, the obvious move is one Klaviyo flow with a conditional split at the top — soft goes left, hard goes right. It looks efficient. It's the wrong call.
Soft and hard declines don't just need different copy. They need different clocks. Soft can wait three or four days between emails, because Stripe is still working in the background. Hard needs a direct ask on day one, since nothing is collecting that payment for you.
Put both branches in one flow and you force two paths to share a timing model neither path wants. It's two flows wearing a trench coat.
Build them separately.
Flow 1 — Soft declines
Triggered on Payment Failed where decline_category = soft.
- Email 1, immediately: low-urgency notice. "Your payment didn't go through. We're retrying automatically — no action needed."
- Email 2, at
attempt_number = max_attempts − 1: raise urgency slightly, but don't push a card update unless retries have already stalled. - Exit: a Payment Recovered event stops the flow, or the retries exhaust and the profile re-enters as a hard decline.
Flow 2 — Hard declines
Same trigger, decline_category = hard.
- Email 1, immediately: direct card-update request with your pre-authenticated Billing Portal link.
- Email 2, +2 days if unopened: new subject line, same call to action.
- Email 3, +5 days if still unresolved: final notice using a
days_until_cancellationfield from your payload. - Exit: the customer updates the card, or you cancel.
It's more setup than a split. But each flow reports on its own, tests on its own, and doesn't quietly contaminate the other's numbers. Try A/B testing timing inside a single split flow and watch your reporting turn to soup.
Make your emails attempt-aware without adding branches
Nobody wants to rebuild a flow every time the retry schedule changes.
Pass attempt_number and max_attempts in the event payload, then handle the wording inside the email template with a conditional block:
{% if attempt_number >= max_attempts - 1 %}
This is our last automatic attempt before your subscription pauses.
{% else %}
No action needed yet — we'll try your card again in a few days.
{% endif %}
Now MAX_RETRY_ATTEMPTS can go from 4 to 6 in your backend and the copy adjusts itself. The flow never has to know.
Stop Stripe's retries and your emails from colliding
Here's one that annoys customers, not just your dashboard.
Stripe's Smart Retries and your own scheduled retries both fire invoice.payment_failed. Don't tell them apart and you'll send "we're retrying your card soon" at the exact moment Stripe already is. Two emails, one failure — customers notice.
Fix it with one field:
retry_source: "stripe_smart_retry" | "manual"
Pair it with Klaviyo's smart-sending window and the overlap disappears. Small change, entire category of redundant emails gone.
What this looked like in practice
I rebuilt a client's dunning setup on exactly this structure — decline categorization upstream, two flows instead of one, attempt-aware copy, retry-source tagging. No new platform, no migration. Just better routing.
Recovery email click-through went from 4% to 19% in the first month. Failed-payment churn dropped from 11% to under 6%. The email copy barely changed. The logic underneath it did.
None of this needs to be complicated. The payload should carry the logic, not the flow — classify upstream, split by urgency, and let attempt-aware copy do the rest. Do that, and dunning stops being an email problem. It becomes routing, and routing is easy to get right once you stop treating every failure the same.
Hi, I'm Mathew — I build Stripe billing infrastructure, dunning systems, and payment integrations that keep SaaS revenue from leaking. If this kind of thing is useful to you, I write more of it over on Medium and post updates on LinkedIn.
I help SaaS founders recover up to 50-60% of their “failed payment” churn through technical Stripe & Klaviyo optimization.