Blog Build a webhook receiver you can actually trust
Build a webhook receiver you can actually trust
TL;DR Treat a webhook endpoint as untrusted input. Verify the signature so you know it is genuine, respond quickly and do the real work asynchronously so you do not time out, and make processing idempotent so a redelivered event does not run twice.
A webhook endpoint is a public door into your system that other people's servers push data through. Built carelessly, it is a security hole and a source of duplicate, half-processed events. Three habits make it safe and reliable.
Verify the signature
The endpoint is public, so anyone can send it a request. The provider signs each webhook with a shared secret, and your first job is to verify that signature before trusting anything in the payload.
const signature = request.headers.get("x-signature");
const expected = hmacSha256(rawBody, WEBHOOK_SECRET);
if (!timingSafeEqual(signature, expected)) {
return new Response("Invalid signature", { status: 401 });
}
Verify against the raw body, not a parsed and re-serialised version, or the signature will not match. An unverified webhook endpoint is a hole anyone can post forged events into.
Respond fast, process later
Providers expect a quick acknowledgement and will retry if you are slow. If you do heavy work, hit a database, call another service, send email, before responding, you risk timing out, which triggers a retry, which gives you a duplicate.
So acknowledge immediately, then process asynchronously:
await queue.add(event); // hand off the work
return new Response("ok", { status: 200 }); // acknowledge now
The endpoint's only synchronous job is to verify and accept. The real work happens on a queue behind it.
Handle duplicates
Even done right, providers redeliver events, after a retry, a network blip, or at-least-once delivery guarantees. If your processing is not idempotent, a redelivered "payment succeeded" charges twice or emails twice.
Track the event IDs you have handled and skip repeats:
if (await alreadyProcessed(event.id)) return; // seen it, do nothing
await process(event);
await markProcessed(event.id);
Now handling an event twice has the same result as handling it once, which is exactly what you want when you cannot control how many times it arrives.
The three rules together
Verify the signature so you only act on genuine events. Respond fast and process asynchronously so you never time out into a retry storm. Make processing idempotent so duplicates are harmless. A webhook receiver that does all three is a door you can leave open without worrying, which is the whole point of building one.
FAQ
Why verify a webhook signature?
Because the endpoint is public, anyone can post to it. The provider signs each request with a shared secret, and verifying that signature is how you know the payload really came from them and was not forged or tampered with.
Why respond before processing the event?
Providers expect a fast response and will retry if you are slow, which can cause duplicate deliveries. Acknowledge the webhook quickly, then do the heavier processing asynchronously, so you never time out and trigger a retry storm.
What does idempotent mean for webhooks?
It means processing the same event twice has the same effect as processing it once. Providers can and do redeliver events, so you track which event IDs you have handled and skip duplicates, or your logic runs twice, sending two emails or charging twice.