ProdVerdict
← Blog
BillingJune 30, 20263 min read

Stripe webhook idempotency in production (checklist)

Raw body verification, evt_ dedup, return 200 before slow work — the Stripe webhook patterns that stop duplicate charges and silent drift.

stripe webhook idempotencywebhook signature verificationstripe webhook handlerevt_ deduplicationstripe webhook productionsubscription webhook

Stripe will send the same evt_... more than once. That is not a bug. It is at-least-once delivery.

If your handler is not idempotent, you double-grant access, double-charge side effects, or skip updates because an early return thinks the job is done.

This is the production checklist I use before trusting a webhook endpoint.

1. Verify the signature on the raw body

Stripe signs the raw request bytes. If you parse JSON first and re-serialize, verification fails.

typescript
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
const event = stripe.webhooks.constructEvent(rawBody, signature!, webhookSecret);

Common failure: request.json() before constructEvent. Works in dev with the CLI forwarder, breaks in prod with real signatures.

2. Return 2xx before slow work

Stripe retries if your handler takes too long. Queue the work, acknowledge fast.

PatternRisk
Update DB inside the HTTP handlerTimeout → retry → duplicate processing
Push to queue, return 200Retry safe if consumer is idempotent

See Base44's paid-but-no-access guide: handlers that swallow errors and still return 200 stop retries but never fix the row.

3. Dedup on evt_... with a unique constraint

sql
create table stripe_webhook_events (
  event_id text primary key,
  received_at timestamptz not null default now()
);
typescript
const inserted = await db.query(
  `insert into stripe_webhook_events (event_id) values ($1) on conflict do nothing returning event_id`,
  [event.id]
);
if (inserted.rowCount === 0) return; // already processed

Without this, a retry replays customer.subscription.updated and you flip has_paid_access twice or apply a stale payload.

4. Refetch live subscription state on critical events

Events can arrive out of order. Do not trust event order for access changes.

typescript
if (event.type.startsWith('customer.subscription.')) {
  const sub = await stripe.subscriptions.retrieve(event.data.object.id);
  await syncAccessFromSubscription(sub);
}

5. Reconcile anyway

Idempotency stops duplicate damage. It does not catch the handler branch you never wrote.

Run a nightly Stripe vs Postgres compare. ProdVerdict's Access contract does this on a schedule. DIY cron works too. See scheduled reconciliation.

What idempotency does not fix

  • Missing customer.subscription.deleted handler
  • Wrong stripe_customer_id column name in your lookup
  • Session cache showing free after DB is fixed

Those are drift bugs. Catch them with live reconciliation, not unit tests with mocks.

Try ProdVerdict on fixtures

$
npx prodverdict demo

For the broader case (why webhooks alone are not enough), read Stripe and Postgres drift evidence.

— mattbaconz

Try it in 60 seconds

Run the fixture demo — no Stripe key or database required.

$fixture demo
npx prodverdict@latest check access --config examples/nextjs-stripe/prodverdict.yml --fixtures --fixtures-dir examples/nextjs-stripe/scenarios/fail-revenue-leak

Comments

Guest comments. No account required. Max 300 words.

Loading comments…

0/300 words