SaaS Dunning Management: Failed Payment Recovery, Retry Schedules, and Grace Periods
Build a production SaaS dunning system: implement smart payment retry schedules with Stripe, design grace periods that recover revenue without losing customers, send recovery email sequences, and measure dunning effectiveness.
Involuntary churn โ customers who want to keep paying but whose cards fail โ accounts for 20โ40% of SaaS churn. Most of it is recoverable. The difference between 2% involuntary churn and 8% is the quality of your dunning system: retry logic, grace periods, and recovery email sequences.
Stripe's Smart Retries handle the low-level retry scheduling. Your job is to build the grace period logic, recovery email sequences, and the account access controls that keep customers around long enough for the payment to succeed.
The Dunning State Machine
ACTIVE โโโบ PAST_DUE โโโบ GRACE_PERIOD โโโบ PAUSED โโโบ CANCELLED
โ โ
โโโโโ payment succeeds โโโโโโโโโ
(back to ACTIVE)
PAST_DUE: Invoice failed. Stripe retrying. Full access maintained.
GRACE_PERIOD: Stripe retries exhausted. Your dunning period begins.
Days 1-3: Full access, daily reminders
Days 4-7: Limited access, urgent reminders
Day 8+: Access paused, final notice
PAUSED: Account disabled. Data preserved. Recovery still possible.
CANCELLED: After 30 days paused, auto-cancel if no recovery.
Stripe Webhook Handler
// src/webhooks/stripe.handler.ts
import Stripe from "stripe";
import { dunningService } from "../services/dunning.service";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function handleStripeWebhook(
rawBody: Buffer,
signature: string
): Promise<void> {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
throw new Error("Invalid webhook signature");
}
switch (event.type) {
// Invoice payment failed โ begins the dunning process
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const subscription = invoice.subscription as string;
// Only handle subscription invoices, not one-off charges
if (!subscription) break;
// Billing reason: 'subscription_cycle' (renewal) or 'subscription_create' (new)
if (invoice.billing_reason === "subscription_cycle") {
await dunningService.onPaymentFailed({
stripeSubscriptionId: subscription,
stripeInvoiceId: invoice.id,
amountDue: invoice.amount_due,
currency: invoice.currency,
attemptCount: invoice.attempt_count ?? 1,
nextAttemptAt: invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000)
: null,
});
}
break;
}
// Payment succeeded โ exit dunning state
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription && invoice.billing_reason === "subscription_cycle") {
await dunningService.onPaymentSucceeded({
stripeSubscriptionId: invoice.subscription as string,
stripeInvoiceId: invoice.id,
});
}
break;
}
// Subscription status changed by Stripe (e.g., past_due โ unpaid)
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
await dunningService.syncSubscriptionStatus(sub.id, sub.status);
break;
}
// Customer updated payment method โ retry immediately
case "payment_method.attached": {
const pm = event.data.object as Stripe.PaymentMethod;
if (pm.customer) {
await dunningService.onPaymentMethodUpdated(pm.customer as string);
}
break;
}
}
}
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Dunning Service
// src/services/dunning.service.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Dunning state stored in your database
interface DunningState {
tenantId: string;
subscriptionId: string;
status: "active" | "past_due" | "grace_period" | "paused" | "cancelled";
failedAt: Date | null;
gracePeriodEndsAt: Date | null;
pausedAt: Date | null;
cancelAt: Date | null;
lastReminderSentAt: Date | null;
reminderCount: number;
}
class DunningService {
async onPaymentFailed(params: {
stripeSubscriptionId: string;
stripeInvoiceId: string;
amountDue: number;
currency: string;
attemptCount: number;
nextAttemptAt: Date | null;
}): Promise<void> {
const tenant = await this.getTenantBySubscription(params.stripeSubscriptionId);
if (!tenant) return;
const isFirstFailure = params.attemptCount === 1;
if (isFirstFailure) {
// First failure: go to PAST_DUE
// Stripe Smart Retries will attempt up to 4 times over ~2 weeks
await this.updateDunningState(tenant.id, {
status: "past_due",
failedAt: new Date(),
});
await this.sendRecoveryEmail(tenant, "payment_failed_initial", {
amountDue: params.amountDue / 100, // Stripe uses cents
currency: params.currency.toUpperCase(),
nextAttemptAt: params.nextAttemptAt,
updatePaymentUrl: await this.generateUpdatePaymentUrl(params.stripeSubscriptionId),
});
} else {
// Subsequent failure during Stripe's retry window
await this.sendRecoveryEmail(tenant, "payment_failed_retry", {
attemptCount: params.attemptCount,
amountDue: params.amountDue / 100,
updatePaymentUrl: await this.generateUpdatePaymentUrl(params.stripeSubscriptionId),
});
}
}
async syncSubscriptionStatus(
stripeSubId: string,
stripeStatus: string
): Promise<void> {
const tenant = await this.getTenantBySubscription(stripeSubId);
if (!tenant) return;
// Stripe marks subscription 'unpaid' when all retries exhausted
if (stripeStatus === "unpaid") {
const gracePeriodEndsAt = new Date();
gracePeriodEndsAt.setDate(gracePeriodEndsAt.getDate() + 7); // 7-day grace period
await this.updateDunningState(tenant.id, {
status: "grace_period",
gracePeriodEndsAt,
});
await this.sendRecoveryEmail(tenant, "grace_period_started", {
gracePeriodEndsAt,
updatePaymentUrl: await this.generateUpdatePaymentUrl(stripeSubId),
});
// Schedule access restriction for day 4 of grace period
await this.scheduleAccessRestriction(tenant.id, 4);
// Schedule account pause for day 8
await this.scheduleAccountPause(tenant.id, 8);
}
}
async onPaymentSucceeded(params: {
stripeSubscriptionId: string;
stripeInvoiceId: string;
}): Promise<void> {
const tenant = await this.getTenantBySubscription(params.stripeSubscriptionId);
if (!tenant) return;
const wasInDunning = await this.isInDunning(tenant.id);
// Restore full access immediately
await this.updateDunningState(tenant.id, {
status: "active",
failedAt: null,
gracePeriodEndsAt: null,
pausedAt: null,
cancelAt: null,
});
await this.restoreFullAccess(tenant.id);
if (wasInDunning) {
await this.sendRecoveryEmail(tenant, "payment_recovered", {});
}
}
async onPaymentMethodUpdated(stripeCustomerId: string): Promise<void> {
// Customer updated their card โ immediately retry the outstanding invoice
const tenant = await this.getTenantByStripeCustomer(stripeCustomerId);
if (!tenant) return;
const dunning = await this.getDunningState(tenant.id);
if (!dunning || dunning.status === "active") return;
// Find the open invoice and retry it
const invoices = await stripe.invoices.list({
customer: stripeCustomerId,
status: "open",
limit: 1,
});
if (invoices.data.length > 0) {
try {
await stripe.invoices.pay(invoices.data[0].id);
// Payment succeeded webhook will restore access
} catch (error) {
// Payment still failed โ dunning continues
console.warn("Immediate retry after payment method update failed:", error);
}
}
}
// Generate a Stripe Customer Portal link for payment method update
private async generateUpdatePaymentUrl(stripeSubId: string): Promise<string> {
const subscription = await stripe.subscriptions.retrieve(stripeSubId);
const session = await stripe.billingPortal.sessions.create({
customer: subscription.customer as string,
return_url: `${process.env.APP_URL}/billing`,
flow_data: {
type: "payment_method_update",
},
});
return session.url;
}
}
export const dunningService = new DunningService();
Access Control During Grace Period
// src/middleware/dunning-gate.middleware.ts
// Check dunning state on every authenticated request
export async function dunningGateMiddleware(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const { tenantId } = req.auth;
const dunning = await getDunningState(tenantId);
if (!dunning || dunning.status === "active") {
return next(); // Full access
}
if (dunning.status === "paused" || dunning.status === "cancelled") {
res.status(402).json({
error: "ACCOUNT_PAUSED",
message: "Your account is paused due to a failed payment.",
updatePaymentUrl: `/billing/update-payment`,
});
return;
}
if (dunning.status === "grace_period") {
// Calculate how far into grace period
const daysSincePastDue = dunning.failedAt
? Math.floor((Date.now() - dunning.failedAt.getTime()) / (1000 * 86400))
: 0;
// Days 1-3: full access with warning header
if (daysSincePastDue <= 3) {
res.setHeader("X-Payment-Status", "grace-period");
res.setHeader("X-Grace-Period-Days-Remaining",
Math.max(0, 7 - daysSincePastDue).toString()
);
return next(); // Full access
}
// Days 4-7: read-only access
if (req.method !== "GET" && req.method !== "HEAD") {
res.status(402).json({
error: "PAYMENT_REQUIRED",
message: `Your account is in a payment grace period. Write access is restricted. ${7 - daysSincePastDue} days remaining.`,
updatePaymentUrl: `/billing/update-payment`,
});
return;
}
return next();
}
next();
}
๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
Recovery Email Sequence
Day 0 (first failure): "We couldn't charge your card"
Subject: "Action needed: Payment failed for your [Plan] subscription"
CTA: Update payment method
Tone: Informational, not urgent
Day 3 (retry failed): "Second payment attempt failed"
Subject: "Still having trouble with your payment"
CTA: Update payment method
Tone: Slightly urgent
Day 7 (grace period): "Your account is on hold"
Subject: "Your account access will be paused in 24 hours"
CTA: Update payment method (prominent)
Tone: Urgent โ specific deadline
Day 8 (paused): "Your account is paused"
Subject: "Your [Product] account is paused"
CTA: Reactivate account
Tone: Clear, non-punitive, easy recovery path
Day 30 (final): "Your account will be deleted in 7 days"
Subject: "Final notice: account deletion scheduled"
CTA: Reactivate or export data
Tone: Clear deadline, data preservation emphasized
Dunning Recovery Metrics
-- Dunning recovery rate by attempt count
SELECT
max_attempt_count,
COUNT(*) AS subscriptions_entered_dunning,
COUNT(*) FILTER (WHERE recovered) AS recovered,
ROUND(100.0 * COUNT(*) FILTER (WHERE recovered) / COUNT(*), 1) AS recovery_rate_pct,
ROUND(AVG(days_to_recover) FILTER (WHERE recovered), 1) AS avg_days_to_recover
FROM (
SELECT
d.tenant_id,
MAX(d.attempt_count) AS max_attempt_count,
BOOL_OR(d.status = 'active') AS recovered,
EXTRACT(DAY FROM (
MIN(d.recovered_at) - MIN(d.failed_at)
)) AS days_to_recover
FROM dunning_events d
WHERE d.failed_at >= NOW() - INTERVAL '90 days'
GROUP BY d.tenant_id
) sub
GROUP BY max_attempt_count
ORDER BY max_attempt_count;
-- Recovery rate by trigger (what caused recovery)
SELECT
recovery_trigger, -- 'card_update', 'smart_retry', 'manual_admin'
COUNT(*) AS recoveries,
ROUND(AVG(days_to_recover), 1) AS avg_days
FROM dunning_recoveries
WHERE recovered_at >= NOW() - INTERVAL '90 days'
GROUP BY recovery_trigger
ORDER BY recoveries DESC;
See Also
- Stripe Billing Engineering โ subscription setup
- SaaS Revenue Recognition โ MRR impact of churn
- SaaS Churn Prediction โ proactive churn prevention
- Stripe Connect Marketplace โ marketplace billing
Working With Viprasol
Involuntary churn recovery is one of the highest-ROI engineering investments a SaaS company can make. We implement dunning systems with smart retry schedules, grace period access controls, recovery email sequences, and analytics that show exactly which intervention recovers the most revenue.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.