HyphaBase

Checking it's really us

Your webhook URL is public, so anyone can post to it. Here's how to tell the difference, and the mistake everyone makes first.

Sarah's webhook URL is on the open internet. Anyone who finds it can post to it, claiming a customer booked, or cancelled, or paid.

The signature is how you tell. Check it before you act on anything — including in development, because a receiver that skips the check in dev tends to ship that way.

What we send

HeaderWhat it is
HyphaBase-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
HyphaBase-Webhook-IdThe event's id. Use it to spot a repeat.
HyphaBase-Webhook-AttemptWhich attempt this is, from 1.

The signature is:

HMAC-SHA256(key: your whsec_ secret, message: "{t}.{raw request body}")

The four steps

Read the raw body

The exact bytes, before any JSON parsing. This is the step people get wrong, and it's the cause of nearly every "signatures never match".

Split the header

On , then on =, giving you t and v1.

Reject old timestamps

If t is more than 300 seconds from now, refuse it. Without this, someone who captures one real delivery can replay it forever.

Compare in constant time

Compute the HMAC over "{t}.{body}" and compare with v1 using a constant-time comparison — not ===.

Parse the body and you've already broken it

The signature covers the bytes we sent. JSON.parse then JSON.stringify produces different bytes — different key order, different whitespace, different unicode escaping — and the signature will never match.

Most frameworks parse the body for you by default, so you have to opt out for this one route. In Express that's express.raw. In Laravel, $request->getContent() rather than $request->all(). In a Next.js route handler, await request.text().

The code

This doesn't belong in a browser, and can't be made to. Checking a signature needs the signing secret, and anything a browser can read is public.

Webhooks are delivered server to server. If you want Sarah's dashboard to update the moment a booking lands, the path is: we post to her server, her server checks the signature, then her server tells the browser — over a WebSocket, server-sent events, or just by invalidating a cache so the next render refetches.

// In the browser: react to your own server, not to ours.
source.addEventListener('booking.changed', (event) => {
  const { id } = JSON.parse(event.data);
  queryClient.invalidateQueries({ queryKey: ['bookings', id] });
});

Changing the secret

Signatures are computed fresh for every attempt. So after you rotate, retries of older events are signed with the new secret. Check against the current secret only; don't keep the old one around expecting late deliveries to need it.

To rotate without dropping anything: fetch the new secret, deploy it, then rotate. The window where either could arrive is small but real — if you can't tolerate it, accept both briefly during the changeover.

When it doesn't work

Next

On this page