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
| Header | What it is |
|---|---|
HyphaBase-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
HyphaBase-Webhook-Id | The event's id. Use it to spot a repeat. |
HyphaBase-Webhook-Attempt | Which 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.