▲13 ▼1 @lfournier 2026-08-27 stripe webhooks subscriptions reliability

Subscription stayed "past_due" after a successful payment: the webhook race between invoice.paid and customer.subscription.updated

verbatim errordb: subscription sub_1Pabc status=past_due (stale) webhook received: invoice.paid evt_1Q9... (ignored — no handler branch) webhook received: customer.subscription.updated evt_1Q8... (status=past_due, snapshot BEFORE payment)

Problem

A renewal payment succeeds — the money is taken, the invoice is paid — and the customer still shows past_due in your database. Features stay locked. The dashboard shows the subscription active; your table disagrees, sometimes for hours, occasionally forever until a support ticket:

db: subscription sub_1Pabc status=past_due (stale) webhook received: invoice.paid evt_1Q9... (ignored — no handler branch) webhook received: customer.subscription.updated evt_1Q8... (status=past_due, snapshot BEFORE payment)

Root cause

Stripe delivers webhook events asynchronously and independently — there is no ordering guarantee between events for the same subscription. The two events that describe a successful renewal, invoice.paid and the corresponding customer.subscription.updated, can arrive in either order. The sequence that breaks naive handlers:

1. The subscription flips to past_due at the retry boundary; a customer.subscription.updated event is generated (status past_due). 2. The payment then succeeds; invoice.paid and a second customer.subscription.updated (status active) are generated. 3. Out-of-order delivery: your handler processes the first update after the second, and the stale past_due overwrites active.

Any handler that treats each webhook as authoritative-at-arrival and stores event.data.object.status unconditionally is racing itself. The ignored invoice.paid is the second half of the bug: it is the strongest "payment actually succeeded" signal available, and it carries payment_intent.status = "succeeded" — the one fact that should beat any stale subscription snapshot.

fix preview — first 3 of 32 lines (js), truncated:
const RANK = { incomplete: 0, incomplete_expired: 0, past_due: 1, unpaid: 1, canceled: 2, active: 3, trialing: 3 }; export async function onSubscriptionEvent(event) { … 29 more lines in the fix

🔒 the fix — including 2 code blocks — is members-only. $1/mo unlocks everything.

🔒 comments and voting are for members. $1/mo · every diagnosis is free to read, plus 3 complete sample fixes.