Webhooks: verify a delivery
How a delivery is signed, how to check it before trusting it, and why a handler has to answer quickly and cope with the same delivery twice.
Updated 8 October 2026 About 4 minutes View as Markdown
When something happens to a project, a booking confirmed, a page published, a payment refunded, the platform posts it to each endpoint you added for that event. Your endpoint is public, so anybody can post to it: check every delivery before acting on it.
What arrives
A POST whose body is JSON naming the delivery, the event and its data:
{
"data": { "booking_id": "8f0c2b41-5d3e-4a7b-9c11-2e6f8a9d0b7c", "status": "confirmed" },
"event": "booking.confirmed",
"id": "4d2a9e17-6b3c-4f8a-a1d5-7e0c9b2f3a68"
}
data is the event's payload, shown for each event in Events and
payloads. id is this delivery's, the same on every
retry of it. Four headers come with it:
| Header | Carries |
|---|---|
X-BackendFree-Event |
The event's name, as in the body, for routing |
X-BackendFree-Delivery |
The delivery's id, as in the body, for logs |
X-BackendFree-Timestamp |
When it was signed, in seconds since 1970 |
X-BackendFree-Signature |
sha256= and the signature in hex |
The signature is an HMAC-SHA256 under the endpoint's signing secret, over the
timestamp, a full stop, and the body exactly as it was sent. Nothing else is
signed, so act on the body's event and id and never on the two headers
repeating them: on a captured delivery those can be rewritten without breaking
the signature.
Live and test endpoints
An endpoint is live or test: one added while the dashboard is in test mode is a
test endpoint. A live endpoint is sent what your live keys do and a test
endpoint what your test keys do, so a staging server never hears about a real
customer. Events about
what both share, such as a page published, go to both. Booking and payment
events also carry livemode, for a handler that wants to check.
Where an endpoint may point
A delivery carries your customers' details, so an endpoint is an https address
on the public internet, and the webhooks screen refuses anything else: an http
address, an IP address that is not public, a name that is not a full domain,
localhost, or a name ending in .localhost, .local, .internal, .home.arpa. A name that only looks public is
checked again as each delivery is sent: one that resolves to an address that is
not public is not sent to, and not tried again.
So a server on your own machine cannot be an endpoint as it is. While you build, put a tunnel in front of it, such as ngrok or Cloudflare Tunnel, and add the https address it gives you as a test endpoint.
Check it with the SDK
import { verifyWebhook } from '@backendfree/core';
const event = await verifyWebhook({
body: await request.text(), // the raw body, before anything parses it
headers: request.headers,
secret: process.env.BACKENDFREE_WEBHOOK_SECRET!,
});
event.name; // 'payment.succeeded', the body's event
event.delivery; // the body's id
event.data; // the payload
It throws a WebhookError when the signature is missing or wrong, or when the
timestamp is more than 5 minutes away from now, which is
how a captured delivery replayed later is refused. Answer 400 and log it.
Check it yourself
Any language with HMAC-SHA256 can do the same in a few lines, and hand back what the signed body says.
import { createHmac, timingSafeEqual } from 'node:crypto';
type Delivery = { id: string; event: string; data: Record<string, unknown> };
export function verify(body: string, headers: Headers, secret: string): Delivery | null {
const timestamp = headers.get('X-BackendFree-Timestamp') ?? '';
const signature = (headers.get('X-BackendFree-Signature') ?? '').replace(/^sha256=/, '');
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return null;
const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
const valid = expected.length === signature.length
&& timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
return valid ? (JSON.parse(body) as Delivery) : null;
}
Read the body as the bytes that arrived, before anything parses it. A framework that parses the JSON and writes it out again produces different bytes, and every signature fails.
app.post('/hooks', express.raw({ type: '*/*' }), handler); // yes
app.use(express.json()); // not on this route
Answer quickly
A delivery waits 10 seconds for each step of the exchange and 15 seconds for all of it, and follows no redirect. Answer with a 2xx as soon as the delivery is verified, and do the work afterwards.
A timeout, a refused connection or a status of 408, 429, 500, 502, 503 or 504 is tried again, up to 5 more times with a growing wait between them. Any other status is taken as your answer and not retried, since sending the same request again would be refused the same way.
Expect the same delivery twice
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. Make the handler
idempotent on the body's id, so the second arrival changes nothing.
When an endpoint keeps failing
After 10 deliveries in a row have failed for good, the endpoint is switched off, and the dashboard says so. Turn it back on once it answers again. Every delivery is kept in its log for 30 days, with the status and the start of what your server answered.
Limits
A website can have up to 4 live endpoints and as many test ones, and a project is sent at most 5,000 deliveries an hour, so one busy project cannot crowd the queue for everybody else.