ON THIS PAGE (14 SECTIONS)
- ✓Verify the webhook signature before you touch any dedup logic — an unauthenticated request should never be able to poison your lock keys.
- ✓Claim each delivery with an atomic Redis SET NX EX lock, then hand the real work to a queue instead of processing inline inside the HTTP request.
- ✓Redis is a fast dedup layer, not a durable one — a database-level unique constraint is your real defense against duplicate writes.
Any system that receives webhooks — payment providers, ad platforms, messaging APIs — will eventually receive the same delivery more than once, because the sender's retry policy assumes failure whenever it doesn't get a fast response. The fix isn't to write handlers that hope duplicates never happen. It's to acknowledge before you process, deduplicate atomically before you acknowledge to your own workers, and write every handler as if it will run more than once anyway, because eventually it will.
Retries happen for reasons entirely outside your control: a network blip between the sender and your load balancer, a slow database write that trips the sender's own timeout, or a scheduled redelivery job on the sender's side that has no idea you already processed the first attempt. Processing a webhook inline, inside the same request that acknowledges it, is exactly the pattern that turns a slow response into a duplicate delivery — the sender times out waiting for your 200, assumes failure, and retries while your first request is still running.
The Three-Stage Pipeline: Verify, Deduplicate, Enqueue
Stage 1 — Verify the Signature First
Order matters here. Deduplication has to happen after signature verification, never before — otherwise an unauthenticated request with a guessed or garbage delivery ID could occupy a lock key and cause a legitimate delivery with the same ID to be silently dropped.
| 1 | function verifyWebhookSignature(req, secret) { |
| 2 | const signature = req.headers['x-webhook-signature']; |
| 3 | const expected = crypto |
| 4 | .createHmac('sha256', secret) |
| 5 | .update(req.rawBody) |
| 6 | .digest('hex'); |
| 7 | |
| 8 | return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); |
| 9 | } |
Stage 2 — Claim the Delivery With an Atomic Lock
Once the signature checks out, claim the delivery ID with a single atomic Redis command. `SET NX EX` sets the key only if it doesn't already exist and attaches an expiry in the same call, so there's no race between checking for existence and setting the key — a check-then-set done as two separate commands is exactly the kind of race condition this whole pipeline exists to avoid.
| 1 | async function claimDelivery(deliveryId, ttlSeconds = 30) { |
| 2 | const lockKey = `webhook:lock:${deliveryId}`; |
| 3 | const claimed = await redis.set(lockKey, 'processing', 'NX', 'EX', ttlSeconds); |
| 4 | |
| 5 | if (!claimed) { |
| 6 | logger.info(`Duplicate delivery ${deliveryId} discarded at the gate.`); |
| 7 | return false; |
| 8 | } |
| 9 | |
| 10 | await queue.add('process-webhook', { deliveryId }, { |
| 11 | attempts: 5, |
| 12 | backoff: { type: 'exponential', delay: 2000 } |
| 13 | }); |
| 14 | |
| 15 | return true; |
| 16 | } |
Stage 3 — Acknowledge Fast, Process Async
The HTTP handler returns immediately after the lock is claimed and the job is enqueued — a 202 Accepted, not a 200 that waits for the actual processing to finish. The real work happens in a worker that can retry independently of the original request.
| 1 | const worker = new Worker('process-webhook', async (job) => { |
| 2 | await handleWebhookPayload(job.data.deliveryId); |
| 3 | }, { connection: redisConnection, concurrency: 10 }); |
| 4 | |
| 5 | worker.on('failed', async (job, err) => { |
| 6 | if (job.attemptsMade >= job.opts.attempts) { |
| 7 | await deadLetterQueue.add('failed-webhook', job.data); |
| 8 | logger.error(`Delivery ${job.data.deliveryId} moved to DLQ: ${err.message}`); |
| 9 | } |
| 10 | }); |
Wrong vs Right: Processing Inline vs Handing Off to a Queue
The anti-pattern is subtle because it works fine at low volume: the handler verifies the signature and then does the real work — writing to the database, calling another service — before responding. It reads simply and passes every manual test. Under load, or the moment a downstream call gets slow, it's exactly what causes the sender's own timeout to fire and trigger the retry that creates the duplicate this whole pipeline is meant to prevent.
| 1 | // Webhook handler |
| 2 | - app.post('/webhooks/orders', async (req, res) => { |
| 3 | - if (!verifyWebhookSignature(req, secret)) return res.sendStatus(401); |
| 4 | - await syncOrderToDatabase(req.body); // can be slow; sender may time out and retry |
| 5 | - await notifyDownstreamServices(req.body); // a second slow call, same request |
| 6 | - res.sendStatus(200); |
| 7 | - }); |
| 8 |
| 1 | // Webhook handler |
| 2 | |
| 3 | + app.post('/webhooks/orders', async (req, res) => { |
| 4 | + if (!verifyWebhookSignature(req, secret)) return res.sendStatus(401); |
| 5 | + const claimed = await claimDelivery(req.headers['x-delivery-id']); |
| 6 | + if (!claimed) return res.sendStatus(202); // already seen, ack and move on |
| 7 | + res.sendStatus(202); // ack immediately, real work happens in the worker |
| 8 | + }); |
Handling Out-of-Order Delivery
Deduplication solves for the same event arriving twice. It doesn't solve for two different events about the same resource arriving out of order — a sender's own retry queue or load balancing can deliver an "order updated" event after a later "order cancelled" event that was actually sent first. If your handler blindly applies whatever arrives last, you can end up applying an older state on top of a newer one. The fix is to carry a sequence number or timestamp in the payload and have the handler compare it against the last-applied value for that resource before writing, discarding anything older instead of assuming delivery order matches send order.
| 1 | UPDATE orders |
| 2 | SET status = $1, updated_at = $2 |
| 3 | WHERE id = $3 AND updated_at < $2; -- no-op if we already have a newer event |
Rotating Webhook Secrets Without Downtime
The signing secret itself needs to be rotatable without a coordinated flag day, because the sender and receiver rarely deploy at the same moment. Accept two active secrets during a rotation window — verify against the new one first, fall back to the old one, and only remove the old secret once you've confirmed the sender has switched. Treat the secret the same way you'd treat a database credential: stored in a secrets manager, not committed anywhere, and rotated on a schedule rather than only when something goes wrong.
Guarding Against Replay of Old, Validly-Signed Requests
A signature proves the payload wasn't tampered with. It doesn't prove the request is fresh — a captured, validly-signed request can be replayed later by anyone who intercepted it. Most webhook providers include a timestamp header alongside the signature specifically so receivers can reject anything too old, even if the signature itself still checks out.
| 1 | function isFreshDelivery(req, maxSkewSeconds = 300) { |
| 2 | const sentAt = Number(req.headers['x-webhook-timestamp']); |
| 3 | if (!sentAt) return false; |
| 4 | return Math.abs(Date.now() / 1000 - sentAt) <= maxSkewSeconds; |
| 5 | } |
Why Redis Alone Isn't Enough
Redis can restart, evict keys under memory pressure, or fail over mid-lock. Treat the lock as a fast, cheap optimization that stops most duplicates at the door — not as your actual correctness guarantee. The real guarantee belongs to the database: a unique constraint on the delivery ID and an upsert that no-ops on conflict.
| 1 | ALTER TABLE webhook_events |
| 2 | ADD CONSTRAINT webhook_events_delivery_id_unique UNIQUE (delivery_id); |
| 3 | |
| 4 | INSERT INTO webhook_events (delivery_id, payload, processed_at) |
| 5 | VALUES ($1, $2, NOW()) |
| 6 | ON CONFLICT (delivery_id) DO NOTHING |
| 7 | RETURNING id; |
Picking Lock TTLs Without Guessing
Too short a TTL and a duplicate can slip through while the first attempt is still processing. Too long, and a legitimate retry after a real transient failure gets silently dropped for no good reason. A two-key pattern separates these concerns cleanly: a short-lived "processing" lock that guards against near-simultaneous duplicates, and a long-lived "done" record that guards against much later redeliveries.
| 1 | async function claimAndFinalize(deliveryId, handler) { |
| 2 | const doneKey = `webhook:done:${deliveryId}`; |
| 3 | if (await redis.get(doneKey)) return false; // already fully processed |
| 4 | |
| 5 | const lockKey = `webhook:lock:${deliveryId}`; |
| 6 | const claimed = await redis.set(lockKey, '1', 'NX', 'EX', 60); |
| 7 | if (!claimed) return false; |
| 8 | |
| 9 | await handler(deliveryId); |
| 10 | await redis.set(doneKey, '1', 'EX', 60 * 60 * 24); |
| 11 | return true; |
| 12 | } |
Backpressure and the Dead Letter Queue
Monitor queue depth, not just error rate — a growing backlog is often the earlier warning sign. Jobs that exhaust their retry budget should land in a dead letter queue rather than disappear; once you've fixed whatever caused the failures, replay tooling for the DLQ lets you reprocess those deliveries deliberately instead of hoping the sender retries again on its own schedule.
What to Actually Alert On
Three numbers matter more than raw error counts for this kind of pipeline. Queue depth over time tells you whether workers are keeping up or falling behind. The rate of "duplicate discarded at the gate" log lines tells you whether a sender has started retrying more aggressively than usual, which is often the first sign something downstream got slow. And DLQ growth tells you whether a category of job is failing systematically rather than transiently — a single job failing five times and landing in the DLQ is normal; twenty unrelated jobs doing the same thing in the same minute is an incident.
Alert on trend, not just threshold. A queue depth of 500 is unremarkable during a traffic spike and alarming if it holds steady for twenty minutes after the spike ends — the second case means workers stopped keeping up, not that load is temporarily high.
What This Looks Like at Vasiva
Dima ingests webhook and callback traffic from several external sources — ad platforms and messaging channels among them — into its unified inbox and performance views. The shape is the same lock-then-queue pipeline described here: verify, claim, enqueue, and let a database constraint be the final word on whether something has already been recorded.
If duplicate writes, timeout-driven retries, or a webhook pipeline that's starting to buckle under load sound familiar, that's the kind of problem our Product Engineering and Internal Tools work is built around. Reach out if you want a second opinion on your event pipeline.
References & Prior Art
RFC 9110: HTTP Semantics — Section 9.2.2 Idempotent Methods
Roy T. Fielding, Mark Nottingham, Julian Reschke • Internet Engineering Task Force (IETF) (2022)
Standard Webhooks: Modern, Secure, Reliable Webhook Specifications
Standard Webhooks Community • Standard Webhooks Organization (2024)
Enterprise Integration Patterns: Idempotent Receiver Pattern
Gregor Hohpe, Bobby Woolf • Addison-Wesley (2003)
Vikas
Software EngineerVikas works on Vasiva's product engineering, focusing on webhook reliability, atomic Redis locks, and resilient background queues.
