Subscription Lifecycle Management and Dunning for Your SaaS: Recover Revenue You're Silently Losing
You've spent months acquiring customers, optimizing your onboarding, and reducing voluntary churn. But there's a silent revenue killer lurking in your SaaS: involuntary churn. Failed credit cards, expired payment methods, and insufficient funds silently cancel subscriptions — and most SaaS founders don't realize how much money they're leaving on the table.
Studies show that 5–9% of MRR is at risk from involuntary churn. The good news? With proper subscription lifecycle management and a smart dunning strategy, you can recover 50–70% of those failed payments automatically.
Understanding the Subscription Lifecycle
Before diving into dunning, let's map out the full subscription lifecycle. Every subscription in your SaaS moves through distinct phases:
Trial → Active → Past Due → Canceled/Expired
↓ ↓ ↓ ↓
Convert Renew Recover Win-back
Each transition is a critical moment where you either keep or lose revenue. Let's break down the states:
1. Trial Phase
The customer is evaluating your product. Key decisions here:
- Trial length: 7 days for simple tools, 14–30 days for complex B2B SaaS
- Credit card upfront? Increases conversion to paid but reduces trial signups
- Trial expiry handling: Grace period or hard cutoff?
2. Active Phase
The subscription is healthy. But don't ignore it — this is where you:
- Send renewal reminders (especially for annual plans)
- Notify about upcoming price changes
- Pre-validate payment methods before renewal
3. Past Due Phase
Payment failed. This is where dunning kicks in. The clock is ticking.
4. Canceled / Expired
Either the customer chose to leave (voluntary) or their payment couldn't be recovered (involuntary). Both need different strategies.
Modeling Subscriptions in Your Database
Here's a practical schema that supports the full lifecycle:
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
plan_id TEXT NOT NULL REFERENCES plans(id),
status TEXT NOT NULL DEFAULT 'trialing',
-- trialing, active, past_due, canceled, expired, paused
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN DEFAULT false,
canceled_at TIMESTAMPTZ,
cancellation_reason TEXT,
trial_end TIMESTAMPTZ,
dunning_started_at TIMESTAMPTZ,
dunning_attempts INT DEFAULT 0,
last_payment_error TEXT,
payment_method_id TEXT,
stripe_subscription_id TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE INDEX idx_subscriptions_period_end ON subscriptions(current_period_end);
CREATE INDEX idx_subscriptions_dunning ON subscriptions(dunning_started_at)
WHERE status = 'past_due';
The key insight: always track current_period_end separately from payment status. A customer whose payment failed at renewal still has access until their paid period ends. Cutting them off immediately is both legally questionable and a terrible user experience.
Building a Dunning Engine
Dunning is the process of attempting to recover failed payments. Here's how to build one that actually works:
The Dunning Sequence
A proven dunning sequence typically spans 14–28 days:
interface DunningStep {
dayAfterFailure: number;
action: 'retry_payment' | 'send_email' | 'in_app_banner' | 'downgrade' | 'cancel';
emailTemplate?: string;
}
const dunningSequence: DunningStep[] = [
// Day 0: Payment fails
{ dayAfterFailure: 0, action: 'retry_payment' },
{ dayAfterFailure: 0, action: 'send_email', emailTemplate: 'payment_failed_soft' },
{ dayAfterFailure: 0, action: 'in_app_banner' },
// Day 3: First retry
{ dayAfterFailure: 3, action: 'retry_payment' },
{ dayAfterFailure: 3, action: 'send_email', emailTemplate: 'payment_failed_update_card' },
// Day 7: Second retry + urgency
{ dayAfterFailure: 7, action: 'retry_payment' },
{ dayAfterFailure: 7, action: 'send_email', emailTemplate: 'payment_failed_urgent' },
// Day 14: Final attempt
{ dayAfterFailure: 14, action: 'retry_payment' },
{ dayAfterFailure: 14, action: 'send_email', emailTemplate: 'payment_failed_final_warning' },
// Day 21: Downgrade to free (if you have a free tier)
{ dayAfterFailure: 21, action: 'downgrade' },
// Day 28: Cancel subscription
{ dayAfterFailure: 28, action: 'cancel' },
];
Implementing the Dunning Worker
Run this as a scheduled job (cron or queue-based):
async function processDunning() {
const pastDueSubscriptions = await db.subscription.findMany({
where: { status: 'past_due' },
include: { user: true, plan: true },
});
for (const sub of pastDueSubscriptions) {
const daysSinceFailure = differenceInDays(
new Date(),
sub.dunningStartedAt!
);
const pendingSteps = dunningSequence.filter(
step => step.dayAfterFailure === daysSinceFailure
);
for (const step of pendingSteps) {
switch (step.action) {
case 'retry_payment':
await retryPayment(sub);
break;
case 'send_email':
await sendDunningEmail(sub.user, step.emailTemplate!);
break;
case 'in_app_banner':
await setInAppBanner(sub.userId, 'payment_failed');
break;
case 'downgrade':
await downgradeToFree(sub);
break;
case 'cancel':
await cancelSubscription(sub, 'involuntary_churn');
break;
}
}
await db.subscription.update({
where: { id: sub.id },
data: {
dunningAttempts: { increment: pendingSteps.length },
updatedAt: new Date(),
},
});
}
}
Smart Retry Timing
Not all retry times are equal. Payment processors report higher success rates when you:
- Retry on different days of the week — avoid weekends
- Retry at the start of the month — when accounts are more likely to have funds
- Retry in the morning — before daily spending occurs
- Use exponential backoff — don't hammer the payment processor
function getOptimalRetryTime(attempt: number): Date {
const now = new Date();
const baseDelay = [0, 3, 7, 14][attempt] ?? 14;
const retryDate = addDays(now, baseDelay);
// Shift to Tuesday or Wednesday morning if weekend
const day = retryDate.getDay();
if (day === 0) retryDate.setDate(retryDate.getDate() + 2); // Sun → Tue
if (day === 6) retryDate.setDate(retryDate.getDate() + 3); // Sat → Tue
retryDate.setHours(9, 0, 0, 0); // Morning retry
return retryDate;
}
Integrating with Stripe
If you're using Stripe (and you probably should be), leverage their built-in dunning features while adding your own layer on top:
// stripe-webhook-handler.ts
import Stripe from 'stripe';
async function handleInvoicePaymentFailed(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscription = await db.subscription.findFirst({
where: { stripeSubscriptionId: invoice.subscription as string },
});
if (!subscription) return;
// Transition to past_due on first failure
if (subscription.status === 'active') {
await db.subscription.update({
where: { id: subscription.id },
data: {
status: 'past_due',
dunningStartedAt: new Date(),
dunningAttempts: 0,
lastPaymentError: invoice.last_payment_error?.message ?? 'Unknown error',
},
});
await analytics.track('subscription.payment_failed', {
userId: subscription.userId,
planId: subscription.planId,
errorType: invoice.last_payment_error?.type,
amount: invoice.amount_due,
});
}
}
async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscription = await db.subscription.findFirst({
where: { stripeSubscriptionId: invoice.subscription as string },
});
if (!subscription) return;
// Recover from past_due
if (subscription.status === 'past_due') {
await db.subscription.update({
where: { id: subscription.id },
data: {
status: 'active',
dunningStartedAt: null,
dunningAttempts: 0,
lastPaymentError: null,
currentPeriodStart: new Date(invoice.period_start * 1000),
currentPeriodEnd: new Date(invoice.period_end * 1000),
},
});
await sendEmail(subscription.userId, 'payment_recovered');
await clearInAppBanner(subscription.userId, 'payment_failed');
}
}
Stripe Billing Settings to Configure
await stripe.subscriptions.update(subscriptionId, {
payment_settings: {
payment_method_options: {
card: {
request_three_d_secure: 'automatic',
},
},
save_default_payment_method: 'on_subscription',
},
collection_method: 'charge_automatically',
});
Key Stripe settings:
- Smart Retries: Enable in Stripe Dashboard → Billing → Automatic collection. Stripe uses ML to find the optimal retry time.
- Failed payment emails: Let Stripe send basic ones, but supplement with your own.
- Customer portal: Enable self-service card updates at
billing.stripe.com.
Pre-Dunning: Prevention is Better Than Cure
The best dunning strategy is avoiding failed payments altogether:
1. Card Expiry Notifications
// Run weekly: find cards expiring within 30 days
async function checkExpiringCards() {
const customers = await stripe.customers.list({ limit: 100 });
for (const customer of customers.data) {
const pm = await stripe.paymentMethods.list({
customer: customer.id,
type: 'card',
});
for (const method of pm.data) {
const expiry = new Date(
method.card!.exp_year,
method.card!.exp_month - 1
);
const daysUntilExpiry = differenceInDays(expiry, new Date());
if (daysUntilExpiry <= 30 && daysUntilExpiry > 0) {
await sendEmail(customer.metadata.userId, 'card_expiring_soon', {
last4: method.card!.last4,
expiryDate: format(expiry, 'MM/yyyy'),
updateUrl: `${APP_URL}/settings/billing`,
});
}
}
}
}
2. Pre-Renewal Reminders for Annual Plans
Annual customers often forget they have a subscription. Send a reminder 7 days before renewal:
async function sendRenewalReminders() {
const sevenDaysFromNow = addDays(new Date(), 7);
const renewingSoon = await db.subscription.findMany({
where: {
status: 'active',
currentPeriodEnd: {
gte: startOfDay(sevenDaysFromNow),
lte: endOfDay(sevenDaysFromNow),
},
plan: { interval: 'year' },
},
include: { user: true, plan: true },
});
for (const sub of renewingSoon) {
await sendEmail(sub.user.email, 'annual_renewal_reminder', {
planName: sub.plan.name,
renewalDate: format(sub.currentPeriodEnd, 'MMMM d, yyyy'),
amount: formatCurrency(sub.plan.price),
});
}
}
Win-Back Flows: After Cancellation
When a subscription does get canceled (voluntary or involuntary), your job isn't over:
Involuntary Churn Win-Back
const winBackSequence = [
{ daysAfterCancel: 1, template: 'winback_update_payment', offer: null },
{ daysAfterCancel: 7, template: 'winback_we_miss_you', offer: '20_percent_off_3_months' },
{ daysAfterCancel: 30, template: 'winback_final', offer: '30_percent_off_3_months' },
];
Pausing Instead of Canceling
Offer a pause option — it's dramatically more recoverable than a cancellation:
async function pauseSubscription(subscriptionId: string, pauseMonths: number) {
const resumeDate = addMonths(new Date(), pauseMonths);
await stripe.subscriptions.update(subscriptionId, {
pause_collection: {
behavior: 'void',
resumes_at: Math.floor(resumeDate.getTime() / 1000),
},
});
await db.subscription.update({
where: { stripeSubscriptionId: subscriptionId },
data: { status: 'paused' },
});
}
Metrics to Track
Build a dashboard that tracks these dunning-specific metrics:
| Metric | Formula | Target |
|---|---|---|
| Involuntary churn rate | Lost MRR from failed payments / Total MRR | < 1% monthly |
| Recovery rate | Recovered payments / Total failed payments | > 60% |
| Average recovery time | Mean days from failure to recovery | < 7 days |
| Dunning email open rate | Opens / Sent dunning emails | > 40% |
| Card update rate | Cards updated after notification / Notifications sent | > 25% |
Common Pitfalls
-
Cutting access immediately on payment failure — Always provide a grace period. Locking customers out breeds resentment, not payments.
-
Generic dunning emails — Personalize them. Include the plan name, what they'll lose access to, and a one-click link to update payment.
-
Not tracking involuntary vs. voluntary churn separately — They need completely different solutions. Mixing them hides the real problem.
-
Ignoring timezone in retry logic — A retry at 3 AM in the customer's timezone is wasted. Always localize.
-
No self-service payment update — If updating a card requires contacting support, you've already lost.
Conclusion
Subscription lifecycle management isn't glamorous, but it's one of the highest-ROI investments you can make in your SaaS. A well-built dunning system recovers revenue that would otherwise silently disappear — and it works 24/7 without any manual intervention.
Start with the basics: handle Stripe webhooks properly, send clear dunning emails, and offer easy card updates. Then layer on smart retry timing, pre-dunning prevention, and win-back flows. Your MRR will thank you.
The difference between a SaaS that grows and one that stalls often isn't acquisition — it's retention. And a huge chunk of retention is simply making sure payments go through.