What is a idempotency?
Idempotency means processing the same webhook event twice produces the same result as processing it once — no duplicate side effects. It's mandatory for production webhook handlers because every major provider retries on failure, which means at-least-once delivery, which means duplicates will happen. The standard pattern: store every event ID you've seen in a durable dedup-store (Postgres `processed_events` table, Redis `SET NX` with TTL), and check before processing. Subtle pitfall: idempotency at the HTTP layer (return 200 on duplicate) is not the same as idempotency at the business-logic layer — do the side-effect work and the dedup-key write in the same transaction.
An idempotent handler can safely receive the same event multiple times. The first delivery does the work; subsequent deliveries detect "already processed" and return 200 without re-doing anything. Idempotency is mandatory for production webhook handlers because every major provider retries on failure, which means at-least-once delivery, which means duplicates will happen.
The pattern is simple: each event has a unique ID (evt_1MnHyz... for Stripe, the X-GitHub-Delivery UUID for GitHub). Your handler stores every event ID it has fully processed. On every inbound webhook, it checks the store first. If the ID is present, return 200 immediately and skip the rest. If absent, process the event, store the ID, return 200.
Where to store the IDs:
- Postgres: a processed_events table with the event ID as primary key. INSERT ... ON CONFLICT DO NOTHING is the canonical idiom — try to insert, if it fails due to PK conflict the event was already processed.
- Redis: SET event_id "" EX 86400 NX — set with no-overwrite and a 24-hour TTL. NX returns null if the key existed, which means duplicate.
- The business object: in some cases the natural key in your domain works. If the webhook is "order shipped" and shipments.tracking_number is unique, an upsert on that table is itself idempotent. Use this when you can; it avoids a separate idempotency store.
The pitfall: idempotency at the HTTP layer (return 200 on duplicate) is not the same as idempotency at the business logic layer (don't double-charge, don't send two emails). A naïve handler that does charge(); send_email(); store(event_id) is broken — if it crashes between charge and store, the next retry charges again. The fix is to do the side-effect-bearing work and the dedup-key write in the same transaction, or to make the side effects themselves idempotent (with their own keys).
For HTTP calls to other services, the standard idempotency-key pattern: include a header like Idempotency-Key: <event_id> so the downstream service dedupes too. Stripe's API supports this, as do most modern payment APIs. Combine handler-level dedup with downstream idempotency keys for end-to-end safety.
Storage retention: keep event IDs for at least as long as the source's max retry window. Stripe retries 3 days, so keep IDs for 7 days minimum (with margin for clock skew, slow retries, and out-of-order delivery). After that, age out — eternal storage isn't necessary.
Example
INSERT INTO processed_events (event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;
-- If RETURNING is empty, this was a duplicate.// Returns null if key existed → duplicate
const ok = await redis.set(
`evt:${eventId}`, '1',
'EX', 86400 * 7, // 7-day TTL
'NX' // only set if not exists
)
if (!ok) return res.status(200).send('duplicate')See Idempotency in real traffic
WebhookWhisper captures every webhook with full headers, body, signature, and timing — so concepts like idempotency stop being abstract and become something you can inspect.
Start Free