A sender will deliver the same webhook more than once. This sample walks through a receiver that treats that as normal. It is an authored example, not a tested recipe, and the endpoint it describes is not part of this website.
Step 1: Define the event contract
Write down the event before you write the handler. Name the type, the version, and the id the sender uses when it retries.
- Choose a stable event id that stays the same across retries.
- Put the type and version where both sides can see them.
- Decide which fields are required.
{
"id": "evt_123",
"type": "order.placed",
"version": 1,
"occurredAt": "2026-04-04T15:00:00Z"
}Checkpoint
You can point at one id that will not change if the sender tries again, and you can name the type and version.
Security and reliability
Do not use the arrival time as the event id. Two deliveries of one event would look like two events.
Step 2: Receive the raw body
Signature checks need the exact bytes the sender signed. Read the body once, as text or bytes, and parse it only after the check.
export async function readRawBody(request: Request): Promise<string> {
return request.text();
}The example path /webhooks/orders belongs to the system you are designing. This site does not expose that route.
Checkpoint
The handler can show the raw body it will verify, and it has not parsed JSON yet.
Step 3: Verify the signature
Compare the sender's signature with one you compute from the raw body and a shared secret. Reject the request when they differ, and do not store a body you could not authenticate.
import { createHmac, timingSafeEqual } from "node:crypto";
export function signaturesMatch(rawBody: string, secret: string, given: string): boolean {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(given);
return a.length === b.length && timingSafeEqual(a, b);
}Checkpoint
A changed byte in the body fails the check, and a matching signature passes.
Step 4: Store before acknowledging
Insert the event in the same transaction that records "we have this id". If the insert says the id is already there, acknowledge again and stop. If the insert fails for another reason, return an error so the sender retries.
insert into webhook_events (id, type, body)
values ($1, $2, $3)
on conflict (id) do nothing;Checkpoint
A second delivery of the same id does not create a second row, and the sender only receives success after the row exists.
Step 5: Process safely in the background
The HTTP handler's job ends when the event is stored. A worker reads stored events and does the slow work. That work has to be safe to run twice, because the worker can crash after it starts and before it finishes.
- Read a stored event that is not marked done.
- Perform the action in a way that repeating it changes nothing the second time.
- Mark the event done in the same transaction as the action's own write, when you can.
Checkpoint
Restarting the worker does not send a second customer notice for an event that already completed.
Step 6: Exercise the failure paths
Try the cases you hope not to see.
- Send a body with a bad signature and confirm it is rejected and not stored.
- Send the same valid event twice and confirm one row.
- Stop the worker halfway and confirm the action still happens once.
Checkpoint
You have seen a rejected signature, a duplicate delivery, and a retried worker, and you can say what the system did in each case.
Wrap-up
The receiver is finished when those three failures behave on purpose. The next place to look is how the worker retries, and how you change the event contract without breaking senders that still use the old version.