Three ways to make Stripe webhooks idempotent (and four traps I hit)
Stripe delivers webhooks at least once, not exactly once. Duplicate deliveries mean duplicate fulfilment, duplicate emails, duplicate charges. Three approaches, from rough to robust, with working code.
If you have integrated Stripe, you have written something like this:
case "checkout.session.completed":
await deliverProduct(session.metadata.order_id);
break;
This will break in production. Not “might”. Will.
Why
Stripe’s webhook delivery is at-least-once. The docs say so plainly, and it is easy to skim past.
Deliveries repeat when:
- Your endpoint takes longer than the timeout (~20s) to return
- You return 5xx
- A network blip eats the response
- You manually resend an event from the Dashboard
- Stripe retries — over 3 days by default, with increasing intervals
Result: for one order, deliverProduct may run twice or three times.
For digital goods that means two delivery emails, two download links, two AI generations (real token cost). For physical goods, two shipments.
Approach 1: event-ID dedupe table
Every Stripe event has a stable event.id (evt_1Nxxx). Store it; a primary-key conflict means you have already handled it.
create table processed_events (
event_id text primary key,
type text not null,
created_at timestamptz default now()
);
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const sig = req.headers.get("stripe-signature");
if (!sig) return new NextResponse("no signature", { status: 400 });
// ⚠️ raw text, not req.json()
const raw = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(raw, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new NextResponse("invalid signature", { status: 400 });
}
const { error } = await supabase
.from("processed_events")
.insert({ event_id: event.id, type: event.type });
if (error?.code === "23505") {
return NextResponse.json({ received: true, duplicate: true });
}
if (error) {
// let Stripe retry
return new NextResponse("db error", { status: 500 });
}
switch (event.type) {
case "checkout.session.completed":
await deliverProduct(event.data.object.metadata.order_id);
break;
}
return NextResponse.json({ received: true });
}
Pro: universal, works for any event type, decouples dedupe from business logic.
Con: an extra table, and ordering matters. If you insert the dedupe row first and the business step then fails, the retry is treated as a duplicate and skipped.
Two orderings:
- insert first, then process — safe from double-processing, but can drop events
- process first, then insert — can double-process, never drops
For fulfilment, dropping is worse than duplicating. So: process, then record. On failure, do not record, let Stripe retry.
Approach 2: conditional update (simplest)
If your order already has a state machine, you do not need another table. The whole trick is in the WHERE:
const { data, error } = await supabase
.from("orders")
.update({ status: "paid", paid_at: new Date().toISOString() })
.eq("id", orderId)
.eq("status", "pending") // ← this line is the idempotency
.select();
if (error) return new NextResponse("db error", { status: 500 });
if (!data?.length) {
// no row updated → either missing or already paid
return NextResponse.json({ received: true, alreadyProcessed: true });
}
await deliverProduct(orderId);
UPDATE ... WHERE status = 'pending' is atomic. Two concurrent requests serialise in the database; exactly one matches the row.
Pro: no extra table, concurrency-safe by construction. Con: only works for one-way state transitions.
Approach 3: idempotency key with a unique index
When the side effect happens outside your system (email, third-party API), a state machine will not save you. Use a business idempotency key.
create table deliveries (
order_id uuid not null,
kind text not null, -- 'email' | 'download' | 'refund'
created_at timestamptz default now(),
unique (order_id, kind)
);
const { error } = await supabase
.from("deliveries")
.insert({ order_id: orderId, kind: "email" });
if (error?.code === "23505") {
return NextResponse.json({ received: true, skipped: true });
}
await sendDeliveryEmail(orderId); // only the request that won the unique key runs this
This is “claim first, act second”, and it dedupes by business action rather than by event, which is more precise than approach 1.
Four traps
Trap 1: req.json() before signature verification
// ❌ wrong
const body = await req.json();
const event = stripe.webhooks.constructEvent(JSON.stringify(body), sig, secret);
JSON.stringify does not reproduce the original bytes (whitespace, escaping, key order can all change). Signature verification fails every time.
Use await req.text().
Trap 2: slow work inside the webhook
Stripe times out around 20 seconds. If you generate an 800–1200 word AI reading in there, you will hit it.
Timeout → retry → generate again.
Correct pattern: webhook writes state and enqueues; the actual delivery runs async.
await markOrderPaid(orderId);
await enqueueDelivery(orderId);
return NextResponse.json({ received: true });
Trap 3: assuming event order
Stripe does not guarantee ordering. You may get payment_intent.succeeded before checkout.session.completed. If the former assumes the order exists and the latter creates it, you get a null dereference.
Do not depend on order. Every handler must tolerate missing prerequisites, or key everything off the order ID rather than the session object.
Trap 4: swallowing errors
try {
await handle(event);
} catch (e) {
console.log(e); // ❌ then return 200
}
return NextResponse.json({ received: true });
Returning 200 tells Stripe “handled, stop retrying”. If you return 200 inside catch, that order is lost forever.
Retryable errors → 5xx. Only return 2xx/4xx for things that genuinely will not succeed (bad signature, missing order).
Production checklist
[ ] Verify with req.text(), never req.json()
[ ] A dedupe mechanism exists (table / conditional update / unique key)
[ ] Dedupe survives concurrency (never an in-memory Set)
[ ] No slow work in the webhook; anything >3s is queued
[ ] No assumptions about event order
[ ] Failures return 5xx; nothing swallowed
[ ] Resend an event three times from the Dashboard and confirm exactly one side effect
[ ] There is a reconciliation script: Stripe charges vs database orders
I added that last one after getting burned. Missing events never raise an error — they just leave orders stuck in pending forever.
On my own site, 76 orders produced 44 pending. Most were people who opened checkout and closed it (normal). But without reconciliation you cannot tell “user abandoned” from “webhook never arrived”.
The point
Idempotency is not about preventing a second execution. It is about making the second execution produce the same result as the first.
The first requires external state (a dedupe table). The second can come from the data itself (a conditional update). Prefer the second: it is concurrency-safe without any extra mechanism.
Does your site have these problems?
Run the free audit tool for a 30-second report, or just write to me and describe the situation — I will tell you whether it is worth fixing.