# 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.

- Author: Dil Ahdan (https://dil.dev/)
- Published: 2026-10-18
- Updated: 2026-10-18
- Tags: stripe, webhooks, billing, saas
- Canonical: https://dil.dev/notes/stripe-stale-payment-failed-replay/

## Key takeaways

- Always retrieve the live invoice from Stripe before acting on invoice.payment_failed, never trust the event payload alone.
- Require attempt_count >= 2 before sending a final dunning email, because first-attempt events can replay long after the customer has paid.
- Return a 200 for subscription-not-in-DB instead of throwing, so Stripe does not hold an idempotency claim and redeliver.
- A paid invoice has next_payment_attempt set to null, which is the same sentinel a final dunning stage checks, so the two conditions must be separated explicitly.
- past_due does not immediately revoke access, but the dunning email it triggers is real and wrong.

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.

## Quick answers

**Why did Stripe redeliver the same webhook event an hour later?**

When a webhook handler returns a non-2xx response, Stripe marks the delivery as failed and retries on its own schedule. The handler threw because the subscription row did not exist yet at the time of the first delivery, so Stripe held the idempotency claim and redelivered. By the time it arrived again, the customer had completed payment.

**How do you detect that an invoice.payment_failed event is stale?**

Retrieve the live invoice from the Stripe API and check its status. If it is paid, the event is stale regardless of what the event payload says. A fallback covers retrieve failures: if the subscription row in the database is active and was updated after event.created, treat the event as stale too.

**Why is next_payment_attempt null on a paid invoice a problem?**

The dunning logic used next_payment_attempt being null as the signal for the final stage, because Stripe sets it to null when there are no more retries. A paid invoice also has next_payment_attempt null, so the stale event matched the final-stage condition and triggered the last-chance email.

**What does attempt_count >= 2 actually guard against?**

It ensures the final dunning action only fires after Stripe has genuinely retried the charge at least once. A first-attempt event, which is what a checkout decline produces, will always have attempt_count of 1, so requiring 2 blocks the stale replay from reaching the final stage even if the null-check somehow passes.

**Did affected customers lose access to Pro features?**

No. Stripe's past_due status keeps the subscription active, so Pro access was never revoked. The rows were corrected by SQL after the incident was identified. The harm was the wrong dunning email being sent.

**Where does the fix live in the codebase?**

The logic was extracted into server/utils/dunning.ts as a pure function called describeDunningDecision, which takes the event payload invoice, the live invoice, the subscription row, and the event. Tests live in tests/server/dunning-stale-payment-failed.test.ts.
