DEV Community

Cover image for Webhook idempotency: how to handle duplicate deliveries safely
Yuriy for Adal

Posted on • Originally published at adal.cloud

Webhook idempotency: how to handle duplicate deliveries safely

Webhook delivery is usually described as a simple flow:

Provider sends an event → your endpoint receives it → your application processes it
Enter fullscreen mode Exit fullscreen mode

In production, it is rarely that simple.

A webhook can be delivered more than once because of timeouts, network failures, retries, process restarts, or an HTTP response that was generated successfully but never reached the sender.

That means every webhook consumer should answer one question before it becomes an incident:

What happens if the same webhook is processed twice?

If the answer is “we create the same record again,” “we send another email,” or “we charge the customer a second time,” the integration needs idempotency.

Duplicate delivery is normal

Webhook providers often use at-least-once delivery semantics.

In practical terms, this means a provider tries to ensure that an event is delivered successfully. If it cannot confirm success, it may send the event again.

Consider this sequence:

1. A provider sends a webhook.
2. Your service receives it.
3. Your service creates an order in the database.
4. Your service responds with 200 OK.
5. A network issue prevents the provider from receiving that response.
6. The provider retries the webhook.
Enter fullscreen mode Exit fullscreen mode

From the provider’s perspective, the first attempt may have failed.

From your application’s perspective, the business action has already happened.

Without protection against duplicates, the retry may create a second order.

This can lead to duplicate payments, duplicate CRM entities, repeated emails, duplicated jobs, or inconsistent state in downstream systems.

What idempotency means

An operation is idempotent when performing it multiple times produces the same final state as performing it once.

For example, setting a user's status is naturally idempotent:

PUT /users/42/status
Content-Type: application/json

{
  "status": "active"
}
Enter fullscreen mode Exit fullscreen mode

Sending that request twice still leaves the user active.

Creating a payment is different:

POST /payments
Content-Type: application/json

{
  "amount": 100
}
Enter fullscreen mode Exit fullscreen mode

Without an additional mechanism, sending this request twice may create two payment operations.

Webhook consumers should be designed so that a retry does not repeat the business action.

Why payload comparison is not reliable

A common first attempt is to compare incoming request bodies.

For example, store the webhook payload and reject any future request with the same JSON.

This approach breaks down quickly.

Two separate events may have identical payloads:

{
  "event": "invoice.created",
  "amount": 1000,
  "currency": "USD"
}
Enter fullscreen mode Exit fullscreen mode

These could represent two different invoices with the same amount and currency.

The same problem applies to payload hashes. A hash makes comparisons easier, but it does not tell you whether two equal payloads represent:

  • the same event delivered again; or
  • two different events with the same contents.

There are also implementation questions that become surprisingly difficult:

  • Should timestamps be included?
  • Should signatures be included?
  • Does JSON key order matter?
  • Should query parameters be compared?
  • How do you handle binary payloads?
  • Which fields are stable across retries?

A payload is data. It is not necessarily an event identity.

Use an explicit idempotency key

The usual solution is an idempotency key: a unique identifier associated with one logical operation.

The sender includes the key with the request. The receiver stores it and ensures that the same key cannot trigger the same business action twice.

For example:

POST /webhooks/payment
Content-Type: application/json
X-Idempotency-Key: 01989d55-20df-7a72-87f9-6e50f3d78315

{
  "event": "payment.completed",
  "amount": 100
}
Enter fullscreen mode Exit fullscreen mode

The processing flow becomes:

1. Receive the webhook.
2. Read the idempotency key.
3. Atomically reserve or store the key.
4. If the key already exists, treat the request as a duplicate.
5. If the key is new, perform the business action.
Enter fullscreen mode Exit fullscreen mode

The key must be handled atomically.

A simple “check whether it exists, then insert it” flow is not safe under concurrent requests:

Request A checks for key → not found
Request B checks for key → not found
Request A creates order
Request B creates order
Enter fullscreen mode Exit fullscreen mode

The uniqueness check belongs in the database.

Example: PostgreSQL

A minimal schema might look like this:

CREATE TABLE processed_webhooks (
    idempotency_key TEXT PRIMARY KEY,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

Then reserve the key with a single statement:

INSERT INTO processed_webhooks (idempotency_key)
VALUES ($1)
ON CONFLICT (idempotency_key) DO NOTHING;
Enter fullscreen mode Exit fullscreen mode

Your application can inspect the number of inserted rows:

  • 1 row inserted: this is the first time the key was seen;
  • 0 rows inserted: the key was already processed.

For example, in Go:

result, err := db.ExecContext(
    ctx,
    `
        INSERT INTO processed_webhooks (idempotency_key)
        VALUES ($1)
        ON CONFLICT (idempotency_key) DO NOTHING
    `,
    idempotencyKey,
)
if err != nil {
    return err
}

rowsAffected, err := result.RowsAffected()
if err != nil {
    return err
}

if rowsAffected == 0 {
    // This webhook was already processed.
    return nil
}

// Perform the business action here.
Enter fullscreen mode Exit fullscreen mode

In a real system, you may also want to store:

  • the event type;
  • the processing status;
  • the response or created entity ID;
  • the original request ID;
  • failure details;
  • timestamps for cleanup and retention.

The exact design depends on whether repeated requests should receive the original result, a generic success response, or a specific “already processed” response.

Store keys for long enough

Idempotency keys are only useful while they are retained.

If a provider can retry a webhook several hours or days later, deleting the key after five minutes may reintroduce duplicate processing.

The retention period should be based on:

  • the provider’s retry policy;
  • your own retry policy;
  • the type of side effect;
  • how expensive or dangerous duplicate execution would be;
  • compliance and storage requirements.

For financial operations, order creation, provisioning, or external API calls, it is usually better to retain keys longer than the minimum expected retry window.

Idempotency is not only for payments

Idempotency keys are often associated with payment APIs, but they are useful anywhere a request can cause a side effect.

Typical examples include:

  • creating users or accounts;
  • provisioning infrastructure;
  • sending emails or messages;
  • updating CRM records;
  • triggering deployments;
  • creating support tickets;
  • synchronizing data to external systems;
  • scheduling jobs;
  • processing queue messages;
  • handling webhook events.

The more expensive or irreversible the action is, the more important duplicate protection becomes.

Adding idempotency keys with Adal

Not every webhook provider sends a suitable event ID or idempotency key.

Some providers include an event ID in the payload. Others expose it through a header. Some do not provide a stable identifier at all.

Adal can add an idempotency key when forwarding a webhook to a destination.

When enabled, Adal adds:

X-Adal-Idempotency: <unique-key>
Enter fullscreen mode Exit fullscreen mode

The setting is configured separately for each destination.

This matters because some consumers may require the original request to remain unchanged, while others benefit from an explicit delivery identity.

The key belongs to one request in Adal.

That gives retries and replays intentionally different behavior:

Automatic retry of the same request
→ same X-Adal-Idempotency value

Manual retry of the same delivery
→ same X-Adal-Idempotency value

Replay of an older webhook
→ new X-Adal-Idempotency value
Enter fullscreen mode Exit fullscreen mode

This distinction is important.

An automatic retry is another technical attempt to deliver the same logical request. It should carry the same idempotency key.

A replay is an explicit decision to create a new request from an older webhook. Even when the payload is identical, it represents a new action and receives a new key.

A practical scenario

Imagine a webhook that creates a customer and an order in your CRM.

Without idempotency:

1. Adal delivers the webhook.
2. Your service creates the customer and order.
3. Your service returns 200 OK.
4. The response is lost because of a network failure.
5. Adal retries.
6. Your service creates another customer and another order.
Enter fullscreen mode Exit fullscreen mode

With X-Adal-Idempotency:

1. Adal delivers the webhook with X-Adal-Idempotency.
2. Your service reserves the key in the database.
3. Your service creates the customer and order.
4. The response is lost.
5. Adal retries using the same key.
6. Your service sees that the key already exists.
7. No duplicate customer or order is created.
Enter fullscreen mode Exit fullscreen mode

Delivery can be retried until the sender receives confirmation, while the business action is executed once.

Final thoughts

Duplicate webhooks are not an exceptional failure mode. They are a normal part of building reliable systems over unreliable networks.

A webhook consumer should not rely on exactly-once delivery unless the provider explicitly guarantees it end to end — and even then, downstream side effects may still need protection.

Do not use payload equality as event identity. Two different events can look identical, and the same event can be delivered multiple times.

Use an explicit idempotency key, enforce uniqueness atomically, and design the consumer so repeated delivery does not repeat the side effect.

That makes retries safe, debugging easier, and webhook integrations much more predictable.


Adal is a webhook delivery and observability platform. It can optionally add X-Adal-Idempotency to forwarded requests on a per-destination basis, helping webhook consumers safely distinguish retries from new requests.

Top comments (0)