HookGet Open dashboard

Verifying webhook signatures

The three headers, a verification function, and the mistake everyone makes once.

In short

  • Three headers travel with every delivery: an id, a timestamp and a signature.
  • The HMAC covers id, timestamp and the raw body — verify before parsing.
  • The timestamp window is what makes a captured request useless later.
  • During rotation two signatures are sent, so the switch costs no deliveries.

The headers

webhook-id:         msg_01M03VKMCJ5H7C1E4K1EEA987B
webhook-timestamp:  1786836013
webhook-signature:  v1,atTVDyPi26ackTT4nZbdk…

HookGet signs with Standard Webhooks, the open specification, so a consumer written for any compliant sender needs no changes.

The verification function

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

export function verify(secret, headers, rawBody) {
  const id = headers['webhook-id'];
  const ts = Number(headers['webhook-timestamp']);

  // Outside the window this is a replay, not a delivery.
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key)
    .update(`${id}.${ts}.${rawBody}`)   // the RAW bytes, never a re-serialised object
    .digest('base64');

  return String(headers['webhook-signature'] ?? '')
    .split(' ')
    .some((part) => {
      const [version, value] = part.split(',');
      const a = Buffer.from(value ?? '', 'utf8');
      const b = Buffer.from(expected, 'utf8');
      return version === 'v1' && a.length === b.length && timingSafeEqual(a, b);
    });
}

The mistake everyone makes once. Parsing the JSON and re-serialising it before verifying changes whitespace and key order, and the HMAC fails. Frameworks that helpfully parse the body for you are the usual cause — capture the raw bytes first.

Why each part is there

PartWhat it prevents
The shared secretAnyone posting JSON to your public endpoint and being believed
The raw body in the HMACModification in transit or by an intermediary
The timestamp in the HMACReplay of a captured request, hours or days later
Constant-time comparisonLearning the correct signature byte by byte from timing
The stable idDouble-processing when the same delivery is retried

Rotating a secret without dropping deliveries

Rotation returns a new secret and keeps the old one valid for an overlap window. During the overlap both signatures are sent in the same header, so a consumer can switch whenever it is ready.

curl -X POST https://api.hookget.com/v1/endpoints/ep_…/rotate-secret \
  -H "authorization: Bearer $HOOKGET_KEY" -d '{"expiry_hours":24}'