Stripe event handlers tolerate out-of-order delivery by re-fetching current state or guarding on recency
Why it matters
Stripe does not guarantee that webhook events arrive in the order they were generated, and retries make reordering routine: a delayed customer.subscription.updated can land after the customer.subscription.deleted that logically followed it. A handler that writes status, plan, or period dates straight from event.data.object therefore lets a stale event overwrite newer state, so a customer who canceled appears active again, or a paying customer loses access because an older past_due update arrived last. The tempting fix, comparing event.created timestamps, is also unreliable: Stripe documents that it does not guarantee delivery order and created has one-second resolution, so two events in the same second tie. The robust patterns are to treat the event as a notification and re-fetch the object (stripe.subscriptions.retrieve(id)) before writing, or to guard the write with a monotonic marker stored alongside the row. This is a data-integrity failure rather than a security hole, but its effect is billing state that disagrees with Stripe, which turns into either free access or wrongly revoked paid access.
Severity rationale
Medium because out-of-order overwrites cause wrong subscription state (a canceled customer shown as active, or the reverse) that persists until the next event, but they require specific delivery timing and are usually self-correcting on the next event.
Remediation
Treat the webhook as a signal and read current state from Stripe before writing. For subscription events, call stripe.subscriptions.retrieve(subscriptionId) and write that object's status, items, and current_period_end, not the event payload's copy. If you must write from the event, store the Stripe object's own version marker alongside the row and refuse to apply an update that does not advance it. Do not order events by event.created. Re-fetching inside the request can push a handler past the Webhook Quick Response check, so persist the verified event, return 200, and re-fetch in the queued job.
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = await stripe.subscriptions.retrieve((event.data.object as Stripe.Subscription).id)
await db.subscription.update({ where: { stripeId: sub.id }, data: { status: sub.status } })
}
DetectionPro
The detection method for this pattern is part of AuditBuffet Pro.
The full method (what to look for, pass and fail conditions, and how results get reported) ships inside the Stripe Webhooks & Billing Integrity audit. The pattern definition, severity rationale, and remediation remain openly licensed under CC-BY-4.0.
External references
- external · stripe-webhooks-event-ordering — Stripe docs: Event ordering is not guaranteed
- external · stripe-event-object — Stripe API reference: The Event object
Taxons
History
- 2026-10-05·v1.0.0·Initial authoring — out-of-order delivery tolerance for subscription/entitlement state writes.·by stripe-billing-integrity-v1-build