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 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.
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.
| Pattern | Risk |
|---|---|
| Update DB inside the HTTP handler | Timeout → retry → duplicate processing |
| Push to queue, return 200 | Retry 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
create table stripe_webhook_events (
event_id text primary key,
received_at timestamptz not null default now()
);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 processedWithout 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.
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.deletedhandler - Wrong
stripe_customer_idcolumn 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 demoFor 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.
npx prodverdict@latest check access --config examples/nextjs-stripe/prodverdict.yml --fixtures --fixtures-dir examples/nextjs-stripe/scenarios/fail-revenue-leakRelated posts
Scheduled Stripe–Postgres reconciliation beats PR-only billing tests
Why Access checks belong on a cron, not every pull request — and how to install nightly billing drift detection in GitHub Actions.
July 1, 2026
Stripe Entitlements migration checklist (before you cut over)
Moving from Postgres paid flags to Stripe Entitlements? Pitfalls, dual-write traps, and how to verify billing vs DB before you flip the switch.
June 30, 2026
Stripe and Postgres drift apart. Here's the proof.
Webhooks are not enough. Stripe docs, practitioner writeups, and reconciliation patterns for when billing state and your database disagree.
June 29, 2026
Comments
Guest comments. No account required. Max 300 words.
Loading comments…