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

The dunning approaches range from the gateway’s built-in emails (no control) to a decline-code-routed, send-time-optimized sequence (full control, highest recovery). A 3-stage backoff is the pragmatic default. The map ranks them.

Dunning email approaches Gateway emails give no control, a single blast is low recovery, a 3-stage backoff is the default, and decline-routed send-time-optimized is the highest recovery. Gateway emails no control Stripe sends zero build Single blast low recovery one email hours 3-stage backoff day 1/3/7 medium-high the default Code-routed multi-track highest recovery weeks
A 3-stage backoff is the pragmatic default; move to code-routing once you can measure recovery per stage.
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.

Weighing recovery lift against sender reputation

The recovery-lift column hides a nonlinear cost: each additional stage you add spends sender reputation whether or not it recovers revenue. A mailbox provider like Gmail scores your sending domain on engagement, and dunning mail is structurally low-engagement — a customer whose card expired may never open the message even though they fully intend to pay. If your third and fourth stages push open rates below roughly 10 percent on a shared IP, you risk dragging all your transactional mail — receipts, password resets, invoice PDFs — toward the spam folder. This is why the send-time-optimized row sits at “lowest” fatigue risk but “weeks” of build effort: the extra engineering buys you fewer, better-timed sends rather than more sends. Treat every stage as a withdrawal from a reputation account that only engagement refills.

For a subscription priced at 2,900 minor units per month, the arithmetic is stark. If a 3-stage sequence recovers 40 percent of failed invoices and a code-routed multi-track sequence recovers 48 percent, that extra eight points is worth 232 minor units per failed invoice — real money at scale, but only if the incremental sends do not degrade the reputation your day-1 email depends on. Model the marginal stage, not the whole funnel: the first email typically recovers the majority of everything you will ever recover, and stages three onward fight over a shrinking remainder of genuinely hard declines. When you compare the last two rows in the table, you are really comparing whether an engineering week is better spent adding a track or optimizing the send instant of the tracks you already have; past a certain volume the second usually wins because it lifts every existing send at once instead of bolting on a low-yield fourth touch.

Step-by-Step Implementation

The five steps trigger the sequence from an idempotent webhook, schedule with timezone-aware backoff, route by decline code, guard against mid-sequence recovery, then instrument. The recovery guard is the pivotal step — the diagram shows how a payment mid-sequence purges the remaining emails.

Dunning recovery guard A three-stage email sequence checks subscription status before each send; a payment mid-sequence purges the remaining emails so a recovered customer gets no dunning mail. Stage 1 day 1 Stage 2 day 3 Stage 3 day 7 Recovered → purge send nothing status check before every send
Each stage re-checks status before sending — a payment mid-sequence purges the rest, so a recovered customer never gets dunned.

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
      },
    });
  });
}

The webhookLog uniqueness check and the dunningState insert must live in the same transaction, or a crash between them leaves you having acknowledged the webhook without arming the sequence — a silent hole where a genuinely failed payment never gets dunned. Stripe redelivers on any non-2xx response, so returning early on a duplicate is safe, but only because the log row and the state row commit atomically. Note the deliberate 60-second first delay rather than an immediate send: gateways occasionally emit invoice.payment_failed moments before a synchronous retry succeeds, and the short buffer lets the recovery guard in step four catch that race before the customer ever sees mail. Persist invoice.id on the state row as well — you will need it to reconcile against the later invoice.paid event and to attribute recovered revenue back to the exact sequence, keyed by subscription_id, that prompted the payment.

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 } }
);

The Math.min cap matters more than the exponent. Uncapped, Math.pow(2, stage) days would place a fifth email 16 days out, long after the grace window has revoked access and the subscription has moved to canceled — you would be emailing someone who is no longer a customer. Clamping at 168 hours keeps the whole sequence inside a 7-day grace period. The tz: customer.tzOffset payload field is what lets the worker recompute the concrete send instant at dispatch time rather than freezing it at enqueue time; a customer who travels, or whose stored offset is corrected mid-sequence, then still receives a civilized send between 9 AM and 6 PM local. Keep the queue’s own retry backoff — the inner exponential 30-second one — conceptually separate from the dunning backoff: the former recovers from a flaky SMTP call, the latter paces the customer-visible cadence. Conflating the two is exactly how teams accidentally deliver stage two twice to the same subscription_id.

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';
  }
}

Routing on decline_code alone is coarser than it looks, because issuers are inconsistent about which field they populate. A do_not_honor frequently arrives as a generic card_declined at the top-level code, with the useful detail buried in the network’s raw response, so fall back to invoice.last_payment_error?.code and then the network advice code before dropping to dunning_generic. The customer-value dimension is orthogonal to the decline reason: a 50-seat annual contract worth 480,000 minor units deserves a human follow-up from an account manager, not template three, so branch high-value customer_ids out of the automated track entirely and into a task queue for the retention team. Resist the temptation to tell an insufficient_funds customer to “update your card” — their card is fine, the charge will often clear on the next payday-aligned retry, and a demand to re-enter correct details reads as a broken system and erodes trust exactly when you need it.

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);
}

The read-then-send in dispatchDunningEmail is a classic time-of-check-to-time-of-use window: the subscription can flip to active in the milliseconds between the findUnique and the emailProvider.send. You cannot close that gap entirely across two systems, but you can shrink it by re-checking as late as possible and making the provider call the very next statement. For the rarer inverse — a webhook-driven status update that lands just after your check — accept that an occasional stray send is tolerable and write the copy so a recovered reader is merely reassured (“if you have already updated your card, no action is needed”) rather than alarmed. Deleting the entire dunningState on recovery, rather than flipping a status flag, also guarantees that any already-queued later stages find no state to act on and no-op cleanly, which keeps the purge idempotent even under concurrent workers processing the same subscription_id.

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;

Read the recovery_rate per stage as a diagnostic, not a scoreboard. A stage-one rate near 55 percent with stage two collapsing to 8 percent tells you the easy wins are front-loaded and stage three is probably pure fatigue you can cut. The opposite shape — a weak stage one but a strong stage three — usually means your first template is landing in spam and only the customers who eventually check a neglected folder recover; that is a deliverability problem masquerading as a cadence problem, and the fix lives in your authentication alignment, not your copy. Segment this query by decline_code as well, because a blended rate hides that expired_card recovers at two to three times the rate of do_not_honor: the latter needs the customer to phone their issuer, which most never will, so no volume of extra email will move it and the honest response is to route those accounts to a human or write them off sooner.

Verification & Testing

The tests prove idempotent sequence creation, mid-sequence suppression on recovery, timezone-safe timing, and template routing by code. The panel lists them before the detail.

Dunning email tests A replayed webhook creates one sequence, a recovered subscription suppresses the next email, timing respects quiet hours, and templates route by decline code. Idempotent webhook twice one sequence Recovery active mid-seq suppress + purge Timing mock clock no quiet hours Routing expired → update correct template
The recovery-suppression test matters most — dunning a customer who already paid is the fastest way to churn them.

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

The pitfalls cluster around duplication, sending after recovery, timezone-naive scheduling, deliverability drift, and cadence past the grace window. The map groups them so each fix is one rule.

Dunning email pitfalls Duplicate senders, sending after recovery, timezone-naive scheduling, SPF/DKIM drift, and cadence past the grace window are the recurring pitfalls. Duplicate gateway + custom → disable gateway After recovery no status check → re-read at send Timezone 3 AM emails → send window Deliverability SPF/DKIM drift → monitor auth Cadence past grace → align window
Five pitfalls — sending after recovery and silent deliverability drift are the two that quietly inflate involuntary churn.
  • 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.

Frequently Asked Questions

How many emails is the right number? Three to five across the retry window, each with a distinct purpose: notify, remind, warn, and a final notice. Beyond that, additional messages mostly annoy customers who were never going to pay.

Should dunning emails come from billing or from support? From a monitored address that a reply reaches a human. A no-reply dunning email tells a customer with a genuine problem that you do not want to hear about it.

Does adding urgency help? Specific dates help; artificial urgency does not. “Your access ends on 14 April” outperforms “act now”, and it is also truthful, which matters because the date is enforceable.

Should the emails mention the amount? Yes, along with the last four digits of the failing card and a direct update link. Customers frequently have several cards and cannot act without knowing which one failed.