Stripe delivers webhooks at least once: duplicates and replays are part of the contract, not a bug. A production integration verifies the signature on the raw request body, deduplicates on the event id with a persistent unique constraint, returns 2xx immediately, and does the real work asynchronously. For state, it trusts the Stripe API rather than the event payload, and a reconciliation job covers whatever the webhooks miss.
Why does Stripe deliver the same event twice?
Stripe guarantees at-least-once delivery: if your endpoint times out, returns a non-2xx, or the connection drops after you processed the event, Stripe sends the same event again. In live mode it retries with exponential backoff for up to three days. Duplicates are therefore expected behavior, and your handler must treat them as routine input.
The retry logic cannot distinguish "your handler failed" from "your handler succeeded but the response was lost". If processing finished and the connection dropped before the 200 left your load balancer, Stripe resends. That is correct behavior on their side, per the webhook documentation: the only safe design assumption is that every event can arrive more than once.
How do you verify the signature without breaking the raw body?
Verification uses the Stripe-Signature header and your endpoint secret through stripe.webhooks.constructEvent, which needs the exact raw request body. Any body parser that runs first (express.json, a framework's default JSON handling) rewrites the bytes and breaks the check. Mount express.raw on the webhook route only, and keep every other route on the normal parser.
constructEvent recomputes an HMAC over the bytes received and compares it to the Stripe-Signature header, with a default tolerance of five minutes on the embedded timestamp, which limits replays of captured payloads. On Fastify, register a raw-body content type parser for that route: the principle is identical. For local testing, the Stripe CLI forwards events to localhost and can trigger or resend test events on demand.
How do you deduplicate events by event.id?
Every Stripe event carries a unique id (evt_...). Store it in a persistent table with a unique constraint and insert before processing: if the insert violates the constraint, you have already seen the event, so acknowledge and stop. This beats read-then-write, which lets two concurrent deliveries pass the existence check and process the event twice.
| Dedup strategy | Safe under concurrency | Survives restarts | Verdict |
|---|---|---|---|
| In-memory set | No | No | Fails on redeploys and multiple instances; avoid |
| Read-then-write (SELECT, then INSERT) | No | Yes | Two concurrent deliveries both pass the read |
| INSERT with unique constraint | Yes | Yes | The database arbitrates; catch the violation |
| Redis SET NX with TTL | Yes | Depends on persistence config | Reasonable when no relational store is available |
How long do you keep processed event ids?
Purge rows after about 30 days: live-mode retries stop after up to three days, and the Events API retains events for 30 days (as of this writing), which also covers manual resends from the dashboard. One caution: dedup on event.id is unrelated to the idempotency keys you send on outbound Stripe API calls (idempotent requests); a production system needs both.
Here is the shape of a handler that gets all of this right:
import express from 'express'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const app = express()
// Raw body on this route only: express.json() would break signature verification
app.post('/stripe/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
let event
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
)
} catch (err) {
return res.status(400).send(`Signature verification failed: ${err.message}`)
}
// Idempotent insert: the unique constraint on event_id is the dedup gate
try {
await db.query(
'INSERT INTO stripe_events (event_id, type) VALUES ($1, $2)',
[event.id, event.type]
)
} catch (err) {
if (err.code === '23505') return res.sendStatus(200) // duplicate: already recorded
return res.sendStatus(500) // storage failed: let Stripe retry
}
// Acknowledge now, process later: hand off to a queue or job runner
await enqueue({ eventId: event.id, type: event.type })
return res.sendStatus(200)
})
Unsure how your Stripe integration would behave under a burst of duplicates and replays? Describe your webhook pipeline: a one-page diagnosis within 48 hours.
Get my diagnosis →Why acknowledge with a 2xx before doing the work?
Return 200 as soon as the event is verified and recorded, then process it from a queue or job runner. Stripe expects a fast response (a slow endpoint counts as failing and triggers retries), and slow inline work (PDF generation, emails, third-party calls) multiplies duplicates exactly when your system is already under load.
A queue between the endpoint and the processing gives you retries you control, backpressure during spikes, and a dead-letter destination for events that keep failing. We cover the pattern in detail in our article on event-driven pipelines with Lambda and SQS; if you are still weighing whether a queue is warranted at all, start with when to use SQS.
What about out-of-order events?
Stripe does not guarantee ordering: invoice.paid can arrive before invoice.finalized, and a checkout.session.completed can land after the subscription events it triggered. Treat the event as a signal that something changed, then fetch the current object from the Stripe API and act on that state, not on the snapshot embedded in the payload.
The payload is a snapshot taken when the event was created; by the time a delayed retry arrives it can be minutes or days stale. Handlers that copy payload fields into the local database will eventually overwrite fresh state with old data. Fetching the object by id before acting costs one API call and removes an entire class of bugs; it also makes handlers naturally idempotent, since replaying an event re-reads the same current state.
What is the safety net when a webhook never arrives?
Webhooks are a notification channel, not a system of record. Endpoints go down, deploys drop traffic, misconfigured routes swallow events. Run a reconciliation job (hourly or daily depending on volume) that lists recent events or open objects through the Stripe API and re-processes anything your database has not seen. That job is the safety net.
Two practical notes. Stripe retries for up to three days in live mode and can disable an endpoint that keeps failing (after notifying you), so an outage longer than the retry window loses deliveries unless reconciliation exists. And keep the job idempotent by routing it through the same dedup gate as the webhook handler: reconciliation must never become a second source of duplicates.
The production checklist
- Mount express.raw (or the Fastify equivalent) on the webhook route only; verify with constructEvent on every request.
- Insert the event id into a table with a unique constraint before processing; on violation, return 200 and stop.
- Return 2xx as soon as the event is verified and recorded; run slow work behind a queue.
- Fetch the current object from the Stripe API before acting; never trust payload freshness or ordering.
- Purge dedup rows after about 30 days, in line with Stripe's event retention.
- Run a reconciliation cron against the Stripe API and alert when it finds events your database missed.