Build the mental model
Webhook delivery is not guaranteed to happen exactly once. If your endpoint is slow to respond or times out, most providers assume delivery failed and retry it, often several times.
This connects directly back to idempotency. An idempotent handler must produce the same end result no matter how many times the same event arrives — every duplicate delivery should be a safe no-op.
Receive and verify
Accept the request and verify its signature before reading anything from the body.
Check the event ID
Look up this event's unique ID in your processed-events store.
New: process and record
Do the real work, then record the ID so future duplicates are recognized.
Duplicate: acknowledge only
Skip the real work and return success immediately so the provider stops retrying.
Every event a provider sends carries a unique ID for exactly this reason; store it, check it, and duplicates stop being a threat.
IDEMPOTENT WEBHOOK HANDLING
---------------------------
-----
Receive request
|
v
Verify signature (reject if invalid)
|
v
Already processed this event ID?
|
+---+---+
| |
yes no
| |
v v
Acknowledge Process + record ID
only + acknowledge
(no reprocessing)Connect it to a real scenario
The code below keeps a processedEvents set and a sideEffects log, standing in for a database table. handleWebhookEvent only touches the real side-effect logic when the ID is genuinely new.
Five deliveries arrive in sequence, including two repeats — exactly what a retry mechanism would produce. Compare the action field on each result against the sideEffects count.
Five requests arrive but only three distinct events trigger real processing. The two repeats get ACKNOWLEDGED_ONLY, meaning no order gets updated twice.
Try the working example
const processedEvents = new Set();
const sideEffects = [];
function handleWebhookEvent(event) {
if (processedEvents.has(event.id)) {
return { id: event.id, action: "ACKNOWLEDGED_ONLY", reason: "Already processed (duplicate delivery)" };
}
processedEvents.add(event.id);
sideEffects.push(event.id);
return { id: event.id, action: "PROCESSED", reason: "New event, side effect applied" };
}
const incomingDeliveries = [
{ id: "evt_001", type: "payment.completed" },
{ id: "evt_002", type: "payment.completed" },
{ id: "evt_001", type: "payment.completed" },
{ id: "evt_003", type: "payment.completed" },
{ id: "evt_002", type: "payment.completed" }
];
const results = incomingDeliveries.map(handleWebhookEvent);
console.log(results);
console.log("Total side effects applied:", sideEffects.length);[
{ id: 'evt_001', action: 'PROCESSED', reason: 'New event, side effect applied' },
{ id: 'evt_002', action: 'PROCESSED', reason: 'New event, side effect applied' },
{ id: 'evt_001', action: 'ACKNOWLEDGED_ONLY', reason: 'Already processed (duplicate delivery)' },
{ id: 'evt_003', action: 'PROCESSED', reason: 'New event, side effect applied' },
{ id: 'evt_002', action: 'ACKNOWLEDGED_ONLY', reason: 'Already processed (duplicate delivery)' }
]
Total side effects applied: 35-minute try-it
Change the processedEvents store to also record a timestamp per event, and add a cleanup step that only keeps IDs from the last 24 hours.
One important caution
Processing the side effect before recording the event ID, which can double-process if the server crashes in between.
Relying only on your own request timeout to prevent duplicates instead of an explicit processed-event check keyed by event ID.
Idempotence — Wikipedia — API Integration & Webhooks