How to Implement Webhook Idempotency: A Practical Guide
Learn how to implement webhook idempotency using event ID deduplication to safely handle retries without processing the same event twice.
Your webhook endpoint is going to receive the same event more than once. Not because something is broken — because every reliable webhook sender retries on anything other than a clean 2xx response. Network hiccups, timeouts, and transient errors all trigger retries. If your handler is not idempotent, duplicate processing is not a risk; it is a certainty.
This guide walks through how to implement webhook idempotency correctly, from the core deduplication pattern to edge cases that will trip you up in production.
Why Webhook Idempotency Matters
An idempotent operation produces the same result whether it runs once or a hundred times. For webhooks, this means that receiving the same payment.succeeded event twice must not charge a customer twice, create two records, or send two confirmation emails.
Most webhook providers — Stripe, GitHub, Shopify, Twilio — stamp each event with a stable, unique ID. That ID stays identical across every retry of the same event. It is the key you use to deduplicate.
Without idempotency, the sequence looks like this:
- Your endpoint receives
evt_abc123and starts processing. - Processing takes 3 seconds. The sender's timeout is 2 seconds.
- The sender marks delivery as failed and retries.
- Your endpoint receives
evt_abc123again — and processes it a second time.
With idempotency in place, the second delivery is a no-op.
The Core Deduplication Pattern
The pattern is simple: before doing any meaningful work, check whether you have already processed this event ID. If yes, return 200 immediately. If no, record it and proceed.
Step 1 — Extract the event ID
Every provider puts the ID somewhere slightly different:
- Stripe:
idfield in the JSON body (e.g.,evt_1PxABC...) - GitHub:
X-GitHub-Deliveryrequest header - Shopify:
X-Shopify-Webhook-Idrequest header
Read the provider's documentation and extract this value at the top of your handler before any other logic.
Step 2 — Check and record atomically
Use your database or cache to check-and-insert in a single atomic operation. A simple SQL approach:
INSERT INTO processed_webhook_events (event_id, received_at)
VALUES ($1, NOW())
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id;If the RETURNING clause comes back empty, the event was already processed — return 200 and stop. If it returns the row, you are the first handler — proceed with your business logic.
With Redis, SET event_id 1 NX EX 86400 gives you the same atomic check-and-set with a 24-hour TTL.
Step 3 — Return 200 before doing slow work
Return an HTTP 200 or 202 immediately after recording the event ID, then hand the actual processing off to a background job. This avoids triggering the sender's timeout, which is often 5–30 seconds.
[Webhook arrives] → verify signature → check dedup store
→ if duplicate: return 200 immediately
→ if new: save event ID, enqueue background job, return 200
→ [background job]: execute business logic
Storage Options for the Dedup Store
Relational database (Postgres, MySQL): Add a processed_webhook_events table with a unique index on event_id. Simple and durable. Works well if your request volume is moderate.
Redis with TTL: Fast and low-overhead. Set TTL to the provider's maximum retry window plus a safety margin. Stripe retries for up to 3 days, so a 4-day TTL covers the window.
DynamoDB or similar: Good if you are already on AWS and want serverless scaling. Use a conditional write on the event ID attribute.
Edge Cases to Handle
Processing failures after recording: If you record the event ID but your background job fails, you will never retry it because the dedup store says it is done. Use a status column — pending, succeeded, failed — and only move to succeeded when the job completes. A separate dead-letter processor handles failed rows.
Ordering: Idempotency does not guarantee order. payment.updated may arrive before payment.created if the network is unlucky. Design your handlers to be order-tolerant, or use a sequence number from the provider if one is available.
Clock drift and replay attacks: Always validate the event timestamp alongside the signature. Most providers embed a timestamp in the signature header. Reject events older than 5 minutes to block replay attacks.
Quick Checklist
- Extract the provider's stable event ID from every request.
- Perform an atomic check-and-insert before doing any meaningful work.
- Return 2xx immediately; process asynchronously.
- Track processing status, not just receipt.
- Set your dedup store TTL to exceed the provider's maximum retry window.
- Handle the case where processing fails after the event is recorded.
Building integrations that handle real-world webhook delivery at scale is exactly the kind of work Clixo specialises in. If you need a production-grade integration architecture that does not break at 3 AM, start a conversation with us.