How to make Stripe webhooks idempotent (and stop double-charging customers)
Stripe retries webhooks, and a naive handler turns those retries into duplicate orders and duplicate charges. Here's the event-ID idempotency pattern, with a Next.js App Router route handler that verifies signatures first.
A customer gets charged once. Your database says they paid twice. Or worse: your fulfillment system ships two of something, your ledger records two subscriptions, and the first person to notice is the customer writing an angry email.
Almost every time I've been called in to look at this, the charge itself was fine. Stripe charged the card once. The duplication happened on our side, because the same webhook event was delivered more than once and the handler did its work more than once. That's not a Stripe bug. It's the documented behaviour of an at-least-once delivery system meeting a handler that assumed at-most-once.
This guide covers why the retries happen, the pattern that makes them harmless, and a Next.js App Router route handler that implements it.
The symptom
The shape is always similar. A checkout.session.completed or invoice.payment_succeeded event fires. Your handler creates an order, grants entitlements, sends a receipt email, maybe posts a row into an accounting system. Some minutes later, the same thing happens again — same customer, same amount, same session — and now you have two orders.
Look at the two records and you'll usually find their Stripe event IDs are identical, or that they trace back to the same underlying object. That identity is the whole solution, but you can only use it if you're storing it.
The reason it slips through testing is that in development the endpoint is fast and healthy, so it gets one delivery and one delivery only. Retries surface in production, under load, on the day your database is slow — which is also the day you least want to be reconciling duplicate orders by hand.
Why Stripe retries webhooks at all
Stripe retries because the alternative is silently losing events, and silently losing a payment event is far worse than delivering one twice.
From Stripe's side, a delivery is successful only when your endpoint returns a 2xx status quickly. Anything else is a failure, and there are more ways to fail than most people account for:
- Your endpoint returned a non-2xx status. A 500 from an unhandled exception, obviously — but also a 401 because a security layer blocked an unauthenticated request, a 404 after a route rename, or a 302 from a redirect rule that swallows POSTs.
- Your endpoint timed out. Stripe won't wait indefinitely. If your handler does slow work inline — a third-party API call, an email send, a report generation — you can process the event fully and still be recorded as failed because you answered too late. Stripe retries; you process it again. This is the single most common source of duplicates I see, and it's the most counterintuitive, because the logs show the work succeeding both times.
- The network dropped between you. A connection reset after your server committed but before the response landed looks identical to a total failure from Stripe's vantage point.
- Your deploy was mid-flight. Rolling deploys, cold starts, and a container being drained all produce brief windows where requests fail.
Retries then back off over a period of days rather than weeks. The practical consequence: an event you botched on Tuesday can land again on Thursday, long after the incident you associated it with is closed.
There's a second, more mundane source of repeats. If you have two endpoints registered, or a test and live endpoint pointed at the same URL, or you replay events from the Stripe dashboard while debugging, you'll get deliveries you didn't plan for. Idempotency covers all of these the same way.
The fix: treat the event ID as a primary key
Every Stripe event has an id that looks like evt_1PabcXYZ.... It is stable across retries of the same event. That's the entire foundation of the fix.
The pattern has four parts, and the order matters:
1. Verify the signature before you trust anything. The payload is an unauthenticated POST body from the public internet until you've checked the Stripe-Signature header against your endpoint's signing secret. Do this first, on the raw bytes, before any parsing or branching. An attacker who can forge events can grant themselves entitlements, and no amount of idempotency logic helps if the event was never real.
2. Record the event ID in your own database. Not in memory, not in a cache with an eviction policy you don't control. A real table with a unique constraint on the event ID. In-memory sets die with the process, and on serverless platforms the process dies constantly.
3. Check and skip before doing the work. If the ID is already recorded, return 200 immediately and do nothing else. Returning 200 is important: a 409 or a 500 tells Stripe you failed, and it will keep retrying an event you've already handled.
4. Make the write and the side effect atomic where you can. If the marker and the business data live in the same database, do both in one transaction. If they don't, record the marker after the work succeeds and accept that a crash in between causes a replay — which is fine, because a replay of correctly-idempotent work is harmless. The failure mode to avoid at all costs is marking an event processed and then failing to do the work, because nothing will ever retry it.
Note that a naive check-then-act has a race: two concurrent deliveries can both read "not processed" before either writes. The unique constraint is what actually closes it — you attempt the insert, and if it violates uniqueness you know another worker owns the event. The check-first read is only an optimization to avoid pointless work.
A Next.js App Router implementation
Two things bite people specifically in the App Router. First, signature verification needs the raw body — a parsed and re-serialized JSON object will not verify, because key order and whitespace change the bytes. In the App Router you get the raw body with await req.text(), and you must not call req.json() first. Second, the route has to run in a Node.js runtime with access to your database, and it must not be statically optimized or cached.
The table is unglamorous. Something like:
CREATE TABLE processed_stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The primary key on event_id is doing the real work. Everything else is for debugging.
Then the route handler at app/api/stripe/webhook/route.ts:
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { db } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "missing signature" }, { status: 400 });
}
// Raw body — do NOT call req.json() before this.
const payload = await req.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
payload,
signature,
webhookSecret,
);
} catch (err) {
// Bad signature or malformed payload: 400 tells Stripe not to bother retrying.
return NextResponse.json({ error: "invalid signature" }, { status: 400 });
}
// Claim the event. The unique constraint arbitrates concurrent deliveries.
const claimed = await db.claimStripeEvent(event.id, event.type);
if (!claimed) {
// Already handled by an earlier delivery or a concurrent worker.
return NextResponse.json({ received: true, duplicate: true });
}
try {
await handleEvent(event);
} catch (err) {
// Release the claim so the retry can pick it up again.
await db.releaseStripeEvent(event.id);
// 500 asks Stripe to retry.
return NextResponse.json({ error: "handler failed" }, { status: 500 });
}
return NextResponse.json({ received: true });
}
claimStripeEvent is the interesting half. In Postgres it's an insert that swallows the conflict and tells you whether you won:
async function claimStripeEvent(eventId: string, eventType: string) {
const rows = await sql`
INSERT INTO processed_stripe_events (event_id, event_type)
VALUES (${eventId}, ${eventType})
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id
`;
return rows.length > 0; // true = we own this event
}
ON CONFLICT DO NOTHING ... RETURNING gives you an atomic test-and-set in a single round trip, with no advisory locks and no read-then-write window. If two deliveries arrive simultaneously, exactly one gets a row back.
The dispatch itself stays deliberately boring, and — importantly — stays fast:
async function handleEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await enqueue("fulfill-order", { sessionId: session.id, eventId: event.id });
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
await enqueue("extend-subscription", { invoiceId: invoice.id, eventId: event.id });
break;
}
default:
// Unhandled types are still recorded as processed. That's intentional.
break;
}
}
Sending real work to a queue rather than doing it inline is what keeps you inside Stripe's response window. It also gives the work its own retry semantics, independent of Stripe's — which is why the eventId is passed along: the background job should key its own side effects on it too, since queues are at-least-once as well.
Signature verification, and the four ways it goes wrong
Verification is four lines of code and still the step most likely to be quietly broken in a codebase that otherwise looks fine. The failure mode is nasty because a wrongly-implemented check usually fails closed in development — you see 400s, you get frustrated, and the fix someone reaches for is to skip verification "for now."
The four things that break it:
The body was parsed first. Any middleware, body parser, or framework convenience that reads the request before you do will hand you a re-serialized object whose bytes no longer match what was signed. In the App Router this means req.text() and nothing before it. If you're behind a proxy or an edge function that normalizes bodies, verify that it isn't rewriting them.
The wrong secret. Each endpoint has its own signing secret, and they are not interchangeable. Test-mode and live-mode endpoints have different secrets, and so do two live endpoints pointed at the same URL. The secret from the Stripe CLI's local listener is different again. When verification fails on deploy but works locally, this is the first thing to check.
Clock skew. Verification includes a timestamp tolerance to prevent replay attacks. A server whose clock has drifted far enough will reject genuine events. Rare on managed platforms, common on self-managed boxes with broken NTP.
Returning the wrong status on failure. A failed signature check should be a 400. If you return a 500, Stripe will retry a payload it can never get you to accept, and you'll spend a day chasing what looks like a delivery problem.
One rule worth writing on the wall: the endpoint should have exactly one path that reaches business logic, and that path should start after a successful constructEvent. No debugging shortcut, no environment flag, no "skip in staging" branch. Staging endpoints have their own secrets; use them.
Testing that the pattern actually works
You cannot verify idempotency by clicking through a checkout, because a healthy flow delivers each event once. You have to deliberately produce the duplicate.
The straightforward way is to replay. Trigger a real event against your local endpoint with the Stripe CLI's listener and forwarding, then re-send the same event a second time from the CLI or the dashboard's event view. The first delivery should do the work and return 200. The second should return 200 immediately, write nothing, and — this is the part to actually assert — leave your order count unchanged.
Then test the ugly cases, because those are the ones production will find:
- Slow handler. Insert an artificial delay long enough to trip Stripe's response window, and confirm that the retry that follows is absorbed by the claim rather than producing a second order.
- Crash mid-handler. Throw after the claim but before the side effect completes, and confirm the claim is released and the retry succeeds. This is the branch people forget, and a claim that's never released turns a transient failure into a permanently dropped payment.
- Concurrent delivery. Fire the same signed payload at the endpoint twice in parallel. Exactly one should do work. If both do, your test-and-set isn't atomic and you're relying on a read that lies.
Log the event ID on every delivery, including the duplicates you skip. When someone asks in three months why a customer's order looks strange, being able to grep one ID and see the full delivery history — first attempt, failure, retry, skip — turns an afternoon of guessing into a two-minute answer.
Ordering is a separate problem
Idempotency stops you doing the same thing twice. It does not guarantee you do things in the right order. Events can arrive out of sequence — a subscription update landing before the creation event it logically follows.
The mitigation isn't more deduplication; it's not trusting the payload as the source of truth for current state. When an event tells you something changed, treat it as a signal and re-fetch the object from Stripe's API to get its current state, then reconcile. The payload is a snapshot from when the event was generated; the API is authoritative now. For state machines that must not go backwards, store the object's last-seen version or timestamp and ignore updates older than what you've already applied.
What to check in your own code today
Three quick audits, in descending order of how often they find something:
- Does your handler do slow work inline? Time it under real conditions, not on your laptop. If a third-party call sits inside the request, you have a duplicate generator waiting for a bad day.
- Is there a unique constraint, or just a check? A
SELECTfollowed by anINSERTreads as correct and fails under concurrency. Look for the constraint in the schema, not the logic in the code. - Do you return 200 for events you don't handle? Unrecognized event types that fall through to a 500 will be retried indefinitely, filling your logs and masking the failures you care about.
Getting this right is a couple of hours of work and a table with three columns. Getting it wrong is a spreadsheet of manual refunds and a customer who no longer trusts your checkout.
Frequently asked
- What if two webhook events arrive at the exact same time?
- Don't rely on a read-then-write check to catch it — two concurrent requests can both read "not processed" before either writes. Put a unique constraint on the Stripe event ID column and let the database arbitrate: the first INSERT wins, the second fails with a uniqueness violation, and your handler treats that violation as "already handled" and returns 200. If the side effect and the marker can live in the same database, wrap them in one transaction so you can never mark an event processed that you didn't finish processing.
- Do I need this for client-side Stripe.js calls too?
- Not in the same form. Idempotency on the client side is about request retries, not event retries — when you create a PaymentIntent or a Customer from your server, you pass an Idempotency-Key header so a retried API call doesn't create a second object. That's a different mechanism from webhook idempotency, which is about the same event being delivered to you more than once. Most applications need both: idempotency keys on outbound writes to Stripe, and event-ID deduplication on inbound webhooks.
- How long should I retain processed event IDs?
- Long enough to outlive Stripe's retry window with a wide margin, and ideally longer for audit value. Retries taper off over a period of days rather than weeks, so a retention window measured in months is comfortably safe; keeping the table forever is also fine for most businesses because the rows are tiny. If the table's size ever becomes a real concern, prune rows older than your retention window on a schedule instead of deleting on a per-event basis — verify Stripe's current documented retry window before you pick the number.
- Should I do the actual work inside the webhook handler?
- Only if it's fast. Stripe expects a quick 2xx and will treat a slow endpoint as a failure and retry it, which is exactly the condition that produces duplicates. The durable pattern is to verify the signature, record the event, return 200, and hand the real work to a queue or background job — where the same event-ID key protects you a second time, because the job runner has its own retry behaviour.
Have a project like this?
Book a call