Quick fact that surprises a lot of developers the first time they hear it: Stripe does not guarantee it will deliver a webhook event exactly once, and it does not guarantee events arrive in the order they happened. Both of those are explicitly documented, stated plainly in Stripe's own docs, and almost every webhook handler I've reviewed on a client project or open source repo is written as if neither is true.
What This Actually Breaks
Here's a completely standard-looking webhook handler, the kind that shows up in probably half the Next.js Stripe tutorials online:
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await User.findByIdAndUpdate(session.metadata?.userId, {
subscriptionStatus: 'active',
});
await resend.emails.send({
to: session.customer_email,
subject: 'Welcome! Your subscription is active',
// ...
});
break;
}
}
return new Response('OK', { status: 200 });
}
This works perfectly, right up until Stripe retries the delivery, which it does automatically if your endpoint responds slowly, times out, or your server has a brief blip during deployment. When that retry arrives, this handler runs the exact same logic again. The user's subscription status gets set to active a second time, harmless on its own, but the welcome email goes out twice. On a busier handler doing something less harmless, granting credits, incrementing a usage counter, creating a database record, a duplicate delivery means duplicate side effects, silently, in production, with no error thrown anywhere to tell you it happened.
Why This Doesn't Show Up in Testing
Locally, using the Stripe CLI to forward events, you get exactly one delivery per event almost every time, a clean, predictable environment. Retries happen specifically when something is slightly off, a slow response, a deploy happening at the wrong moment, a brief network hiccup, exactly the conditions your local dev environment almost never produces. This bug lives specifically in the gap between "works every time I test it" and "works reliably under real production conditions," which is exactly why it slips through so often.
The Fix: Track Processed Event IDs
Every Stripe event has a unique id. Idempotency means checking whether you've already processed that specific ID before doing anything else.
// app/api/webhooks/stripe/route.ts
import ProcessedEvent from '@/models/ProcessedEvent';
export async function POST(request: Request) {
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
await connectDB();
const alreadyProcessed = await ProcessedEvent.findOne({ eventId: event.id });
if (alreadyProcessed) {
return new Response('OK', { status: 200 }); // acknowledge, but do nothing
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await User.findByIdAndUpdate(session.metadata?.userId, {
subscriptionStatus: 'active',
});
await resend.emails.send({ /* ... */ });
break;
}
}
await ProcessedEvent.create({ eventId: event.id, processedAt: new Date() });
return new Response('OK', { status: 200 });
}
A small collection, just event IDs and timestamps, is enough. This single check means a retried delivery still returns a successful response to Stripe, so Stripe stops retrying, but does absolutely nothing the second time, no duplicate email, no duplicate side effect.
The Second Part Nobody Handles: Out-of-Order Delivery
This one's subtler and catches even developers who already handled duplicate delivery. Stripe does not guarantee checkout.session.completed arrives before a later customer.subscription.updated for the same customer. If your handler for the later event assumes certain fields already exist because "the completed event must have run first," that assumption can be wrong.
// ❌ Assumes the user record already has stripeCustomerId set
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await User.findOneAndUpdate(
{ stripeCustomerId: subscription.customer as string }, // might not be set yet
{ subscriptionStatus: subscription.status }
);
break;
}
If this event somehow arrives before the customer ID was ever set on the user record, this update silently matches nothing, and the update just disappears with no error. The fix is designing each handler to be safe regardless of arrival order, either by looking up the user a different way (Stripe's customer ID stored at signup, not only set during checkout completion), or by handling a "no match found" case explicitly instead of assuming it can't happen.
The Actual Checklist
Store and check event IDs before processing anything. This alone fixes the majority of real-world duplicate delivery issues.
Never assume event order. Design each event handler to work correctly on its own, not dependent on a specific event having already run first.
Always return a 200 quickly, even for events you don't care about. A slow or failing response is exactly what triggers Stripe's retry logic in the first place, so an unhandled event type returning an error status can actually be the thing causing your own duplicate-delivery problem.
Log every event processed, with its ID, somewhere you can actually query later. When something does go wrong with a payment, having a real record of which events arrived and when is the difference between a five-minute investigation and a genuinely confusing debugging session.
If you've got a Stripe webhook handler in production right now, go check whether it's actually idempotent, not whether it works when you test it once locally. Drop what you find in the comments, curious whether this is as common in the wild as what I've personally run into reviewing client codebases.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)