# 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.

- **Published:** 2025-04-01
- **Author:** Clixo
- **Reading time:** 5 min read
- **Tags:** webhooks, idempotency, backend, integrations
- **Canonical URL:** https://clixo.sh/blog/how-to-implement-webhook-idempotency

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:

1. Your endpoint receives `evt_abc123` and starts processing.
2. Processing takes 3 seconds. The sender's timeout is 2 seconds.
3. The sender marks delivery as failed and retries.
4. Your endpoint receives `evt_abc123` again — and processes it a second time.

With idempotency in place, the second delivery is a no-op.

## The Core Deduplication Pattern

```mermaid
flowchart TD
  A["Webhook arrives"] --> B["Verify signature"]
  B --> C["Extract stable event ID"]
  C --> D{"Event ID in dedup store?"}
  D -- Yes --> E["Return 200 immediately"]
  D -- No --> F["Atomic insert into dedup store"]
  F --> G["Return 200 to sender"]
  G --> H["Enqueue background job"]
  H --> I["Execute business logic"]
  I --> J["Mark event as succeeded"]
```

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**: `id` field in the JSON body (e.g., `evt_1PxABC...`)
- **GitHub**: `X-GitHub-Delivery` request header
- **Shopify**: `X-Shopify-Webhook-Id` request 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:

```sql
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](https://clixo.sh/#contact).

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
