Back to all articles
DunningAugust 28, 20265 min read

How to Build a Stripe Dunning Engine That Recovers Failed Payments (2026 Guide)

Webhook Engineer · Stripe & Dunning Specialist

# The Dunning Bible

A field guide for building a payment recovery engine that actually works

You built the product. You found the customers. You set up Stripe. And then someone's card expired.

That's when the money starts leaking.

A dunning engine is just a polite robot that knocks on your customer's door when their payment fails. It asks them to fix things. It retries the charge. It knows when to push and when to back off.

Get it right, and you keep revenue. You keep customers.

Get it wrong, and you annoy people. You cancel subscriptions that didn't need to die. You lose money you could have kept.

This is for builders, not theorists. Webhooks. State machines. Retry logic. Security holes. The emails that actually get opened. Let's get into it.

---

1. What dunning actually is

Stripe calls it "dunning management." Accountants call it accounts receivable. Customers call it "that email about my card."

They're all describing the same conversation: your app says *we tried to charge you and it didn't work — can you help?* The customer answers yes or no. Your app listens, then acts.

Most teams build this like an error handler: something broke, fire off an email, move on. That's the wrong model. A failed payment isn't an error — it's a state the customer is temporarily sitting in. They still want the product. Their card just hit a wall.

Run the coffee shop test. A regular walks in with an empty wallet. You don't throw them out — you point them to the ATM, or tell them to settle up next time. Dunning is the ATM. It's not a bouncer.

2. Set your principles before you write code

A dunning engine isn't only logic — it's a handful of values, encoded as conditionals. Write them down before you touch a database schema.

interface DunningPhilosophy {
  principle: string;
  explanation: string;
}

const dunningPrinciples: DunningPhilosophy[] = [
  {
    principle: "Assume good faith",
    explanation: "Nobody fails a payment on purpose. Treat every case that way."
  },
  {
    principle: "Escalate slowly",
    explanation: "Start gentle. Only get urgent once patience has been tried."
  },
  {
    principle: "Meet them where they are",
    explanation: "Email, SMS, in-app — pick the channel that gets seen."
  },
  {
    principle: "One action per message",
    explanation: "Every touchpoint should have a single, obvious next step."
  },
  {
    principle: "Instrument everything",
    explanation: "Nothing gets improved if nobody's measuring it."
  },
  {
    principle: "Let people leave well",
    explanation: "When it doesn't work out, make the exit dignified. They may be back."
  }
];

That last one is the one people skip. "Let people leave well" has nothing to do with try/catch blocks — it's about a cancellation flow that doesn't make anyone feel like a criminal.

Come back to this list whenever you're unsure about a feature. Does it assume good faith? Does it escalate slowly? Does it give one clear action? If not, don't ship it.

3. Two kinds of decline, two very different responses

Stripe attaches a `decline_code` to every failed charge, and it splits cleanly into two buckets.

Soft declines are temporary: insufficient_funds, do_not_honor, processing_error, issuer_not_available, try_again_later. The card is fine — the bank is just saying "not today." Payday might be Friday. The bank might have flagged a recurring charge. Give these patience: retry in a day, then a few more days. The money often shows up on its own.

Hard declines are permanent until the customer steps in: expired_card, incorrect_cvc, lost_card, stolen_card, fraudulent. Retrying does nothing here. You need a human to type in new digits.

Your engine has to tell these apart and route accordingly — soft declines into the retry queue, hard declines straight into an "update your card" email. A rough rule that holds up: if the code mentions "card," "cvc," or "fraud," treat it as hard. Everything else starts soft.

4. Keep the data model small

Four tables. Not forty.

Profiles — one row per customer: email, name, Stripe customer ID, plan. Your phone book.

Subscriptions — one row per subscription: Stripe subscription ID, status, current period. Your ledger.

Invoices — one row per invoice: Stripe invoice ID, amount due, amount paid, status, attempt count, next attempt. Your source of truth for money.

Payment failures — one row per failure event. This is where dunning state actually lives: decline code, decline type, dunning step, next retry time, payment status, resolution outcome, email flow status.

Invoices and failures stay separate because one invoice can fail more than once, and because the invoice is Stripe's record while the failure is your app's narrative of what happened to it. Link them. Don't merge them.

Add a retry-schedule table too — your playbook in row form. Step 0: silent wait. Step 1: soft nudge after a day. Step 2: firmer email after three days. Step 3: winback after a week. Step 4: cancel at two weeks.

Make it a table, not hardcoded conditionals. The day your founder asks "can we wait two days instead of one," a table means changing one row. Code means a deploy.

5. The webhook handler

Stripe pushes events at your app. You need an endpoint that catches them, verifies them, and acts.

The flow: Stripe fires invoice.payment_failed. Your app checks the signature, parses the payload, finds the customer, writes or updates the payment-failure row, schedules the next retry, and kicks off the right email.

Then it returns 200. Fast — Stripe doesn't wait around for your database transaction, it wants an acknowledgment. Take too long and it retries the delivery, which means duplicate events, duplicate emails, and a confused customer.

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function POST(req: NextRequest) {
  const payload = await req.text();
  const sig = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      payload,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err: any) {
    console.error('Webhook signature verification failed.', err.message);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Acknowledge immediately
  const response = NextResponse.json({ received: true });

  // Process asynchronously
  processEvent(event).catch(console.error);

  return response;
}

async function processEvent(event: Stripe.Event) {
  const { data: existing } = await supabase
    .from('stripe_webhook_events')
    .select('id')
    .eq('stripe_event_id', event.id)
    .single();

  if (existing) {
    console.log(`Event ${event.id} already processed. Skipping.`);
    return;
  }

  await supabase.from('stripe_webhook_events').insert({
    stripe_event_id: event.id,
    event_type: event.type,
    payload: event.data.object as any,
    processed_at: new Date().toISOString(),
  });

  if (event.type === 'invoice.payment_failed') {
    await handlePaymentFailed(event.data.object as Stripe.Invoice);
  }

  if (event.type === 'invoice.payment_succeeded') {
    await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
  }

  if (event.type === 'customer.subscription.deleted') {
    await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
  }
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;
  const subscriptionId = invoice.subscription as string;
  const charge = invoice.charge ? await stripe.charges.retrieve(invoice.charge as string) : null;
  const declineCode = charge?.outcome?.type === 'blocked' ? 'blocked' : charge?.decline_code || 'unknown';

  const hardCodes = ['expired_card', 'incorrect_cvc', 'incorrect_number', 'lost_card', 'stolen_card', 'fraudulent'];
  const declineType = hardCodes.includes(declineCode) ? 'hard' : 'soft';

  const { data: schedule } = await supabase
    .from('retry_schedule')
    .select('*')
    .eq('step', 0)
    .single();

  const now = new Date();
  const nextRetry = schedule?.days_after_failure
    ? new Date(now.getTime() + schedule.days_after_failure * 24 * 60 * 60 * 1000)
    : new Date(now.getTime() + 24 * 60 * 60 * 1000);

  const { error } = await supabase.from('payment_failures').upsert({
    stripe_invoice_id: invoice.id,
    stripe_charge_id: invoice.charge as string,
    stripe_customer_id: customerId,
    stripe_subscription_id: subscriptionId,
    amount_cents: invoice.amount_due,
    currency: invoice.currency,
    decline_code: declineCode,
    attempt_count: invoice.attempt_count || 1,
    dunning_step: 0,
    dunning_started_at: now.toISOString(),
    next_retry_at: nextRetry.toISOString(),
    grace_period_ends_at: new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
    payment_status: 'open',
    resolution_outcome: 'pending',
    klaviyo_flow_status: 'not_sent',
  }, {
    onConflict: 'stripe_invoice_id',
  });

  if (error) {
    console.error('Failed to record payment failure:', error);
    return;
  }

  if (declineType === 'hard' && schedule?.klaviyo_event_name) {
    await triggerKlaviyoEvent(customerId, schedule.klaviyo_event_name, {
      amount_due: invoice.amount_due / 100,
      invoice_url: invoice.hosted_invoice_url,
    });
  }
}

async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
  const { data: failures } = await supabase
    .from('payment_failures')
    .select('*')
    .eq('stripe_invoice_id', invoice.id)
    .in('payment_status', ['open', 'retry_scheduled']);

  if (!failures || failures.length === 0) return;

  for (const failure of failures) {
    await supabase.from('payment_failures').update({
      payment_status: 'recovered',
      resolution_outcome: 'auto_retry',
      resolved_at: new Date().toISOString(),
    }).eq('id', failure.id);
  }

  await suppressKlaviyoFlow(invoice.customer as string);
}

async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
  await supabase.from('subscriptions').update({
    status: 'canceled',
    canceled_at: new Date().toISOString(),
  }).eq('stripe_subscription_id', sub.id);
}

async function triggerKlaviyoEvent(customerId: string, eventName: string, properties: any) {
  console.log(`Triggering ${eventName} for ${customerId}`);
}

async function suppressKlaviyoFlow(customerId: string) {
  console.log(`Suppressing flows for ${customerId}`);
}

Notice the shape: acknowledge first, verify the signature, check for duplicates, log everything, then act. Resist the urge to get clever with it.

6. The retry scheduler

Webhooks handle the opening move. A scheduled job handles everything after.

Run an hourly job that finds payment failures where next_retry_at has passed and payment_status is still open or retry_scheduled. For each one: advance the dunning step, check the retry schedule, and decide whether to retry the charge, send an email, or cancel the subscription.

// app/api/cron/dunning/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

// Only your cron service should be able to hit this.
export async function GET(req: NextRequest) {
  const authHeader = req.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { data: failures } = await supabase
    .from('payment_failures')
    .select('*, profiles(email, first_name), retry_schedule(*)')
    .in('payment_status', ['open', 'retry_scheduled'])
    .lte('next_retry_at', new Date().toISOString());

  if (!failures || failures.length === 0) {
    return NextResponse.json({ processed: 0 });
  }

  let processed = 0;

  for (const failure of failures) {
    try {
      await processFailure(failure);
      processed++;
    } catch (err) {
      console.error(`Failed to process ${failure.id}:`, err);
    }
  }

  return NextResponse.json({ processed });
}

async function processFailure(failure: any) {
  const nextStep = failure.dunning_step + 1;

  const { data: schedule } = await supabase
    .from('retry_schedule')
    .select('*')
    .eq('step', nextStep)
    .single();

  if (!schedule) {
    await cancelSubscription(failure);
    return;
  }

  // The customer may have already fixed this themselves.
  const invoice = await stripe.invoices.retrieve(failure.stripe_invoice_id);
  if (invoice.status === 'paid') {
    await supabase.from('payment_failures').update({
      payment_status: 'recovered',
      resolution_outcome: 'customer_updated_card',
      resolved_at: new Date().toISOString(),
    }).eq('id', failure.id);
    return;
  }

  if (schedule.stripe_should_retry) {
    const paymentIntent = await stripe.paymentIntents.create({
      amount: failure.amount_cents,
      currency: failure.currency,
      customer: failure.stripe_customer_id,
      payment_method: invoice.default_payment_method as string,
      off_session: true,
      confirm: true,
    });

    if (paymentIntent.status === 'succeeded') {
      await supabase.from('payment_failures').update({
        payment_status: 'recovered',
        resolution_outcome: 'auto_retry',
        resolved_at: new Date().toISOString(),
      }).eq('id', failure.id);
      return;
    }
  }

  const nextRetry = new Date();
  nextRetry.setDate(nextRetry.getDate() + schedule.days_after_failure);

  await supabase.from('payment_failures').update({
    dunning_step: nextStep,
    next_retry_at: nextRetry.toISOString(),
    payment_status: schedule.action === 'cancel_subscription' ? 'canceled' : 'retry_scheduled',
    klaviyo_flow_status: 'queued',
    klaviyo_event_name: schedule.klaviyo_event_name,
  }).eq('id', failure.id);

  if (schedule.klaviyo_event_name) {
    await triggerKlaviyoEvent(failure.stripe_customer_id, schedule.klaviyo_event_name, {
      first_name: failure.profiles?.first_name,
      amount_due: failure.amount_cents / 100,
      step: nextStep,
    });
  }

  if (schedule.action === 'cancel_subscription') {
    await cancelSubscription(failure);
  }
}

async function cancelSubscription(failure: any) {
  await stripe.subscriptions.cancel(failure.stripe_subscription_id);

  await supabase.from('payment_failures').update({
    payment_status: 'canceled',
    resolution_outcome: 'churned',
    canceled_at: new Date().toISOString(),
    canceled_by: 'system',
  }).eq('id', failure.id);
}

async function triggerKlaviyoEvent(customerId: string, eventName: string, properties: any) {
  console.log(`Triggering ${eventName} for ${customerId}`);
}

The guard clause matters more than anything else in this file. Before every retry, check whether the invoice is already paid — the customer might have updated their card five minutes ago. Charging them twice, or emailing them after they've already fixed it, is how you turn a save into a complaint.

7. Model it as a state machine

A payment failure has a lifecycle: openretry_scheduledrecovered, paused, or canceled. Sketch it before you code it — a box per state, an arrow per transition.

open → retry_scheduled → recovered
open → retry_scheduled → canceled
open → paused
retry_scheduled → recovered

That's the whole graph. If you catch yourself wanting a path from canceled back to open, you've either found a bug or a business requirement nobody scoped.

Enforce it in code, not just in your head:

const validTransitions: Record<string, string[]> = {
  open: ['retry_scheduled', 'recovered', 'paused', 'canceled'],
  retry_scheduled: ['recovered', 'canceled', 'paused'],
  recovered: [],
  paused: ['open', 'canceled'],
  canceled: [],
};

function canTransition(from: string, to: string): boolean {
  return validTransitions[from]?.includes(to) ?? false;
}

Call this before every state update. If it returns false, log the attempt and stop. Boring state machines are the ones that don't wake you up at 2am.

8. Security, not optional

Verify webhooks. Stripe signs every event. Skip verification and anyone can POST fake failures to your endpoint, trigger emails, or cancel real subscriptions. Use the SDK's built-in check — don't roll your own.

Lock down the cron route. Your scheduler is a URL, and URLs aren't secret — they end up in git history and deploy logs. Put a secret in the Authorization header and reject anything that doesn't match.

const authHeader = req.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Never let the service role key near the browser. Supabase's service role key bypasses row-level security entirely — it belongs in server-side env vars only. Client reads go through the anon key. One accidental import into a client component and every visitor has admin access.

Sanitize logs. You'll log event IDs, customer IDs, invoice IDs. Stripe doesn't send full card numbers in webhooks, but if you're dumping entire payloads for debugging, know exactly what's in them.

Rate-limit any public simulator. If you run a demo that fakes payment failures, someone will hammer it.

CREATE TABLE simulator_attempts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  ip_address TEXT NOT NULL,
  email TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_simulator_attempts_ip_created
  ON simulator_attempts (ip_address, created_at DESC);
const { data: recent } = await supabase
  .from('simulator_attempts')
  .select('id')
  .eq('ip_address', ip)
  .gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());

if (recent && recent.length > 5) {
  return NextResponse.json({ error: 'Rate limited' }, { status: 429 });
}

Treat idempotency as a security control, not just a nicety. Processing the same event twice means duplicate emails, duplicate charges, a dunning step advanced twice. Store the event ID, check it first, every time.

const { data: existing } = await supabase
  .from('stripe_webhook_events')
  .select('id')
  .eq('stripe_event_id', event.id)
  .single();

if (existing) return;

9. Writing emails people actually read

Klaviyo sends them. You decide what they say — and every message should answer three things: what happened, what to do, what happens if you do nothing.

The soft reminder

> Subject: We couldn't process your payment

>

> Hi Sarah,

>

> We tried to charge your card ending in 4242 for $49.00, and the bank said no.

>

> No stress — this happens. We'll try again in 3 days.

>

> Want to update your card first? [Update Payment Method]

>

> If you don't do anything, we'll retry automatically. Reply here if you have questions.

Nothing apologetic, nothing threatening. Your app didn't do anything wrong, and neither did the customer. Just useful, with an easy way out.

The hard decline

> Subject: Your card expired — let's fix it

>

> Hi David,

>

> Your card ending in 1234 expired, so we couldn't charge it.

>

> [Update Card]

>

> Your subscription is active for 3 more days. After that, we'll need to pause it. Two minutes fixes this.

Urgent, not panicked. A deadline gives them a reason to act now instead of later.

The winback

> Subject: We're pausing your account in 48 hours

>

> Hi Priya,

>

> We've tried your card 3 times and it isn't going through. We're going to pause your account Friday — you'll lose access to your dashboard, templates, and team settings.

>

> We can still stop that. [Keep My Account Active]

>

> Not ready to commit right now? [Pause My Plan for 3 Months] instead.

>

> We've been working together for 2 years. We'd hate to lose you.

This one's allowed to be a little emotional — it reminds them what's actually at stake, and gives them a middle option besides "pay now" or "lose everything."

The cancellation notice

> Subject: Your account has been paused

>

> Hi Ahmed,

>

> We paused your subscription today — you won't be charged again.

>

> Your data's safe for 90 days. Want back in? [Reactivate]

>

> If this was a mistake, just reply. We're here.

Kind, no guilt, door left open.

Every one of these needs a one-click unsubscribe, even the transactional ones, even where the law doesn't require it. Give an annoyed customer a way to mute your emails — the alternative is they cancel the subscription instead.

10. The recovery page

The link in every email goes to a page that does exactly one thing: let the customer update their card. No re-login, no dashboard detour, no billing history nobody asked to see. Card number, expiry, CVC, submit.

The link carries a signed, short-lived JWT — https://yourapp.com/billing/update?token=abc123 — that expires in 24 hours and encodes the customer ID. The page verifies it, pulls the Stripe customer ID from Supabase, and renders a Stripe Elements form.

The customer submits a card, Stripe tokenizes it, your app attaches it as the new payment method, and retries the open invoice immediately.

// app/billing/update/page.tsx
'use client';

import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

function UpdateForm({ customerId, invoiceId }: { customerId: string; invoiceId: string }) {
  const stripe = useStripe();
  const elements = useElements();
  const [status, setStatus] = useState<'idle' | 'processing' | 'success' | 'error'>('idle');
  const [message, setMessage] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    setStatus('processing');

    const { error, paymentMethod } = await stripe.createPaymentMethod({
      type: 'card',
      card: elements.getElement(CardElement)!,
    });

    if (error) {
      setStatus('error');
      setMessage(error.message || 'Something went wrong.');
      return;
    }

    const res = await fetch('/api/billing/update-card', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        customerId,
        invoiceId,
        paymentMethodId: paymentMethod.id,
      }),
    });

    const result = await res.json();

    if (result.success) {
      setStatus('success');
      setTimeout(() => {
        window.location.href = '/dashboard';
      }, 2000);
    } else {
      setStatus('error');
      setMessage(result.error || 'Your bank declined the card.');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <CardElement options={{ style: { base: { fontSize: '16px' } } }} />
      <button type="submit" disabled={status === 'processing'}>
        {status === 'processing' ? 'Updating...' : 'Update Card'}
      </button>
      {status === 'error' && <p style={{ color: 'red' }}>{message}</p>}
      {status === 'success' && <p style={{ color: 'green' }}>All set. Redirecting...</p>}
    </form>
  );
}
// app/api/billing/update-card/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20' });
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

export async function POST(req: NextRequest) {
  const { customerId, invoiceId, paymentMethodId } = await req.json();

  try {
    await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId });
    await stripe.customers.update(customerId, {
      invoice_settings: { default_payment_method: paymentMethodId },
    });

    const invoice = await stripe.invoices.pay(invoiceId, {
      payment_method: paymentMethodId,
    });

    if (invoice.status === 'paid') {
      await supabase.from('payment_failures').update({
        payment_status: 'recovered',
        resolution_outcome: 'customer_updated_card',
        resolved_at: new Date().toISOString(),
      }).eq('stripe_invoice_id', invoiceId);

      return NextResponse.json({ success: true });
    }

    return NextResponse.json({ success: false, error: 'Payment failed' });
  } catch (err: any) {
    return NextResponse.json({ success: false, error: err.message }, { status: 500 });
  }
}

Keep the page bare. White background, one headline, one form, one button. No sidebar, no ten-link footer. Whoever lands here is already a little stressed — don't add to it.

11. Watching the engine

You can't fix what you can't see, so build a dashboard — not for customers, for yourself. It should surface:

  • Active dunning cases right now
  • Recovery rate, broken out by segment (real estate recovers slower than ecommerce, and it's worth knowing why)
  • Which email in the sequence actually converts
  • Revenue at risk — the sum of every open failure
  • Why people actually left: canceled vs. paused
SELECT 
  COUNT(*) as open_cases,
  SUM(amount_cents) / 100.0 as revenue_at_risk_usd,
  AVG(EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600) as avg_hours_open
FROM payment_failures
WHERE payment_status IN ('open', 'retry_scheduled');

Run that every morning. If revenue at risk jumps from $2,000 to $15,000 overnight, something's broken — a webhook format changed, the cron job stalled, an API key expired. You want to find out in hours, not weeks.

if (revenueAtRisk > 10000) {
  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `🚨 Dunning alert: $${revenueAtRisk} at risk. ${openCases} open cases. Check the cron job.`,
    }),
  });
}

Log what your app *did*, not just its current state. payment_failures tells you where things stand; stripe_webhook_events tells you what Stripe said happened. Add a third table for what your engine actually acted on.

CREATE TABLE dunning_actions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  payment_failure_id UUID REFERENCES payment_failures(id),
  action TEXT NOT NULL, -- 'retry_charge', 'send_email', 'cancel_subscription', 'customer_recovery'
  success BOOLEAN,
  error_message TEXT,
  metadata JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

When a customer says "I never got the reminder email," this table has the answer — success: false, error_message: 'Klaviyo API returned 429'. You know what happened, you fix it, you apologize with specifics instead of guessing.

12. Things that quietly break trust

Sending every email on schedule, no matter what. A customer fixes their card, Stripe retries and succeeds — then your cron job runs anyway, sees the record still marked open, and sends "we couldn't process your payment" a second time. Always check the invoice status before acting, no exceptions.

Canceling on the first miss. A hard decline feels final but often isn't — the customer might swap cards in twenty minutes. Give hard declines at least a day, soft declines at least a week.

Reusing the same subject line. Three emails titled "Payment failed" and the third one gets ignored. Vary the subject and the tone as the sequence escalates.

Burying the update link. I've seen dunning emails put the link at the bottom, in gray text, after three paragraphs of brand voice. The link is the entire point of the email — first sentence, blue button, unmissable.

Forgetting mobile. Most of these emails get opened on a phone. The update page and the Stripe Elements form need to work at that size, with thumb-sized buttons.

Storing card numbers. You should never see a full card number — Stripe handles that. If one somehow lands in a webhook payload, don't log it, don't store it. PCI compliance isn't optional.

Hardcoding the retry schedule. Worth repeating because everyone does it anyway, and everyone regrets it later.

Ignoring bounces. Klaviyo will tell you when an email bounces. If it does, that customer never sees the sequence at all — you need a fallback: SMS, in-app, or a phone call for high-value accounts. Log it, flag the account, do something.

Building this before you have revenue. With ten customers, don't automate any of this — retry charges manually from the Stripe dashboard and learn what your customers actually do first. Automation is for scale, not for day one.

13. Pre-launch checklist

  • [ ] Webhook signatures are verified
  • [ ] Stripe events are logged and deduplicated
  • [ ] The cron route requires a secret
  • [ ] The service role key never reaches the browser
  • [ ] The retry schedule lives in a table, not in code
  • [ ] The state machine enforces valid transitions
  • [ ] Every retry checks whether the invoice is already paid
  • [ ] Every email has one clear call to action
  • [ ] The recovery page works on mobile
  • [ ] Revenue at risk is monitored daily
  • [ ] Failed actions get logged
  • [ ] Bounced emails have a fallback channel
  • [ ] The cancellation email reads as kind, not punitive
  • [ ] You've run the full flow end to end — with a real card, a real failure, a real recovery

That last one is the one people skip. Test mode is clean; a real declined charge on your own card, walked through email to recovery page to reactivated subscription, is the only way to know the flow actually feels okay from the other side.

14. Why any of this matters

Dunning isn't collections and it isn't debt recovery. It's keeping good customers who happened to hit a bad moment.

Nobody wakes up and decides to stop paying you. Cards expire. Banks flag charges for no visible reason. People switch accounts and forget to update one subscription out of a dozen. The job is to make fixing it easier than churning.

That means fewer emails, not more. Clearer links, not prettier ones. Patience with soft declines, real urgency with hard ones. Treating the person on the other end like someone who wants to stay — not like a delinquent account.

Build it, watch it, adjust it. But never lose track of the fact that there's a person on the other side of every one of these emails, and they chose your product once already. Make it easy for them to keep choosing it.

---

15. Testing it properly

Stripe's test mode is too clean to trust on its own. Real life is messier, so test for that.

Step 1 — Create a real test customer. Use an email you actually check — yours, a cofounder's.

Step 2 — Subscribe them to a $1 plan. A $0 invoice never triggers invoice.payment_failed, so you need something with a real charge attached.

Step 3 — Force a real decline. Test mode gives you specific card numbers for this (4000000000000002 for a generic decline, 4000000000000127 for a bad CVC, 4000000000000069 for an expired card), but in live mode you have two real options: call your bank and ask them to block the merchant for 24 hours, or run a small real charge and let it fail naturally.

Step 4 — Walk the whole path yourself. Read the email on your phone. Is the subject right? Does the link open cleanly? Does the update page load fast and render the Stripe form correctly? Enter a new card — does the retry fire, does the invoice mark paid, does the dashboard's revenue-at-risk number actually drop?

Step 5 — Poke at the edge cases.

What if the customer updates their card in the gap between the cron job's query and its retry? Build in a short buffer — a few minutes is enough to dodge most race conditions.

What if Klaviyo is down when you try to trigger an email? The app should log the failure and retry on the next run, not crash.

What if Stripe delivers the same webhook twice? POST the same payload to your endpoint manually and confirm the idempotency check catches the duplicate.

What if the subscription is already canceled by the time the webhook arrives? Check status before creating a new failure record:

const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'canceled') {
  console.log('Subscription already canceled. Ignoring failure.');
  return;
}

Step 6 — Test it under real volume. Seed 1,000 fake failures and time the cron run. Thirty seconds is a warning sign — at real scale that becomes 10,000 and the job starts timing out. Batch the work, or move it onto a queue; Supabase's pg_net extension handles async HTTP calls well if you need one.

Step 7 — Test the cancellation path specifically. Let a test subscription run through every step to cancellation. Confirm the Stripe status actually flips to canceled, the customer record stays intact, and reactivation works. This is the path people test least and break most.

16. Scaling past the first version

The first version is simple on purpose: one cron job, one email flow, one schedule. Growth changes that — European customers whose banks behave differently, annual plans that fail on a different cadence than monthly ones, enterprise accounts that deserve a phone call instead of an email.

Segment the retry schedule. Add a segment column and branch the schedule lookup by customer type.

ALTER TABLE retry_schedule ADD COLUMN segment TEXT NOT NULL DEFAULT 'default';
CREATE UNIQUE INDEX idx_retry_schedule_step_segment ON retry_schedule(step, segment);

Add a real queue. Past a few thousand customers, a single cron job starts to strain. Something like Inngest, QStash, or BullMQ can retry failed jobs and process in parallel — the webhook handler and the cron job both enqueue work, and a worker does the actual processing.

await queue.enqueue('process-payment-failure', { eventId: event.id });

worker.on('job', async (job) => {
  await processPaymentFailure(job.data.eventId);
});

Add a circuit breaker. If Stripe's API has an outage, a naive cron job just fails a hundred times in a row and logs a hundred errors. Trip a breaker after a handful of failures, pause calls for ten minutes, and alert yourself instead.

let failureCount = 0;
let lastFailureTime = 0;

async function callStripeWithCircuitBreaker(fn: () => Promise<any>) {
  if (failureCount >= 5 && Date.now() - lastFailureTime < 10 * 60 * 1000) {
    throw new Error('Circuit breaker open. Stripe API temporarily disabled.');
  }

  try {
    const result = await fn();
    failureCount = 0;
    return result;
  } catch (err) {
    failureCount++;
    lastFailureTime = Date.now();
    throw err;
  }
}

Add a kill switch. Eventually a bug will ship that sends thousands of emails in minutes or cancels subscriptions it shouldn't. You need a way to stop the engine without a deploy — an env var checked at the top of the cron job does it.

if (process.env.DUNNING_ENABLED === 'false') {
  console.log('Dunning engine disabled by kill switch.');
  return NextResponse.json({ status: 'disabled' });
}

Flip it in your hosting dashboard. One click, no deploy, no waiting.

17. Last word

None of this is glamorous. It's plumbing — the pipes behind the wall that keep everything dry. When it's working, it's invisible: a customer's card fails, they get one email, they click one link, they enter one card, and they never think about it again.

That invisibility is the actual goal. The moment you're thinking hard about your dunning engine, something has already gone wrong upstream of it.

Build it once, test it for real, then let it do its job quietly.

---

*Need help auditing your Stripe setup or building this out for your own product? I run Stripe billing audits and full dunning engine builds — see recent work with Brixly, The Larder Club, and Frame & Thread.*


Mathew Ngatia

Hi, I'm Mathew — I build Stripe billing infrastructure, dunning systems, and payment integrations that keep SaaS revenue from leaking. If this kind of thing is useful to you, I write more of it over on Medium and post updates on LinkedIn.


R
Webhook Engineer – 8 yrs Stripe integrations & dunning
Is your subscription revenue leaking?

I help SaaS founders recover up to 50-60% of their “failed payment” churn through technical Stripe & Klaviyo optimization.

Project: Stripe Audit $1,800 · includes dunning review

Newsletter

Revenue insights delivered to your inbox

Get weekly articles on Stripe optimization, dunning strategies, integration guides, and revenue engineering practices. No spam, just signal.