We lost about $340 in MRR one month before we realized what was happening. No loud failures, no error emails, just a slow bleed of subscriptions quietly going past_due and then canceling. Stripe was retrying payments — but we had no emails going out, no grace period UI, nothing. The customer probably didn't even know their card had expired. That was our dunning wake-up call.
Dunning is the process of communicating with customers about failed payments and trying to recover the revenue. It sounds unsexy because it is. But if you're running any kind of subscription business, ignoring this is leaving real money on the table. Let's build it properly.
What Actually Happens When a Payment Fails
First, let's be clear about what Stripe does automatically vs. what you need to build yourself. Stripe has a built-in Smart Retries feature that will retry failed charges over the course of several days using ML to pick the best times. You configure this in your Dashboard under Billing > Settings > Retry schedule. Out of the box you get up to 4 retry attempts over ~4 weeks.
When a payment fails, the subscription moves to past_due. When all retries are exhausted, you can configure it to cancel or leave it past_due. What Stripe does NOT do for you: send emails with your branding, update your UI to show a warning banner, or pause access to your product. That's your job.
The webhook events you care about are: invoice.payment_failed (every failed attempt), invoice.payment_action_required (card needs 3DS auth), customer.subscription.updated (status changes), and customer.subscription.deleted (when it finally cancels). You need handlers for all of these.
Setting Up the Webhook Handlers
Here's a solid webhook handler that covers the main dunning events. This is Next.js App Router style, but the logic is the same anywhere:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { db } from '@/lib/db';
import { sendDunningEmail } from '@/lib/email';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { 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.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCanceled(subscription);
break;
}
case 'invoice.paid': {
// Payment recovered! Clear dunning state
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentRecovered(invoice);
break;
}
}
return NextResponse.json({ received: true });
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
const attemptCount = invoice.attempt_count;
// Update subscription status in your DB
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: {
status: 'past_due',
failedPaymentCount: attemptCount,
lastFailedAt: new Date(),
},
});
// Send different emails based on attempt count
await sendDunningEmail({
customerId,
attemptCount,
invoiceUrl: invoice.hosted_invoice_url ?? undefined,
});
}
async function handlePaymentRecovered(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: {
status: 'active',
failedPaymentCount: 0,
lastFailedAt: null,
},
});
// Optionally send a "payment recovered" confirmation email
}The Dunning Email Sequence
One generic "your payment failed" email isn't dunning, it's a notification. Real dunning is a sequence with escalating urgency. Here's what we send:
- Attempt 1: Friendly heads up. "Hey, your payment didn't go through — probably just an expired card. Update it here." No panic, no threats.
- Attempt 2 (3-5 days later): Slightly more urgent. "We tried again and it failed. Your account will be affected soon." Include the update payment link prominently.
- Attempt 3: "Your access is at risk. Update your payment method in the next X days to keep your account active."
- Final notice before cancellation: "This is the last retry. After [date] your subscription will be canceled and you'll lose access to [specific features]."
// lib/email/dunning.ts
interface DunningEmailParams {
customerId: string;
attemptCount: number;
invoiceUrl?: string;
}
export async function sendDunningEmail({
customerId,
attemptCount,
invoiceUrl,
}: DunningEmailParams) {
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (!user) return;
// Generate a direct link to update payment method
// This is MUCH better than sending them to a generic billing page
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
});
const templates = {
1: {
subject: 'Payment issue with your subscription',
urgency: 'low',
},
2: {
subject: 'Action needed: Update your payment method',
urgency: 'medium',
},
3: {
subject: 'Your account is at risk — payment failed again',
urgency: 'high',
},
4: {
subject: 'Final notice: Subscription will be canceled',
urgency: 'critical',
},
};
const template = templates[Math.min(attemptCount, 4) as keyof typeof templates];
await resend.emails.send({
from: 'billing@yourapp.com',
to: user.email,
subject: template.subject,
react: DunningEmail({
userName: user.name,
attemptCount,
urgency: template.urgency,
updatePaymentUrl: session.url,
invoiceUrl,
}),
});
}Always link directly to the Stripe Billing Portal with a pre-created session, not to your /billing page. Fewer clicks = more recovered payments. Every extra step loses customers.
The UI Side: Grace Period Banners
Email alone won't recover everyone. People ignore email. What works really well is a persistent banner inside your app that shows up whenever the subscription is past_due. Not a modal, not a blocking screen — a banner. You want to be firm but not make the product unusable on the first failure.
// components/DunningBanner.tsx
import Link from 'next/link';
interface DunningBannerProps {
failedAttempts: number;
updatePaymentUrl: string;
daysUntilCancellation: number;
}
export function DunningBanner({
failedAttempts,
updatePaymentUrl,
daysUntilCancellation,
}: DunningBannerProps) {
const isUrgent = daysUntilCancellation <= 3;
return (
<div
className={`w-full px-4 py-3 text-sm font-medium flex items-center justify-between ${
isUrgent
? 'bg-red-600 text-white'
: 'bg-yellow-50 text-yellow-900 border-b border-yellow-200'
}`}
>
<span>
{isUrgent ? (
<>
⚠️ Your subscription cancels in{' '}
<strong>{daysUntilCancellation} days</strong> due to a failed
payment.
</>
) : (
<>Your last payment failed. Update your payment method to avoid interruption.</>
)}
</span>
<Link
href={updatePaymentUrl}
className={`ml-4 px-3 py-1 rounded text-sm font-semibold ${
isUrgent
? 'bg-white text-red-600 hover:bg-red-50'
: 'bg-yellow-900 text-white hover:bg-yellow-800'
}`}
>
Update payment
</Link>
</div>
);
}
// In your root layout, check subscription status and show conditionally:
// const subscription = await getSubscription(userId);
// {subscription?.status === 'past_due' && (
// <DunningBanner ... />
// )}One thing we debated for way too long: should you lock users out of the product during the grace period? Our take — no, at least not immediately. Locking someone out who had their card expire accidentally is a great way to guarantee they cancel instead of update. Give them 7-14 days with visible warnings before restricting access. After that, redirect them to the update payment page instead of the dashboard.
Handling the 3DS Authentication Case
This one is especially painful in Europe because of SCA (Strong Customer Authentication). A payment might fail not because the card is invalid or has no funds, but because the bank requires 3DS authentication that didn't complete. The webhook event is invoice.payment_action_required, and the fix is different — you can't just retry with the same payment method, the customer needs to actively authenticate.
The hosted invoice URL that Stripe generates handles this automatically. When the customer opens it, Stripe walks them through the 3DS flow in the browser. This is why you should always include the invoice URL in your dunning emails for European customers — it handles both the payment update case AND the authentication case. Don't try to build a custom 3DS flow unless you have a very specific reason.
async function handleActionRequired(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
// The hosted invoice URL will handle 3DS automatically
// Just make sure you're sending it in the email
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (!user) return;
await resend.emails.send({
from: 'billing@yourapp.com',
to: user.email,
subject: 'Action required: Authenticate your payment',
react: ActionRequiredEmail({
userName: user.name,
// This URL handles 3DS flow natively
invoiceUrl: invoice.hosted_invoice_url!,
}),
});
}Pausing Instead of Canceling
Stripe lets you configure what happens when all retries are exhausted. The default is cancellation. But consider setting it to "leave as past_due" and handling cancellation yourself with a delay. Why? Sometimes customers are traveling, sometimes their new card just arrived, sometimes they genuinely didn't see your emails. If you give them an extra week after Stripe gives up, you can recover a few more.
You'd implement this with a scheduled job (cron or a queue) that checks for past_due subscriptions older than X days and cancels them. In Next.js this is straightforward with a cron endpoint on Vercel or a background job with something like Trigger.dev. The key thing to track is when the subscription first went past_due, not when the last retry happened.
We added a 7-day manual grace period after Stripe's retries exhausted and recovered an extra 8% of revenue that would have otherwise just canceled silently. Not massive, but it's free money.
Measuring What's Actually Working
Track these numbers in your database and review them monthly. They tell you if your dunning is working or if you're just annoying people:
- Dunning recovery rate: (subscriptions recovered / subscriptions that entered past_due) × 100. Anything above 40% is decent. Above 60% is great.
- Recovery by attempt: Which retry actually converted? If attempt 1 recovers 80% of what you recover, maybe you don't need 4 retries.
- Recovery by email open: Are people clicking the update link? Low click rate means your email copy or CTA is bad, not your retry logic.
- Time to recovery: How long after the first failure do customers fix it? If it's usually within 24 hours, the first email is doing the work.
Stripe Sigma (their SQL query tool) has some of this built in, but for SaaS you want this data in your own database where you can slice it however you want. Add a dunning_events table that logs every failed attempt, every email sent, and every recovery. You'll thank yourself later.
The peal.dev Next.js SaaS template ships with dunning webhook handlers, the billing portal integration, and the past_due banner already wired up — it's one of those things that's genuinely annoying to set up from scratch every time, so we built it once properly.
The short version of everything above: configure Stripe's Smart Retries, handle invoice.payment_failed with an escalating email sequence, always link to the Billing Portal (not your own pages), show a persistent in-app banner for past_due subscriptions, and add a manual grace period after Stripe gives up. Do those five things and you've got a dunning system that's better than 90% of SaaS apps out there. Most founders set up Stripe, forget about this entirely, and then wonder why their churn numbers look weird.
