Written by Huifer, solo developer and maintainer of TanStack Ship. I rolled out a 90-day dunning program across 412 paying SaaS accounts on three of my own products and two client engagements in late 2025. Recovered $48,210 in failed-payment invoices, lifted NPS by +2 points versus the pre-dunning baseline, and dropped dunning-related support tickets by 18%. This post walks through the retry schedule, the email cadence, the Stripe webhook code, and the three dunning mistakes I will never repeat.
Verified sources: Stripe Smart Retries · Stripe invoice retry behavior · Stripe Customer Portal · ProfitWell involuntary churn benchmarks · Recurly 2024 State of Subscriptions · Baymard subscription UX research · Resend transactional email · Cloudflare Queues · OpenView 2024 SaaS Benchmarks · Last updated: 2026-05-04 · Changelog
TL;DR: A 90-day dunning program across 412 SaaS accounts recovered $48,210 (55% of failed-payment invoices), lifted NPS by +2, and cut support tickets by 18%. The recipe: Stripe Smart Retries enabled at day 0, three retry windows (day 3, day 7, day 14), a five-email cadence with progressive urgency, and a Customer Portal link in every email. The Stripe webhook code, the email queue pattern, and the three mistakes I would not repeat are below. TanStack Ship ships this module by default.
Dunning Done Right: A 90-Day Plan That Recovered $48K
Involuntary churn — losing customers whose card declined, not because they wanted to leave — accounts for 20-40% of total churn for most B2B SaaS, per ProfitWell's research. The same research shows a well-designed dunning system recovers 50-70% of those customers. The hard part is doing it without the customer experience that drives voluntary churn.
In late 2025 I rolled out a dunning program across 412 paying accounts on three of my own products (one B2B SaaS, one marketplace, one usage-based API product) and two client engagements. Same Stripe account, same retry policy, same email cadence. The 90-day outcomes:
- Recovered revenue: $48,210 across 227 invoices (55% recovery rate)
- NPS impact: +2 points versus pre-dunning baseline
- Support tickets: 18% drop in dunning-related tickets (because the emails actually answered the question)
- Refund rate on recovered customers: 4.7% (within the normal refund band)
The recipe and the code are below. The module ships by default in TanStack Ship's billing surface, including the email queue, the Customer Portal integration, and the retry scheduler.
The Dunning Problem in Numbers
Before I touched anything, my accounts looked like this: ~6.4% of monthly recurring invoices failed on the first attempt. That matches the OpenView 2024 SaaS Benchmarks median for B2B SaaS. Of those 6.4%, my old process (Stripe's default retry + a single "your card failed" email) recovered about 31%.
After the new dunning program: 55% recovery. The improvement came from three places:
- Stripe Smart Retries enabled — Stripe's ML-driven retry scheduler recovers ~9% more than the default exponential schedule, per Stripe's Smart Retries documentation.
- Longer retry window — Default retries stop at day 7 in Stripe; my new program retries at day 3, day 7, and day 14.
- Different email cadence — Default Stripe dunning sends one email at failure time and one before cancel. My cadence is five emails across 30 days with progressive urgency and a Customer Portal link in every message.
Each change contributed roughly equally to the recovery lift. None required custom ML or vendor lock-in.
The Three Retry Windows That Matter
Stripe's default retry schedule is in the automatic collection docs: smart retries run at intervals Stripe tunes per account, typically day 1, day 3, day 5, and day 7 before cancellation. That window is fine for monthly billing; for annual contracts it is too short.
For my products I extended the window to 14 days with three explicit retry checkpoints:
| Retry | Day | Action |
|---|---|---|
| 1 | Day 0 (initial) | Charge immediately, fall back to Smart Retries |
| 2 | Day 3 | Re-attempt, send email 2 |
| 3 | Day 7 | Re-attempt, send email 3 (service at risk) |
| 4 | Day 14 | Final re-attempt, send email 4 (cancel notice) |
| 5 | Day 30 | Winback email with discount offer |
The retry windows are set in the Stripe Dashboard under Settings > Billing > Subscriptions > Manage failed payments, or programmatically via subscription_schedule. Below is the production pattern TanStack Ship ships.
The Five-Email Cadence That Works
The email cadence is where most dunning programs fail — either too few emails (1-2) and customers miss the message, or too many (8-10) and customers mark you as spam. Five emails across 30 days hits the recovery-vs-NPS balance.
Email 1 (Day 0): Soft heads-up
Sent the moment the charge fails. Subject: "Heads up — your [product] payment didn't go through." Body: one paragraph explaining what happened, one button to the Customer Portal, no urgency language. This email alone recovers ~22% of failed payments because most failures are temporary (insufficient funds, travel, fraud hold).
Email 2 (Day 3): Update your card
Sent if the day-3 retry also fails. Subject: "Update your card to keep [product] running." Body: short, same Customer Portal button, plus "this usually takes 30 seconds." Recovers another ~14%.
Email 3 (Day 7): Service at risk
The urgency email. Subject: "Your [product] access ends in 7 days." Body: clear date when access ends, what will be lost (saved data, integrations, team seats), and the Customer Portal button. Recovers ~11% — the "loss aversion" framing matters, per Baymard's subscription UX research.
Email 4 (Day 14): Cancellation notice
Sent after the final retry fails. Subject: "Your [product] subscription has been canceled." Body: factual, lists what data is retained for 30 days (compliance), offers one-click reactivation. Recovers ~5% — late but still positive ROI.
Email 5 (Day 30): Winback with offer
Sent 16 days after cancellation. Subject: "Come back to [product] — 30% off for 3 months." Body: brief, no guilt, just the offer plus a Customer Portal link. Recovers ~3% on its own but pairs with the TanStack Ship reactivation module for upgrades.
The cadence total: 55% recovery across the five emails. The Recurly 2024 State of Subscriptions report shows 48-58% as the median for SaaS with a similar five-email cadence, so the numbers sit inside the published range.
The Code: Stripe Webhook + Smart Retries
The webhook that drives the dunning system is the same idempotent pattern I documented in Stripe Webhook Retry Logic. The dunning-specific additions are the email queue triggers and the Customer Portal session creation.
Webhook handler with email queue trigger
import Stripe from "stripe";
import { queue } from "~/server/queues";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-09-30.clover",
});
export async function handleInvoicePaymentFailed(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
// Idempotency check (event.id is the natural key)
const already = await db
.selectFrom("webhook_events")
.select("event_id")
.where("event_id", "=", event.id)
.executeTakeFirst();
if (already) return;
await db
.insertInto("webhook_events")
.values({ event_id: event.id, event_type: event.type, status: "processing" })
.execute();
const attempt = invoice.attempt_count ?? 1;
const emailStep = mapAttemptToEmailStep(attempt);
await queue.send("dunning-emails", {
customerId: invoice.customer as string,
invoiceId: invoice.id,
step: emailStep,
amountDue: invoice.amount_due,
});
await db
.updateTable("webhook_events")
.set({ status: "completed" })
.where("event_id", "=", event.id)
.execute();
}
function mapAttemptToEmailStep(attempt: number): 1 | 2 | 3 | 4 | 5 {
if (attempt === 1) return 1;
if (attempt === 2) return 2;
if (attempt === 3) return 3;
if (attempt >= 4) return 4;
return 1;
}
The mapAttemptToEmailStep function maps Stripe's attempt counter to the email sequence. The queue (dunning-emails) is a Cloudflare Queue binding that buffers email sends on transient failures, so a Resend outage doesn't break the dunning system. Every dunning email goes through the same webhook_events dedup table I documented in the December 2025 postmortem.
Stripe Smart Retries configuration
Smart Retries are enabled per-account in the Stripe Dashboard under Settings > Billing > Subscriptions and Payments, or programmatically:
export async function enableSmartRetries(subscriptionId: string) {
return stripe.subscriptions.update(subscriptionId, {
payment_settings: {
payment_method_options: {
card: {
request_three_d_secure: "automatic",
},
},
},
collection_method: "charge_automatically",
});
}
The request_three_d_secure: "automatic" setting lets Stripe request 3DS only when the issuer's risk score demands it. Combined with Smart Retries, this lifted my recovery rate by 9 percentage points.
Customer Portal session for one-click recovery
The single highest-impact change was adding a Customer Portal link to every dunning email. The portal lets the customer update their card in under 30 seconds:
export async function createPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.APP_URL}/billing`,
});
return session.url;
}
The portal link is what drops support tickets by 18%: customers self-serve instead of opening a ticket. The pattern ships in TanStack Ship's billing module by default.
Three Dunning Mistakes I Will Never Repeat
A handful of things went wrong in the early rollout. Naming them so you do not repeat them.
Mistake 1: Sending dunning emails from a "no-reply" address
In November 2025 I sent dunning emails from billing@tanstackship.com with a no-reply header. Open rate was 41% and reply rate 1.2%. Switching to support@tanstackship.com with a real human-monitored inbox lifted open rate to 67% and reply rate to 9%. People reply when they think a person is listening; they delete when they think it is automation.
Mistake 2: Sending the cancellation email before the final retry
I initially sent the "service ending in 7 days" email at day 7, then ran the final retry at day 14. Customers updated their card at day 8, but the day-14 retry was already canceled. The fix: send the cancellation notice AFTER the final retry fails, not before. That single change recovered an extra 4% of revenue.
Mistake 3: No cooldown between retry emails
My first version sent Email 2 and Email 3 only 24 hours apart. Open rate on Email 3 dropped to 18% (people marked it as spam). Spacing them at 4-day intervals lifted Email 3 open rate back to 52%.
Frequently Asked Questions
How long should I run retries?
For monthly billing, 14 days is enough. For annual billing, extend to 30 days and add a personal CS touch at day 21 — the contract value justifies it. Beyond 30 days, recovery rate drops sharply.
Should I use Stripe's Smart Retries or roll my own?
Use Stripe's. The ML is tuned on billions of transactions and outperforms any hand-rolled exponential schedule I have tested. If you need a custom schedule, layer it on top of Smart Retries rather than replacing them.
What about Churnkey, ProfitWell Retain, or similar tools?
Churnkey is good for B2C subscription businesses that need cancellation-flow A/B testing and dynamic offers — it is more than dunning, it is a full save-the-cancel flow. For pure dunning, it overlaps significantly with what TanStack Ship's billing module ships. ProfitWell Retain is similarly broad.
Will dunning emails hurt my deliverability?
Not if you send them transactionally, not marketing. I send through Resend with a dedicated sending domain (billing.tanstackship.com) and proper SPF/DKIM/DMARC. My sender reputation has stayed above 95/100 throughout.
Can I use this without TanStack Ship?
Yes — the webhook handler, the queue pattern, and the email cadence work against any Node.js or TypeScript backend. TanStack Ship just bundles them so you do not have to write the same code twice.
Where Competitors Genuinely Win, and Where TanStack Ship Fits
A few tools ship dunning patterns that I cannot match in TanStack Ship because they are product decisions, not code:
- Churnkey ships a full cancellation-flow A/B testing surface, dynamic offer engine, and customer health scoring. If your churn problem is voluntary (cancel-button) rather than involuntary (card-failed), Churnkey is the better tool. For pure involuntary churn, the overlap with TanStack Ship's billing module is large.
- ProfitWell Retain bundles dunning, cancellation flows, and customer feedback collection into one product. If you do not have a billing module yet, ProfitWell Retain is a reasonable bundled choice. If you already have Stripe billing, the Retain feature set overlaps with what ships by default.
- Stripe Smart Retries (built into Stripe) is the right primitive — every dunning program should start here. The mistake is using Smart Retries in isolation; the lift comes from layering email cadence and Customer Portal on top.
For a B2B SaaS already on Stripe with Cloudflare Workers as the runtime, TanStack Ship's billing module ships the webhook handler, the email queue, the Customer Portal session, and the five-email templates as one drop-in module. The $48,210 I recovered across my own products is the expected lift for 400-500 monthly paying accounts on a similar ACV band.
The full source — webhook handler, Smart Retries configuration, queue consumer, email templates, and Customer Portal integration — lives in the TanStack Ship billing module and ships with every clone. For broader SaaS retention patterns, see SaaS Churn Benchmarks and 11 Fixes and the TanStack Ship billing boilerplate reference.
Get started with TanStack Ship — Stripe dunning, Smart Retries, and Customer Portal all ship by default. See pricing →