Quick answer: Webhook providers resend an event whenever they miss a response, time out, or hit a temporary error, so a well-behaved handler receives the same event more than once as part of normal operation, not just failure. Mock that duplicate delivery in development and confirm the handler processes the first event and then recognizes the replay without duplicating side effects. If you only test a single webhook, the first provider retry in production can double-insert or double-notify.
Why duplicates are the normal case
Outbound webhooks are delivered over a network the sender does not control. The provider considers an event delivered only when it gets a fast, successful response. A timeout leaves the outcome unknown, so the safe move is to retry, even though your handler may already have finished the work.
The retry schedule is documented and predictable, usually with backoff over several attempts. That means your endpoint should be built expecting repeated calls with the same event id, and the response you return matters. A slow response is treated as a failure and triggers another delivery.
What the handler must do on a replay
Idempotency here rests on a stable event identifier. The provider sends a unique id on each event, and the handler uses it to tell a new event from a replay.
| Situation | Correct behavior | Broken behavior |
|---|---|---|
| First event received | Process it, record the id, return 2xx | No record of the id |
| Same event id replayed | Skip the side effect, return 2xx | Run the work a second time |
| Replay while first is running | Wait or lock, then resolve once | Two parallel inserts |
| Different event, same payload | Process as a new event | Treat as a duplicate |
| Unknown signature | Reject before processing | Process untrusted data |
The stored id is the key piece. Without a record of what has already been handled, the endpoint has no way to know a retry is a replay.
How to mock the duplicate locally
A mock lets you drive the exact retry sequence without waiting for a real provider or forcing real failures.
- Capture a signed example event with a fixed event id and configure the mock to POST it to your local endpoint.
- Send the event once and confirm the side effect, such as a database row, happens a single time.
- Send the identical event again with the same id and signature, the way the provider's retry would, and assert no second row or notification is created.
- Confirm both calls return a quick 2xx, because a non-2xx on the replay would prompt the provider to keep retrying.
- Send a second event with a new id and confirm it is processed, proving the handler is not rejecting everything.
Drive the mock from the same spec or fixture as the real events so the signature header, timestamp, and body shape match production. A hand-built request that leaves out the signature header tests the happy path but not the verification gate that runs first. A desktop tool like Powerduck keeps the signed fixture and the replay in one place, so the duplicate is the identical request rather than a rebuilt approximation.
The cases that expose weak handlers
The simple sequential replay is the baseline. Real failures show up around timing and ordering.
- Parallel delivery. Some providers retry before the first call finishes, so two requests with the same id overlap. A unique constraint or lock on the event id keeps the side effect atomic; a check-then-process flow can race.
- Out-of-order delivery. Retries can interleave with newer events. The handler should process each id independently rather than assume events arrive in order.
- Expired dedup records. If stored ids are purged too aggressively, a late retry looks new. Keep the record at least as long as the provider's retry window.
- Partial failure. If the side effect succeeds but the response is lost, the replay must not undo or repeat it. Test the path where work is done but the original 2xx never reached the sender.
Frequently asked questions
Should I return 200 on a duplicate or an error? Return 2xx. The provider only wants confirmation the event was received. An error on a replay signals delivery failure and triggers more retries, even though the work is already done.
How is a webhook id different from an idempotency key? They serve the same dedup idea, but the provider assigns the event id, while an idempotency key is usually generated by the client. For inbound webhooks, use the provider's event id rather than inventing your own.
Do I still need signature verification if I dedup? Yes, and it runs first. Dedup protects against retries; signature verification protects against forged requests. They solve different problems and both stay in the handler.
What if the provider sends the same event with a new id? Treat it as a new event unless the payload carries a stable business identifier you can dedup on. Document which field is the source of truth so the behavior is not guessed.
The bottom line
Duplicate webhook delivery is not an edge case you hope never happens. It is built into how providers guarantee delivery, so idempotency is a requirement of the handler, not an optional hardening. Mock the replay with the same event id, assert the side effect happens once while both responses stay 2xx, and add an overlapping delivery to catch the race that sequential tests miss. Get this right in the mock and a retry storm becomes a quiet no-op instead of a batch of duplicate records. More patterns are in the API mocking guide.



