How to verify a webhook before you trust it
Why an endpoint must check every delivery, the four steps that do it, and the mistakes that make a valid delivery fail.
Checked 8 October 2026 About 3 minutes View as Markdown
The short answer
Check the signature over the raw body with your endpoint's secret, and refuse an old timestamp. Answer quickly, and expect the same delivery twice.
- HMAC-SHA256 over the timestamp and the body, exactly as it arrived.
- Refuse a delivery more than 5 minutes old.
- Delivered at least once: act on each delivery only once.
Why every delivery has to be checked
A webhook endpoint is an address on the public internet, and anybody who finds it can post to it. A handler that confirms a booking or ships an order on whatever arrives is a handler anybody can drive. Before acting on a delivery, prove it came from the platform and has not been changed or replayed.
The four steps
- Read the raw body. Take the bytes exactly as they arrived, before anything parses them. The signature covers those bytes, and JSON parsed and written out again comes back with different spacing and key order.
- Recompute the signature. HMAC-SHA256 under your endpoint's signing secret, over the timestamp header, a full stop, and the raw body.
- Compare in constant time. An ordinary comparison stops at the first wrong character, and how long it took says how much was right.
- Refuse an old timestamp. The timestamp is inside what is signed, so it cannot be changed, and refusing one more than 5 minutes away from now makes a captured delivery useless later.
The SDK does all four:
import { verifyWebhook } from '@backendfree/core';
const event = await verifyWebhook({
body: await request.text(), // the raw body
headers: request.headers,
secret: process.env.BACKENDFREE_WEBHOOK_SECRET!,
});
It throws when any step fails. Answer 400, log it, and do nothing the delivery asks: sending the same delivery again would fail the same way.
Act on the body, never the headers
The signature covers the timestamp and the body, and nothing else. So the body
says what happened: the event's name in event, the delivery's id in id and
the payload in data. Two headers repeat the name and the id for routing and
logs, outside what is signed, and on a captured delivery they could be
rewritten without the signature noticing. The SDK reads both from the body.
Then answer quickly
Answer with a 2xx as soon as the delivery is verified, and do the work afterwards. A delivery that times out is taken as failed and sent again, so a slow handler turns into the same delivery arriving twice.
And expect it twice anyway
Delivery is at least once, never exactly once: a delivery your server handled
can arrive again when its answer was lost on the way back. Keep the body's
id, the same on every retry of it, and act on each id only once.
The mistakes that fail a real delivery
- A framework parsed the body first. A JSON middleware running on every route hands the handler a parsed object, and the bytes the signature covers are gone. Read that route's body raw.
- The wrong secret. Each endpoint has its own signing secret.
- A clock that is far out. A server whose clock is wrong by more than 5 minutes refuses every delivery as too old.
Verify a delivery has the headers, the retries and a version without the SDK, and Events lists what can arrive.
Questions people ask next
Why does my check fail on real deliveries?
Something parsed the body first. The signature covers the bytes as they arrived. A framework that parses the JSON and writes it out again changes them, so read the raw body on that route.
Why does the timestamp matter?
It stops a delivery being replayed. It is inside what is signed, so it cannot be changed. Refusing one more than 5 minutes old makes a captured delivery useless.
Is a secret in the endpoint's address enough?
No. An address ends up in logs and proxies, and it proves nothing about the body. A signature covers the exact bytes that arrived.
What should the endpoint answer to a bad signature?
400, and log it. Do nothing the delivery asks. A retry would be refused the same way, so a 400 is the honest answer.