All docs

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:

body
{
  "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

webhook.ts
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.

verify.ts
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.

server.js
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.