Dunning Email Sequences for Churn Reduction
Involuntary churn — subscriptions lost to failed payments rather than deliberate cancellation — is recoverable revenue, and a well-timed dunning email sequence is the cheapest recovery channel you have. The engineering problem is coordinating payment-gateway webhooks, a delayed job queue, and a transactional email provider so that customers receive timely, non-annoying prompts that stop the instant they pay. This guide is part of Grace Period & Retry Logic, and the decision you face is how aggressively to email without burning deliverability or fatiguing customers whose card simply needs a tap to update.
You hit this problem the moment automated retries alone stop recovering enough revenue and you need the customer to take action — update an expired card, clear a fraud hold, or top up a balance. The sequence below is decline-aware, backoff-scheduled, and recovery-guarded.
Trade-offs
| Approach | Recovery lift | Build effort | Churn-fatigue risk | Deliverability control |
|---|---|---|---|---|
| Gateway built-in emails (Stripe Smart Retries) | Baseline | None | Low (Stripe-tuned) | None — Stripe sends |
| Single “payment failed” blast | Low | Hours | Low | Full |
| 3-stage backoff sequence | Medium-high | Days | Medium | Full |
| Decline-code-routed multi-track | Highest | 1–2 weeks | Low if tuned | Full |
| Send-time-optimized + timezone-aware | Highest | Weeks | Lowest | Full |
A 3-stage backoff sequence (day 1, day 3, day 7) is the pragmatic default for most SaaS. Move to decline-code routing once you can measure recovery per stage — an expired_card customer needs a different message and link than an insufficient_funds one. Keep the gateway’s built-in emails off when you run your own, or customers get duplicates.
Step-by-Step Implementation
1. Trigger the state machine from an idempotent webhook
Process invoice.payment_failed exactly once and initialize the dunning state. Verify the signature before doing anything.
async function onPaymentFailed(payload: Stripe.Event) {
const { id: webhookId } = payload;
const invoice = payload.data.object as Stripe.Invoice;
const existing = await db.webhookLog.findUnique({ where: { webhookId } });
if (existing) return { status: 'DUPLICATE_IGNORED' }; // ✅ idempotent
await db.$transaction(async (tx) => {
await tx.webhookLog.create({ data: { webhookId, processedAt: new Date() } });
await tx.dunningState.create({
data: {
subscriptionId: invoice.subscription as string,
declineCode: invoice.last_payment_error?.decline_code ?? 'unknown',
stage: 'STAGE_1',
nextSendAt: new Date(Date.now() + 60_000), // first email shortly after failure
},
});
});
}
2. Schedule emails with backoff and timezone awareness
Cap backoff at the grace window and never send at 3 AM local time.
const backoffMs = (stage: number) =>
Math.min(Math.pow(2, stage) * 86_400_000, 168 * 3_600_000); // cap at 7 days
await queue.add(
'dunning-email',
{ subscriptionId, stage, templateId: declineTemplate(declineCode), tz: customer.tzOffset },
{ delay: backoffMs(stage), attempts: 3, backoff: { type: 'exponential', delay: 30_000 } }
);
3. Route messaging by decline code and customer value
Different declines need different asks. Map codes to templates; prioritize high-LTV accounts. The code taxonomy is in Smart Retry Timing With Card Issuer Decline Codes.
function declineTemplate(code: string): string {
switch (code) {
case 'expired_card': return 'dunning_update_card'; // ask to replace card
case 'insufficient_funds': return 'dunning_retry_soon'; // we will retry, FYI
case 'do_not_honor': return 'dunning_contact_bank'; // ⚠️ customer must call issuer
default: return 'dunning_generic';
}
}
4. Guard dispatch against mid-sequence recovery
Before sending, re-check the subscription is still failing. A customer who paid via the portal must not receive a “your payment failed” email.
async function dispatchDunningEmail(job: DunningJob) {
const sub = await db.subscription.findUnique({ where: { id: job.subscriptionId } });
if (sub?.status === 'active') {
await db.dunningState.deleteMany({ where: { subscriptionId: job.subscriptionId } });
return; // ✅ recovered — purge sequence, send nothing
}
await emailProvider.send(job.templateId, sub);
}
5. Instrument per-stage recovery and tune cadence
Measure where customers stall so you can move or cut a stage.
SELECT stage,
COUNT(*) AS dispatched,
SUM(CASE WHEN status = 'RECOVERED' THEN 1 ELSE 0 END)::float
/ NULLIF(COUNT(*), 0) AS recovery_rate
FROM dunning_states
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY stage
ORDER BY stage;
Verification & Testing
Replay an invoice.payment_failed webhook twice and assert a single dunning sequence is created. Mark a subscription active mid-sequence and assert the next scheduled email is suppressed and the state purged. Use a mock clock to confirm stage 2 fires at the backoff interval, not before, and that no email is scheduled inside the customer’s quiet hours. Feed an expired_card decline and assert the update-card template is selected; feed do_not_honor and assert the contact-bank template. Verify SMTP hard bounces auto-suppress the address and pause the sequence rather than retrying into a dead inbox.
Gotchas & Production Pitfalls
- Duplicate emails from gateway + custom sender. If Stripe Smart Retries emails are still enabled while you run your own sequence, customers get two “payment failed” notices. Disable the gateway’s dunning emails when you own the flow.
- Sending after recovery. Without a pre-dispatch status check, a customer who already updated their card gets a dunning email anyway — the fastest way to make a recovered customer churn. Always re-read subscription state at send time.
- Timezone-naive scheduling. Normalizing to UTC for storage is correct, but dispatching at UTC midnight means 3 AM emails for half your customers. Apply a
preferred_send_windowand convert at send time. - Deliverability collapse from SPF/DKIM drift. A misaligned sending domain silently drops dunning mail, and silent failure here looks like high involuntary churn. Monitor bounce categories and authentication alignment.
- Cadence that ignores the grace window. Emails scheduled past the suspension boundary arrive after access is already revoked. Align the last email with the final retry inside the grace period defined in Grace Period & Retry Logic.