Skip to content

Webhooks

Verify webhook signatures

On this page

Every request carries a signature. Verify it before you trust the body: anyone can send a POST to your endpoint, but only Bizisy has your endpoint's secret.

How the signature works#

Bizisy follows Standard Webhooks, so any Standard Webhooks library verifies it. Without one:

  1. Take the raw body, exactly as received. Parsing and re-serializing the JSON changes it and breaks the signature.
  2. Build the signed content: {webhook-id}.{webhook-timestamp}.{body}.
  3. Compute HMAC-SHA256 of it with the endpoint's secret: the part after whsec_, base64-decoded.
  4. Base64-encode the result and compare it, in constant time, with each v1,<base64> entry of webhook-signature (space-separated).
  5. Refuse timestamps more than five minutes from now, so an old request can't be replayed.

Code#

Node
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the body exactly as received (string or Buffer), before any JSON parsing.
// secret: the endpoint's signing secret, whsec_…
export function verifyBizisyWebhook(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;
  // Refuse old or future requests (replays).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
  // During a secret rotation the header holds two signatures: accept either.
  return signatures.split(' ').some((part) => {
    const [version, sig] = part.split(',');
    const got = Buffer.from(sig ?? '', 'base64');
    return version === 'v1' && got.length === expected.length && timingSafeEqual(got, expected);
  });
}

In a web framework#

Read the raw body before any JSON middleware touches it, verify, answer, then work:

Express
app.post('/webhooks/bizisy', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyBizisyWebhook(req.body, req.headers, process.env.BIZISY_WEBHOOK_SECRET)) return res.sendStatus(400);
  res.sendStatus(204); // answer within 10 seconds, then do the work
  queue.add(JSON.parse(req.body));
});

Rolling the secret#

Roll a secret in Settings → Webhooks (owners and admins). You can keep the previous secret working for up to 72 hours (24 by default): during that time every request carries two signatures, space-separated, one per secret, and either verifies. The code above accepts both. Deploy the new secret to your receiver, then let the overlap end. Choose no overlap if the secret leaked.

Something missing or wrong on this page? Write to hello@bizisy.com.

Developer docs