We once had a SaaS product where roughly 8% of monthly revenue was quietly evaporating due to failed payments. Not churn — people who wanted to keep paying, whose cards just... failed. Expired cards, banks declining SaaS subscriptions, temporary insufficient funds. The money was sitting there. We just weren't going after it properly.
Dunning is the process of retrying failed payments and communicating with customers to recover that revenue. Done badly, it's aggressive and annoying — you spam people into canceling. Done well, it's almost invisible and recovers 40-60% of initially failed payments. Here's how to do it well with Stripe.
Understanding Why Payments Fail
Before writing any code, you need to understand the failure taxonomy. Stripe gives you decline codes, and they matter a lot for how you respond. There's a big difference between `card_declined` (bank said no, try again later) and `card_expired` (no amount of retrying will work, the card is dead). Treating them the same way is how you waste retries and annoy customers.
- Soft declines: Temporary issues — insufficient funds, generic bank decline, do_not_honor. These are retryable.
- Hard declines: card_expired, invalid_number, lost_card, stolen_card. Retrying is pointless, you need a new card immediately.
- Fraud blocks: Stripe Radar blocked the charge. Retrying the same card will likely fail again.
- 3DS issues: Card requires authentication you didn't trigger. You need to send the customer a payment link.
Stripe's Smart Retries (part of Revenue Recovery in the billing settings) handles the retry timing for soft declines using ML to pick optimal retry windows. Enable this. It's free and it works. But you still need to handle the communication, the hard declines, and the customer-facing flow.
Setting Up the Webhook Foundation
Everything in dunning flows through webhooks. The events you care about are `invoice.payment_failed`, `invoice.payment_action_required`, `customer.subscription.updated`, and `customer.subscription.deleted`. Here's a solid webhook handler for these:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { db } from '@/lib/db';
import { sendDunningEmail } from '@/lib/email/dunning';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('Webhook signature verification failed', { status: 400 });
}
switch (event.type) {
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
case 'invoice.payment_action_required': {
const invoice = event.data.object as Stripe.Invoice;
await handleActionRequired(invoice);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionStatus(subscription);
break;
}
}
return new Response('ok', { status: 200 });
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
const attemptCount = invoice.attempt_count;
const nextAttempt = invoice.next_payment_attempt;
// Get decline code if available
const charge = invoice.charge
? await stripe.charges.retrieve(invoice.charge as string)
: null;
const declineCode = charge?.failure_code;
// Update subscription status in your DB
await db.subscription.updateMany({
where: { stripeCustomerId: customerId },
data: {
status: 'past_due',
paymentFailedAt: new Date(),
failureCount: attemptCount,
},
});
// Hard decline — card is dead, no point retrying
const hardDeclineCodes = ['card_expired', 'invalid_number', 'lost_card', 'stolen_card'];
const isHardDecline = declineCode && hardDeclineCodes.includes(declineCode);
await sendDunningEmail({
customerId,
attemptCount,
nextAttemptDate: nextAttempt ? new Date(nextAttempt * 1000) : null,
isHardDecline: !!isHardDecline,
invoiceUrl: invoice.hosted_invoice_url ?? undefined,
});
}
async function handleActionRequired(invoice: Stripe.Invoice) {
// 3D Secure required — must send payment link, retrying won't work
const customerId = invoice.customer as string;
await sendDunningEmail({
customerId,
attemptCount: 1,
nextAttemptDate: null,
isHardDecline: false,
requiresAction: true,
invoiceUrl: invoice.hosted_invoice_url ?? undefined,
});
}The Dunning Email Sequence
The email sequence is where most people get this wrong. They either send nothing (leaving money on the table) or send 5 aggressive emails in 3 days (burning goodwill and triggering spam filters). The sweet spot is three emails over 14 days, with different tones depending on where you are in the retry cycle.
- Email 1 (Day 0, first failure): Friendly and informative. 'Hey, your payment didn't go through. We'll try again in 3 days, no action needed unless your card changed.'
- Email 2 (Day 7, second/third failure): Slightly more urgent. Include a direct link to update payment method. Mention that access continues for now.
- Email 3 (Day 13, final warning): Clear and honest. 'Your subscription will be canceled in 24 hours unless you update your card.' Direct link to billing portal.
// lib/email/dunning.ts
import { Resend } from 'resend';
import { db } from '@/lib/db';
const resend = new Resend(process.env.RESEND_API_KEY!);
interface DunningEmailParams {
customerId: string;
attemptCount: number;
nextAttemptDate: Date | null;
isHardDecline: boolean;
requiresAction?: boolean;
invoiceUrl?: string;
}
export async function sendDunningEmail(params: DunningEmailParams) {
const user = await db.user.findFirst({
where: { stripeCustomerId: params.customerId },
select: { email: true, name: true },
});
if (!user) return;
// Generate a Stripe billing portal session for easy card updating
const portalSession = await stripe.billingPortal.sessions.create({
customer: params.customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
const updatePaymentUrl = portalSession.url;
if (params.requiresAction) {
await resend.emails.send({
from: 'billing@yourapp.com',
to: user.email,
subject: 'Action required: Complete your payment',
html: actionRequiredTemplate({
name: user.name,
invoiceUrl: params.invoiceUrl,
updatePaymentUrl,
}),
});
return;
}
if (params.isHardDecline) {
await resend.emails.send({
from: 'billing@yourapp.com',
to: user.email,
subject: 'Update your payment method to continue',
html: hardDeclineTemplate({
name: user.name,
updatePaymentUrl,
}),
});
return;
}
// Soft decline — sequence based on attempt count
const subjects = [
"We couldn't process your payment",
'Reminder: Payment issue on your account',
'Final notice: Your subscription ends soon',
];
const subjectIndex = Math.min(params.attemptCount - 1, 2);
await resend.emails.send({
from: 'billing@yourapp.com',
to: user.email,
subject: subjects[subjectIndex],
html: softDeclineTemplate({
name: user.name,
attemptCount: params.attemptCount,
nextAttemptDate: params.nextAttemptDate,
updatePaymentUrl,
isFinalWarning: params.attemptCount >= 3,
}),
});
}One thing we learned: always generate a fresh Stripe billing portal session per email. Don't store the URL — it expires in minutes, and a customer clicking a dead link in a billing email is a churn-generating event.
Configuring Stripe's Retry Logic
Stripe lets you configure the subscription's payment behavior for failed payments. In your Stripe Dashboard under Billing → Settings → Automatic collection, or via the API when creating/updating subscriptions. The key settings are how many days before canceling and what to do to the subscription status while in dunning.
// When creating subscriptions, configure dunning behavior
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_settings: {
payment_method_types: ['card'],
save_default_payment_method: 'on_subscription',
},
// What happens to the subscription when payment fails
// 'mark_uncollectible' = invoice stays open but sub continues
// 'cancel' = cancel immediately (too aggressive)
// Default behavior configured in Stripe Dashboard is usually fine
collection_method: 'charge_automatically',
// Send Stripe's own hosted invoice reminder emails (or disable if you handle it)
// We disable Stripe's emails and send our own for more control
});
// To check current dunning state on a subscription
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
console.log(subscription.status); // 'active', 'past_due', 'canceled', 'unpaid'
// 'past_due' = payment failed, retrying
// 'unpaid' = all retries exhausted, waiting for manual resolution
// 'canceled' = subscription endedIn your Stripe Dashboard, go to Billing → Settings → Manage failed payments. Set the retry schedule (we use 3, 5, 7 days — Stripe Smart Retries will adjust within these windows). Set the final action to either 'Cancel subscription' or 'Mark invoice as uncollectible' — we prefer cancel because it keeps data clean, but if you want to let customers catch up later without re-subscribing, use uncollectible.
Gating Access During Dunning
This is the philosophical question of dunning: do you cut access immediately when payment fails, or do you give a grace period? We strongly recommend a grace period. Cutting access on the first failed payment is aggressive, loses recoverable customers, and generates support tickets. Most payment failures are accidental.
// lib/subscription.ts
export type AccessStatus = 'active' | 'grace_period' | 'suspended' | 'canceled';
export function getAccessStatus(subscription: {
status: string;
paymentFailedAt: Date | null;
canceledAt: Date | null;
}): AccessStatus {
if (subscription.status === 'active') return 'active';
if (subscription.status === 'canceled') return 'canceled';
if (subscription.status === 'past_due' && subscription.paymentFailedAt) {
const daysSinceFailed = Math.floor(
(Date.now() - subscription.paymentFailedAt.getTime()) / (1000 * 60 * 60 * 24)
);
// 14-day grace period before suspending access
if (daysSinceFailed < 14) return 'grace_period';
return 'suspended';
}
if (subscription.status === 'unpaid') return 'suspended';
return 'active';
}
// Usage in your middleware or layout
export async function checkSubscriptionAccess(userId: string) {
const subscription = await db.subscription.findFirst({
where: { userId },
});
if (!subscription) return { allowed: false, reason: 'no_subscription' };
const status = getAccessStatus(subscription);
return {
allowed: status === 'active' || status === 'grace_period',
status,
reason: status,
};
}Show a persistent but non-blocking banner for `grace_period` status. Something like 'Your payment failed — update your card to avoid losing access.' Not a modal. Not a page takeover. A banner. Let them keep working.
Tracking Recovery Metrics
You can't improve what you don't measure. The metrics that matter for dunning are: initial payment failure rate (what % of renewals fail), recovery rate (what % of failures eventually recover), and time-to-recovery (how many days does it take). Log enough to answer these questions.
// Track dunning events for analytics
async function logDunningEvent(event: {
customerId: string;
type: 'payment_failed' | 'payment_recovered' | 'subscription_canceled';
attemptCount: number;
declineCode?: string;
amountCents?: number;
}) {
await db.dunningEvent.create({
data: {
stripeCustomerId: event.customerId,
eventType: event.type,
attemptCount: event.attemptCount,
declineCode: event.declineCode ?? null,
amountCents: event.amountCents ?? null,
createdAt: new Date(),
},
});
}
// Call this when invoice.payment_succeeded fires after previous failure
async function handlePaymentRecovered(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
await db.subscription.updateMany({
where: { stripeCustomerId: customerId },
data: {
status: 'active',
paymentFailedAt: null,
failureCount: 0,
},
});
await logDunningEvent({
customerId,
type: 'payment_recovered',
attemptCount: invoice.attempt_count,
amountCents: invoice.amount_paid,
});
// Optional: send a 'welcome back' email confirming payment
// Keep it short — just a receipt, no need to make a big deal of it
}The Stuff That Actually Moves the Needle
After iterating on this for a while, a few non-obvious things made more difference than we expected:
- Card updater service: Stripe has an automatic card updater that updates expired cards without customer action. Enable this in Dashboard → Settings → Card updates. It's free and silently recovers a chunk of failures.
- Send emails from a human name: 'Stefan from YourApp' gets opened, 'billing@yourapp.com' gets filtered. Test this — the difference is real.
- Link directly to the billing portal, not your app's settings page: The Stripe-hosted portal is one click to update a card. Your settings page probably requires navigation. Remove friction.
- Don't retry on weekends: Stripe Smart Retries handles this, but if you're doing manual retries, avoid Friday evening through Sunday. Banks are more aggressive about declining on weekends.
- Offer a 'pay now' button in your app: If someone is in grace period and logged in, show them a button that immediately attempts payment. Don't make them wait for the retry schedule.
If you're starting a new project and want this all wired up from day one rather than retrofitting it, our Stripe-integrated templates at peal.dev ship with webhook handling, subscription status tracking, and the billing portal integration already connected — you still need to write your dunning email copy, but the plumbing is there.
The best dunning system is one that makes customers feel helped, not chased. If your emails read like a debt collection notice, you're doing it wrong.
One last thing: test your webhook handling with Stripe CLI before going live. `stripe listen --forward-to localhost:3000/api/webhooks/stripe` and then `stripe trigger invoice.payment_failed`. We've been bitten too many times by webhook code that looked right but had a silent error path that swallowed the event. Test every event type you handle, check your logs, verify the database updates actually happened.
A well-tuned dunning system typically recovers 40-60% of initially failed payments. On $10k MRR, that's potentially $800-1200/month you'd otherwise write off. It's not glamorous work, but it's one of the highest-ROI things you can build once your subscription business is running.
