A subscription fails at 3am. Nobody notices. The customer's card expired two weeks ago but they're still using your app — you just haven't charged them yet. Then Stripe tries, fails, tries again, fails again, and eventually cancels the subscription. The customer gets a cold 'your subscription has been cancelled' email, gets confused, maybe angry, and churns. You lost money and a customer. This happens constantly in SaaS and most teams handle it badly.
Dunning is the process of handling failed payments — retrying charges, notifying customers, and gracefully degrading access when someone genuinely can't pay. Done right, it recovers 20-40% of failed payments that would otherwise become churn. Done wrong, it's either too aggressive (annoying customers who just forgot to update a card) or too passive (letting people use your app for free for weeks).
Understanding Why Payments Fail
Before writing any code, know what you're dealing with. Stripe's decline codes fall into a few categories and you should handle them differently.
- Soft declines: Temporary issues — insufficient funds, bank timeout, card temporarily blocked. These are worth retrying automatically.
- Hard declines: The card is closed, stolen, or fraudulent. Retrying is pointless; you need customer action.
- Expired card: The customer hasn't updated their payment method. Completely recoverable if you reach them.
- Do not honor: Catch-all bank decline. Usually temporary, worth one or two retries.
- Authentication required: The card needs 3D Secure. Stripe handles this with payment intents but you need to prompt the customer.
Stripe Smart Retries (part of their built-in dunning) uses ML to pick optimal retry times. It's genuinely good and you should enable it. But relying on it entirely means you're handing customer communication to Stripe's default emails, which are functional but completely generic. You want to own that communication.
The Webhook Events That Matter
Your dunning system lives or dies on webhooks. These are the events you need to listen to:
// The four events that drive your dunning flow
const DUNNING_EVENTS = [
'invoice.payment_failed', // First failure — start the dunning sequence
'invoice.payment_action_required', // 3DS needed — send auth link immediately
'customer.subscription.updated', // Status changes (active -> past_due)
'customer.subscription.deleted', // Final cancellation — last chance email
] as const;
// In your webhook handler
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('Webhook signature failed', { status: 400 });
}
switch (event.type) {
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_action_required':
await handleActionRequired(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
}
return new Response('ok');
}One thing we got wrong early on: we were handling `invoice.payment_failed` and `customer.subscription.updated` separately but triggering duplicate emails. The subscription update to `past_due` fires right after the first payment failure. If you're not careful, the customer gets two emails within seconds. Track what you've already sent.
Building the Dunning Sequence
Here's a sequence that works well without being aggressive. The key insight is that most customers who fail payment aren't trying to avoid paying — they just have a stale card on file and haven't noticed.
// dunning.ts
import { db } from '@/lib/db';
import { sendEmail } from '@/lib/email';
interface DunningState {
subscriptionId: string;
customerId: string;
attemptCount: number;
lastAttemptAt: Date;
status: 'retrying' | 'past_due' | 'cancelled';
}
export async function handlePaymentFailed(invoice: Stripe.Invoice) {
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription as string
);
const customer = await stripe.customers.retrieve(
invoice.customer as string
) as Stripe.Customer;
const attemptCount = invoice.attempt_count ?? 1;
// Get or create dunning record in your DB
const dunningRecord = await db.dunning.upsert({
where: { subscriptionId: subscription.id },
create: {
subscriptionId: subscription.id,
customerId: customer.id,
attemptCount,
status: 'retrying',
},
update: {
attemptCount,
lastAttemptAt: new Date(),
},
});
// Generate a payment update URL (hosted page or your own)
const session = await stripe.billingPortal.sessions.create({
customer: customer.id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
// Send the right email based on attempt number
if (attemptCount === 1) {
await sendEmail({
to: customer.email!,
template: 'payment-failed-first',
data: {
customerName: customer.name,
updatePaymentUrl: session.url,
nextRetryDate: getNextRetryDate(subscription),
},
});
} else if (attemptCount === 2) {
await sendEmail({
to: customer.email!,
template: 'payment-failed-second',
data: {
customerName: customer.name,
updatePaymentUrl: session.url,
daysUntilCancellation: getDaysUntilCancellation(subscription),
},
});
} else if (attemptCount >= 3) {
await sendEmail({
to: customer.email!,
template: 'payment-failed-final-warning',
data: {
customerName: customer.name,
updatePaymentUrl: session.url,
},
});
}
}
function getNextRetryDate(subscription: Stripe.Subscription): string {
// Stripe retries at roughly 3, 5, and 7 days by default with Smart Retries
const nextDate = new Date();
nextDate.setDate(nextDate.getDate() + 3);
return nextDate.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric'
});
}Graceful Access Degradation
This is where most implementations go wrong in both directions. Option A: immediately lock out the customer the moment a payment fails. You lose recoverable churns because they can't even log in to fix their card. Option B: let them keep full access through all the retries. You're giving away the product for free for potentially 2-3 weeks.
The middle ground is a grace period with progressive restriction. Give full access during retries, then read-only access when the subscription goes `past_due`, then lock on cancellation. Customers can always update their payment method regardless of access level.
// lib/subscription-access.ts
import type { Stripe } from 'stripe';
type AccessLevel = 'full' | 'readonly' | 'locked';
export function getAccessLevel(
subscription: Stripe.Subscription | null
): AccessLevel {
if (!subscription) return 'locked';
switch (subscription.status) {
case 'active':
case 'trialing':
return 'full';
case 'past_due':
// past_due = payment failed, Stripe is still retrying
// Give readonly access so they can still log in and fix payment
return 'readonly';
case 'canceled':
case 'unpaid':
return 'locked';
case 'incomplete':
case 'incomplete_expired':
// Never fully activated — treat as locked
return 'locked';
default:
return 'locked';
}
}
// Use in middleware or server components
export async function checkSubscriptionAccess(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
if (!user?.subscription?.stripeSubscriptionId) {
return { level: 'locked' as AccessLevel, subscription: null };
}
// Cache this — you don't want a Stripe API call on every request
const subscription = await stripe.subscriptions.retrieve(
user.subscription.stripeSubscriptionId
);
return {
level: getAccessLevel(subscription),
subscription,
};
}Cache subscription status in your database and sync it via webhooks. Making a Stripe API call on every page load to check subscription status will kill your performance and run up your API usage. Your webhook handler should update a `subscriptionStatus` field in your DB, and you check that instead.
The 3DS Action Required Case
European customers and increasingly everyone else will trigger 3D Secure requirements. This is different from a regular failure — the charge isn't declined, it just needs additional authentication from the customer. Stripe fires `invoice.payment_action_required` and you need to get the customer to click a link.
export async function handleActionRequired(invoice: Stripe.Invoice) {
const customer = await stripe.customers.retrieve(
invoice.customer as string
) as Stripe.Customer;
// The payment intent has the hosted_voucher_url for 3DS
const paymentIntent = await stripe.paymentIntents.retrieve(
invoice.payment_intent as string
);
// This URL takes them through the 3DS flow
const authUrl = paymentIntent.next_action?.use_stripe_sdk?.stripe_js
?? paymentIntent.next_action?.redirect_to_url?.url;
if (!authUrl) {
// Fallback to billing portal
const session = await stripe.billingPortal.sessions.create({
customer: customer.id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
await sendEmail({
to: customer.email!,
template: 'payment-action-required',
data: {
customerName: customer.name,
actionUrl: session.url,
},
});
return;
}
await sendEmail({
to: customer.email!,
template: 'payment-3ds-required',
data: {
customerName: customer.name,
authUrl,
// 3DS links expire, so create urgency
expiresIn: '24 hours',
},
});
}Stripe's Built-in Dunning vs. Custom
Stripe's built-in dunning (under Billing → Subscriptions → Smart Retries) handles the retry scheduling well. You can configure it to retry 3-4 times over 1-4 weeks before cancelling, and Smart Retries picks the optimal timing. Enable this. It works.
What Stripe doesn't do well: customer-facing communication that matches your brand and tone. Their default emails are plain, don't know your product's name, and can't link to your specific payment update page. So the division of labor is: let Stripe handle retry timing and mechanics, own the email communication yourself.
- Stripe Smart Retries: handles when to retry, uses ML on card data to pick best times
- Your webhook handlers: track state, send branded emails, update your DB
- Stripe Billing Portal: payment method updates (save yourself building this UI)
- Your app middleware: enforce access levels based on subscription status
One thing worth paying for: Stripe Revenue Recovery, their hosted email dunning. If you're early stage and don't want to build all this, it's a reasonable option. The emails are still generic but they handle the whole sequence automatically. We built custom dunning for peal.dev's templates because it gives buyers the right experience, but Revenue Recovery is a legitimate starting point.
Testing Your Dunning Flow
Stripe has test cards specifically for payment failures. Use these in your test environment to verify every step of your dunning sequence actually fires correctly.
// Test cards for dunning scenarios
const TEST_CARDS = {
// Triggers invoice.payment_failed immediately
alwaysDeclines: '4000000000000002',
// Declines with insufficient_funds
insufficientFunds: '4000000000009995',
// Requires 3DS authentication
requires3DS: '4000002760003184',
// Card expired
cardExpired: '4000000000000069',
} as const;
// Manually trigger an invoice payment attempt in test mode
async function testDunningFlow(subscriptionId: string) {
// Retrieve the latest invoice
const invoices = await stripe.invoices.list({
subscription: subscriptionId,
limit: 1,
});
const invoice = invoices.data[0];
// Force a payment attempt (test mode only)
await stripe.invoices.pay(invoice.id, {
forgive: false, // Don't forgive failed payment
});
// Your webhook should fire invoice.payment_failed
// Use Stripe CLI to forward webhooks locally:
// stripe listen --forward-to localhost:3000/api/webhooks/stripe
}Run `stripe listen --forward-to localhost:3000/api/webhooks/stripe` during development. Then use `stripe trigger invoice.payment_failed` to fire test events. This is way faster than creating actual subscriptions with failing cards — though you should do that too for a full integration test.
Metrics to Track
Build a simple dunning dashboard even if it's just a DB query. You want to know: how many subscriptions are currently in dunning, what percentage recover after each retry attempt, and what your average churn rate from payment failures is. If you're seeing >60% of failed payments never recover, your communication is probably too passive or your billing portal link isn't prominent enough.
- Recovery rate: % of past_due subscriptions that eventually return to active
- Attempt distribution: how many payments recover on retry 1 vs 2 vs 3
- Email open rate on dunning emails: if nobody opens them, your subject lines are wrong
- Time to recovery: how long does the average recovery take? Longer = more support load
- Involuntary vs voluntary churn: failed payments vs deliberate cancellations are different problems
If you're building a SaaS on Next.js and want all of this pre-built, our templates at peal.dev include complete Stripe subscription handling with dunning webhooks, email sequences, and the access level middleware already wired together — so you're not building this from scratch at 11pm because a customer emailed asking why their account is locked.
The real ROI of dunning isn't just recovered revenue — it's retained customers. Someone who updates their card and keeps their subscription is worth far more than the single payment you recovered. Treat failed payments as a communication problem, not a billing problem.
Set this up before you have paying customers, not after. The worst time to figure out your dunning logic is when a real customer is frustrated and their access is broken. Get the webhooks wired, the emails written, and the access levels tested in staging. Then when a card inevitably fails at 3am, you're asleep and the system handles it.
