DIL.DEV
LOADING0%
DIL.DEV
← ALL NOTES

Stripe Redelivered a Stale payment_failed After the Customer Had Already Paid

A checkout decline raised invoice.payment_failed before the subscription row existed, the handler threw, and Stripe redelivered the event after the customer paid, flipping them to past_due.

stripewebhooksbillingsaas

A terminal showing a webhook event log with a stale invoice.payment_failed replay arriving after a successful payment.

Three customers on Free Stencil Maker received a last-chance dunning email after they had already paid. The subscription row in the database was flipped to past_due by a webhook event that was over an hour old. The root cause was a handler that threw on first delivery, releasing the idempotency claim back to Stripe, which then redelivered the same event into a world that had moved on.

How the Sequence Unfolded

A customer attempts checkout. The card declines. Stripe raises invoice.payment_failed almost immediately, before the subscription row has been written to the database. The handler looks up the subscription, finds nothing, and throws. That throw produces a non-2xx response. Stripe marks the delivery failed.

The customer fixes their card details and pays. The subscription row is created. Stripe, on its retry schedule, redelivers the original invoice.payment_failed event. This time the row exists, so the handler runs to completion. It reads the event payload, sees a failed invoice, and applies dunning logic.

The payload invoice has next_payment_attempt set to null. The dunning code used that as the signal for the final stage. A paid invoice also has next_payment_attempt null, because there are no more retries needed. The two cases were not distinguished. The customer received a last-chance email and the subscription was marked past_due.

Why the Throw Was the Real Trigger

The handler throwing is what made everything else possible. If it had returned a 200 with a note that the subscription was not in the database yet, Stripe would have considered the event delivered and never redelivered it.

Throwing felt correct at the time. Something unexpected happened, so signal an error. But Stripe's retry semantics mean a throw is an invitation to redeliver, not a way to log a problem quietly.

The fix for this specific case is simple: when the subscription row is not in the database, return a 200 and log it. The event is almost certainly a first-attempt decline on a checkout that did not complete. There is nothing to act on.

The Stale-Event Check

The primary guard is a live Stripe API call. Before acting on any invoice.payment_failed, retrieve the invoice directly from Stripe and check its current status. If it is paid, log invoice.payment_failed_stale with success: true and return. The event payload is irrelevant at that point.

There is a fallback for when the retrieve itself fails. If the subscription row in the database is active and its updated timestamp is after event.created, the event is stale by definition. That path logs invoice.payment_failed_retrieve_fallback and also returns cleanly.

Both paths produce a 200. Stripe sees a successful delivery. No redeliver.

Separating the Final-Stage Conditions

The original dunning logic used next_payment_attempt === null to mean "Stripe has given up, send the last-chance email." That is correct for a genuinely exhausted subscription. It is wrong for a paid invoice, which also has next_payment_attempt null.

The fix adds attempt_count >= 2 as a required condition for the final stage. A checkout decline is always a first attempt, so attempt_count is 1. Even if a stale event somehow slipped past the live-invoice check, it could not reach the final stage.

The full set of conditions for the final stage now reads:

  • Invoice status is unpaid. Not paid, not void.
  • next_payment_attempt is null. Stripe has scheduled no further automatic retries.
  • attempt_count is at least 2. The charge has been tried more than once.

All three must be true together. Any single condition on its own is not enough.

The Pure Function and Its Tests

The logic was extracted from the webhook route into server/utils/dunning.ts as a pure function: describeDunningDecision(invoice, liveInvoice, subRow, event). It takes the event payload invoice, the freshly retrieved live invoice, the database subscription row, and the raw event. It returns a decision object. No side effects, no database calls, no Stripe calls inside the function itself.

That made it straightforward to write deterministic tests in tests/server/dunning-stale-payment-failed.test.ts. The cases covered include:

  • Stale paid event, retrieve succeeds. Decision is stale, no dunning action.
  • Stale paid event, retrieve fails, row is active and newer. Decision is stale via fallback.
  • Genuine first attempt, unpaid. Decision is early dunning, not final.
  • Genuine exhausted subscription, attempt_count 3, unpaid, next_payment_attempt null. Decision is final stage.
  • Subscription not in DB. Decision is ack-and-ignore.

The commit that shipped this was 053f5b89 on 2026-09-27, message: fix(webhook): ignore stale invoice.payment_failed replays, never dun a paid invoice.

What Actually Happened to the Three Customers

Past_due does not immediately revoke Pro access on Free Stencil Maker. The subscription stays active. So none of the three customers lost functionality. They received a wrong email, which is bad, but the damage was bounded.

The database rows were corrected by SQL after the incident was identified. The customers were on past_due for the window between the stale event and the manual correction, roughly the span of the production incident from 2026-09-24 to 2026-09-27.

What I'd do differently

  • Ack unknown subscription rows immediately. The throw-on-not-found pattern felt defensive but it handed Stripe a retry token. A 200 with a structured log would have been safer from day one.
  • Never use a single null check as a final-stage gate. next_payment_attempt being null is shared by two very different invoice states. Any condition that matches both "done retrying" and "successfully paid" is a bug waiting for a race condition to expose it.
  • Retrieve the live resource before acting, not after. The event payload is a snapshot from the moment Stripe fired it. For billing events especially, the world can change significantly between fire and delivery. Treating the payload as ground truth is the assumption that caused this.
  • Write the pure decision function first. Having describeDunningDecision as a testable unit from the start would have made the edge cases visible before they hit production. The extraction happened reactively, after the incident.

NOTES BY EMAIL

Get the next note in your inbox.

One email per note. No spam. Reply “stop” any time and you are off the list.