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_window and 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.