Building Dunning on invoice.payment_failed: Seven Failure Modes That Cost Real Money
A technical breakdown of what goes wrong when you build failed-payment recovery on Stripe webhooks yourself: duplicate sends, race conditions, serverless timeouts, and invoices that die before your email arrives.
Failed-payment recovery looks like a weekend project. Stripe fires
invoice.payment_failed, you send an email, the customer updates their card,
you keep the MRR. Two hundred lines, maybe three hundred with templates.
That version works in staging. What follows is what breaks in production, in roughly the order teams discover it. Every item below is a real failure mode with a real cost — most of them silent, which is why they survive so long.
1. Stripe delivers events at least once, not exactly once
Stripe's webhook delivery guarantee is at-least-once. Your endpoint can and
will receive the same event twice, and the docs are explicit that you should
guard against it by tracking processed event IDs. In some cases Stripe generates
two distinct Event objects for what is conceptually one occurrence, so
deduplicating on event.id alone is not sufficient — you also need the ID of the
object in data.
If your handler is "receive event → send email," a redelivery sends a second copy of a payment-failure notice to a paying customer. That is not a cosmetic bug. It is the single fastest way to get a support ticket and a spam complaint on the same day.
The fix is not a try/catch. It is a uniqueness constraint that makes the second
send structurally impossible:
CREATE UNIQUE INDEX scheduled_email_invoice_stage
ON "ScheduledEmail" ("stripeInvoiceId", "stage");
One row per (invoice, stage). A duplicate webhook attempting to schedule the same stage hits the constraint and does nothing. Idempotency enforced by the database is the only kind that survives concurrency.
2. Ordering is not guaranteed either
Stripe does not guarantee that events arrive in the order they occurred. A
invoice.paid can land before the invoice.payment_failed that preceded it.
In a naive handler, that sequence produces a "your payment failed" email sent after the customer already paid. The customer is now confused about whether they were charged twice, and your support burden is worse than if you had sent nothing.
The defensive posture is to treat every send as conditional on state read at send time, not on the event that triggered it. Which leads to the next problem.
3. One email per webhook means Stripe sets your cadence, not you
This is the most common architecture in the wild, and the least visible failure.
// Do not do this.
if (event.type === 'invoice.payment_failed') {
await sendDunningEmail(invoice);
}
Stripe's own retry schedule now determines when your customers hear from you. If Smart Retries attempts four charges over two weeks, your customer receives four emails at intervals a machine-learning model picked to optimise authorisation rates — not intervals you chose to optimise replies. If Stripe retries twice, your five-step sequence sends two emails.
You have outsourced your customer communication schedule to a system optimising for something else.
The correct model separates the trigger from the schedule. The first failure plans the entire sequence and writes it to a table. Subsequent failures only increment a counter:
ScheduledEmail
(userId, paymentIntentId, stripeInvoiceId, recipientEmail,
stage, sendAt, deadlineAt, sentAt, cancelledAt, attempts, lastError)
A worker then sends what is due. Stripe tells you that a payment failed; you decide everything else.
4. The SELECT-then-UPDATE window
Once you have a queue and a worker, you have concurrency. If a cron job and a webhook handler can both flush the queue — and they should, because relying on a single daily cron means a send scheduled for 15:09 goes out tomorrow — two processes will eventually claim the same row.
This is the losing pattern:
const due = await db.query(
`SELECT * FROM "ScheduledEmail" WHERE "sentAt" IS NULL AND "sendAt" <= now()`
);
for (const row of due) {
await deliver(row);
await db.query(`UPDATE "ScheduledEmail" SET "sentAt" = now() WHERE id = $1`, [row.id]);
}
Between the SELECT and the UPDATE there is a window measured in hundreds of
milliseconds. Two workers running concurrently both read the row as unsent, both
deliver, and the customer gets two identical emails.
Claiming and marking must be a single atomic statement:
UPDATE "ScheduledEmail"
SET "sentAt" = now()
WHERE id = $1
AND "sentAt" IS NULL
RETURNING id;
If it returns no row, another worker already owns it — skip and move on. For
batch claiming, SELECT … FOR UPDATE SKIP LOCKED lets multiple workers pull
disjoint sets without blocking each other.
5. A serverless function killed mid-batch leaves phantom sends
This one is specific to Vercel, Lambda, Cloud Functions — anywhere execution time is capped and the cap is lower than you think.
Vercel's Hobby default is 10 seconds. A Stripe webhook handler that ends by
flushing the email queue can be killed halfway through the batch. If you mark
rows as sent before delivery completes, the surviving state is: sentAt set,
attempts at 0, lastError null. Nothing about that row looks broken. It will
never be retried, it counts as sent in your metrics, and no email was ever
delivered.
Two requirements follow. Declare maxDuration on every route that can send
email. And make sure a row that fails delivery is explicitly released back to the
queue — with an attempt counter and a ceiling, so a permanently invalid address
does not loop forever.
6. The invoice can die before your email arrives
This is the failure mode nobody warns you about, and it is worth walking through in full because it breaks an assumption most schedulers make without noticing.
A subscription created with payment_behavior=allow_incomplete on a customer
whose default payment method declines will sit in incomplete status. After
roughly 23 hours without payment, Stripe transitions it to incomplete_expired
and cancels the associated invoice — with no webhook that a typical dunning
integration is listening for.
If you scheduled a five-step sequence at the moment of failure, the following is now true: your first email goes out pointing at a payment link that is already dead, and four more are still queued for a subscription that no longer exists. The recipient clicks, hits an error, and forms an opinion about your product.
The invariant to internalise: never assume an invoice is still valid between
scheduling and sending. Re-fetch the invoice status immediately before each
delivery, and cancel the remainder of the sequence if it is no longer open.
Fail open if the Stripe call itself errors — a transient API failure should not
silence a legitimate reminder.
const invoice = await stripe.invoices.retrieve(row.stripeInvoiceId, {
stripeAccount: row.stripeAccountId,
});
if (invoice.status !== 'open') {
await cancelScheduledEmail(row.id, `invoice status: ${invoice.status}`);
continue;
}
Note that this is a different operation from cancelling because the customer paid. Same outcome for the queue, different reason on the record — and you want to be able to tell them apart when you audit why a sequence stopped.
7. Five emails, five different deadlines
Most homegrown templates compute the deadline at render time: "you have 3 days," "you have 24 hours." Each template does its own arithmetic against its own send time.
The customer, who keeps emails, sees four different dates for the same event. One of them is wrong by definition, and any of them can be wrong by an hour if a send slips. A stated deadline is a commitment; computing it four times independently guarantees you will break it at least once.
Freeze deadlineAt at scheduling time, store it on every row of the sequence,
and have every template read the same field. If the cadence changes, the deadline
does not move — because it was already announced.
The same logic applies to the last email. Whatever the number of intervals a
merchant configures, the final message sent must be the one whose copy
actually says it is the last one. A four-step schedule should send
soft → medium → urgent → last-chance, not soft → medium → urgent → final
with the closing message left unsent.
The one that is a security bug, not a reliability bug
Merchant-supplied data reaching an email header is a header-injection vector.
A businessName field that a merchant types into your settings page and that you
interpolate into a From: header — "{businessName} via YourBrand" <noreply@…>
— accepts newlines and quotes unless you strip them explicitly.
Truncating to 60 characters is not sanitisation. A dedicated
sanitizeSenderName() that removes CR, LF, quotes and angle brackets is. This is
the header-level counterpart to escaping the same field before rendering it into
HTML body content, which you are presumably already doing.
Testing this is harder than building it
A closing note, because it costs teams more time than the implementation.
invoice.payment_failed only fires on automatic collection. Running
stripe invoices pay against a declining card returns a 402 synchronously and
emits no event at all — the invoice stays open, your webhook receives nothing,
and your test appears to fail for reasons unrelated to your code. The asymmetry
is complete: an invoices pay that succeeds does emit invoice.paid.
The reliable way to produce a genuine invoice.payment_failed in test is a
subscription created with payment_behavior=allow_incomplete on a customer whose
default payment method declines. And when attaching that payment method, note
that payment_methods attach returns a new ID — that is the one to set as
invoice_settings[default_payment_method], never the shared test token.
What this actually costs
Add it up. Deduplication, a scheduling table with a uniqueness constraint, atomic claiming, a worker with attempt limits and timeout headroom, invoice revalidation, deadline freezing, header sanitisation, five templates in three tones, deliverability setup, and a test harness that can produce the event at all.
That is not a weekend. It is a few weeks of senior engineering time, plus the tail of silent bugs that only surface as unexplained gaps in recovered revenue — the kind you find eight months later, if you find them.
The build-vs-buy question is not "can we build this." Most teams can. It is whether the second and third weeks of that work are the best available use of your engineering capacity, given that the failure modes are invisible in your logs and the cost of each one is denominated in churned customers.
PaidGuard handles this specific problem. Connect your existing Stripe account read-only, get an audit of the last 90 days of failed payments before you enter a card, and see what the gap is worth on your account. $39/month plus 5% of revenue actually recovered — first month free.
Related: How to evaluate Stripe dunning software · Stripe Smart Retries vs dunning emails