Even when the sending system is working correctly, the same webhook may be delivered more than once. A network failure, a timeout, a retry by the sender, a restarted consumer, or a case where your service completed the work but failed to return a successful HTTP response can all lead to duplicate delivery.
That leaves every webhook consumer with one important question:
What happens if the same webhook is processed twice?
Sometimes, nothing serious. For example, a webhook may simply update an object to a status it already has.
But duplicate processing often has real consequences:
a customer receives the same email or notification twice;
an order is created twice;
an account balance is credited twice;
duplicate records appear in a CRM;
an external API receives the same request multiple times;
the same webhook-triggered background job runs repeatedly.
That is why webhook consumers should be designed so that processing the same event more than once does not change the final result.
This property is called idempotency.
What is idempotency?
An idempotent operation can be performed multiple times while producing the same final state as a single execution.
For example, this request is naturally idempotent:
PUT /users/42/status
{
"status": "active"
}
Sending it twice still leaves the user in the same state: active.
But this request may not be idempotent:
POST /payments
{
"amount": 100
}
Without additional protection, sending it twice may create two separate payment operations.
For webhooks, this matters because the receiving service cannot always determine whether an incoming request represents a new event or another delivery attempt for an event that has already been processed.
Why comparing request contents is not enough
It may seem reasonable to detect duplicates by comparing webhook payloads.
For example, you could save the JSON body of an incoming webhook and check whether the same payload has been received before.
In practice, this is not reliable.
Two independent events can have identical contents:
{
"event": "invoice.created",
"amount": 1000,
"currency": "USD"
}
Those may be two different invoices with the same amount. They may be two similar messages from different users. Or they may be separate events that occurred at different times but happen to contain the same fields.
Comparing every request field quickly becomes complicated:
Which fields should be compared?
Which fields should be ignored?
Does the order of keys in JSON matter?
Should headers be included?
Should query parameters be included?
How should binary request bodies be handled?
What about timestamps, random values, and signatures?
Hashing the request body does not fundamentally solve the problem either. A hash makes storage and comparison more convenient, but it does not answer the main question: are two identical payloads the same event, or are they two separate events with similar data?
A hash is also still a probabilistic identifier. The chance of a collision may be extremely small, but it is not zero.
What an idempotency key is for
The reliable way to distinguish a repeated delivery from a new event is to use a separate identifier that is not derived from the request contents.
This identifier is commonly called an idempotency key.
The sender generates a unique key for a specific logical operation and includes it with the request. The receiver stores that key in its database and checks whether it has already been processed before performing any side effects.
At a high level, the flow looks like this:
1. Receive the webhook.
2. Read the idempotency key from the request header.
3. Attempt to reserve or store the key as processed.
4. If the key already exists, do not perform the action again.
5. If the key is new, perform the action and record the result.
For example:
POST /webhooks/payment
X-Idempotency-Key: 01989d55-20df-7a72-87f9-6e50f3d78315
Content-Type: application/json
On the receiving side, the key can be recorded with an atomic database operation:
INSERT INTO processed_webhooks (idempotency_key, processed_at)
VALUES (:idempotency_key, NOW())
ON CONFLICT (idempotency_key) DO NOTHING;
If the row is inserted successfully, the consumer can continue processing the webhook.
If the key already exists, the request has already been handled. The service can return a successful response without creating another order, charging another payment, or sending another notification.
The check and reservation must be atomic. A simple “check first, then insert” sequence can fail if two identical requests are processed concurrently.
An idempotency key is more than a UUID in a header
A random identifier alone does not make a system idempotent.
Idempotency only exists when the receiving service:
stores the key;
enforces uniqueness at the database level;
prevents side effects from being repeated;
defines what should be returned for repeated requests.
For example, if the first request created an order, another request with the same key must not create a second one. Depending on the application, it may return the existing order ID or simply respond with 200 OK.
You should also define how long idempotency keys are retained. If webhook retries can occur several days later, deleting keys after a few minutes is not enough. The appropriate retention period depends on your retry policy, delivery guarantees, and how costly duplicate actions would be.
What Adal does
Not every webhook source provides its own idempotency key. Some services include an event ID, some do not. Sometimes the identifier exists only inside the payload, and sometimes it is not available at all.
That is why Adal can add an idempotency key while forwarding webhooks.
The setting is configured separately for each destination. This matters because different receivers may have different requirements:
one destination may require the original request to remain completely unchanged;
another may use an idempotency key to prevent duplicate processing;
a third may be an internal service where the additional header is part of the delivery contract.
When enabled, Adal adds the following header to the forwarded request:
X-Adal-Idempotency: <unique-key>
The receiving service can then use it as its idempotency key:
POST /incoming-webhook
X-Adal-Idempotency: 01989d55-20df-7a72-87f9-6e50f3d78315
The key belongs to a specific request in Adal.
When Adal automatically retries delivery of that request, the key remains the same. The same applies to a manual retry: retrying an existing delivery sends the same X-Adal-Idempotency value, allowing the receiver to safely recognize it as a repeat.
Replay works differently.
A replay intentionally creates a new request based on a previously received webhook. The body, headers, and other request data may be similar to the original, but the replay is a new operation within Adal. For that reason, a replay receives a new X-Adal-Idempotency value.
This makes it possible to distinguish between two very different cases:
Automatic retry of the original request
→ the same idempotency key
Manual retry of the original delivery
→ the same idempotency key
Replay of an older webhook request
→ a new idempotency key
This behavior separates a technical repeat of the same delivery from an intentional decision to run an older webhook again.
A practical example
Imagine a service that receives a webhook for a new order and creates records in a CRM.
Without idempotency, the flow may look like this:
1. Adal delivers the webhook.
2. Your service creates a customer and an order in the CRM.
3. The 200 OK response does not reach Adal because of a network failure.
4. Adal retries the delivery.
5. Your service creates another customer and another order.
With X-Adal-Idempotency, the same situation becomes safer:
1. Adal delivers the webhook with X-Adal-Idempotency.
2. Your service stores the key in a table of processed requests.
3. Your service creates the customer and order in the CRM.
4. The 200 OK response does not reach Adal.
5. Adal retries the delivery with the same X-Adal-Idempotency value.
6. Your service sees that the key already exists.
7. No duplicate customer or order is created.
The delivery can be retried as many times as needed to confirm successful receipt, while the business action is performed only once.
Conclusion
Duplicate webhook deliveries are not an unusual edge case, and they do not necessarily indicate that something is broken. They are a normal consequence of distributed systems, unreliable networks, and retry mechanisms.
Webhook consumers should not assume that every request will arrive exactly once. It is safer to assume the opposite: the same webhook may be delivered again, and the receiving system should be ready for it.
Do not try to identify duplicates by comparing request bodies, JSON fields, or hashes of the payload. Identical payloads may represent different events, while one event may be delivered multiple times for purely technical reasons.
That is what idempotency keys are for: explicit identifiers that allow the receiver to safely distinguish a repeated operation from a new event.
With Adal, you can enable X-Adal-Idempotency separately for each destination. This lets you preserve the original request unchanged where necessary, while adding predictable protection against duplicate processing where it matters.