Build1 publisher3 min readPublished
Stripe webhooks are not exactly-once, and the standard Next.js handler assumes they are
A reviewer of a dozen Next.js Stripe integrations says almost none guard against duplicate or out-of-order deliveries. The failure mode is a second welcome email and no error anywhere.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened
- Stripe does not guarantee that it will deliver a webhook event exactly once, and does not guarantee that events arrive in the order they happened; both are explicitly documented in Stripe's own docs.
- The author says they have reviewed a dozen Next.js Stripe integrations, on client projects and in open source repos, and almost every webhook handler is written as if exactly-once delivery and ordering were guaranteed.
- The author describes the handler shown as a completely standard-looking one, of the kind that appears in probably half the Next.js Stripe tutorials online.
- The sample handler in app/api/webhooks/stripe/route.ts calls stripe.webhooks.constructEvent, switches on event.type, and for checkout.session.completed sets the user's subscriptionStatus to 'active' via User.findByIdAndUpdate, sends a welcome email with resend.emails.send, and returns a 200 'OK' response.
- Stripe retries webhook delivery automatically if the endpoint responds slowly, times out, or the server has a brief blip during deployment.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A developer writing on dev.to says they have reviewed a dozen Next.js Stripe integrations, on client projects and in open source repos, and that almost every webhook handler was written as if duplicate and out-of-order delivery could not happen [2]. It matters because Stripe documents that both can: delivery is not guaranteed to be exactly once, and events are not guaranteed to arrive in the order they occurred, according to the post [1].
The handler in question is the one that shows up in roughly half of the Next.js Stripe tutorials online, by the author's estimate [3]. It verifies the signature, switches on `event.type`, and on `checkout.session.completed` sets the user's `subscriptionStatus` to active, sends a welcome email through Resend, and returns 200 [4]. Stripe retries a delivery automatically if the endpoint responds slowly, times out, or the server has a brief blip during a deploy [5]. On the retry, the same branch runs again: the status is set to active a second time, which is harmless, and the welcome email goes out twice, which is not [6]. Swap the email for granting credits, incrementing a usage counter, or inserting a row, and you get duplicated side effects silently, in production, with no error thrown to tell you [7].
The reason this survives code review is environmental. Forwarding events with the Stripe CLI locally produces exactly one delivery per event almost every time [8], and retries are triggered by precisely the conditions a laptop rarely reproduces: a slow response, a deploy at the wrong moment, a network hiccup [9].
The mitigation is unglamorous and cheap. Every Stripe event carries a unique `id`, so the handler checks whether that ID has been processed before doing anything else [10]. In the post's version, the route looks up a `ProcessedEvent` record by `event.id`, returns 200 immediately if one exists, otherwise runs the branch and then writes `{ eventId, processedAt }` [11]. A collection holding nothing but event IDs and timestamps is enough: the retry still gets a success response, so Stripe stops retrying, and no duplicate side effect occurs [12].
One thing to note about that sample: the guard row is written after the email is sent [11], so a crash between the send and the write leaves the guard unset and the next retry duplicates the email anyway [16]. If the side effect is expensive or externally visible, claim the event ID first and treat the insert itself as the lock.
Ordering is the subtler half, and it catches people who already fixed duplicates. Stripe does not guarantee that `checkout.session.completed` arrives before a later `customer.subscription.updated` for the same customer [13]. The common pattern updates the user by `stripeCustomerId`, a field the earlier event was supposed to set; if the later event lands first, the update matches nothing and disappears without an error [14]. The author's advice is to make each handler safe regardless of arrival order, for instance by resolving the user through a different key [15].
Worth checking on your own system: how many distinct event IDs your endpoint has acknowledged versus how many state transitions it actually performed. The gap, if any, is already in your outbox.