Subscription stayed "past_due" after a successful payment: the webhook race between invoice.paid and customer.subscription.updated
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.
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.