Verify webhook signatures

The exact signing scheme Hannu uses — x-hannu-signature with a timestamped HMAC-SHA256 — and a verification snippet.

Every webhook delivery is signed. Verify the signature before you act on the payload — it proves the delivery came from Hannu and hasn't been tampered with or replayed.

The scheme

Each delivery carries an x-hannu-signature header:

HTTP
x-hannu-signature: t=1737100000,v1=3a7f… (hex)
  • t — the Unix timestamp when the delivery was signed.
  • v1HMAC-SHA256(secret, "<t>.<rawBody>"), hex-encoded.

The secret is your endpoint's signing secret (prefixed whsec_), shown once when you create the endpoint in the dashboard. Sign over the exact raw request body — parse it only after verifying.

Verify it

JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto';
 
export function verifyHannuWebhook(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // stale / replayed
 
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}
Sign the raw bytes

Compute the HMAC over the raw request body, before any JSON parsing or re-serialization. Re-encoding changes bytes and breaks the signature.

Reject replays

The timestamp (t) bounds the delivery in time. Reject anything older than your tolerance (5 minutes is a good default) so a captured delivery can't be replayed later.

One scheme, authoritative

Use exactly the header and construction above (x-hannu-signature: t=…,v1=… over t.body). Older references to X-Signature or sha256=… are superseded.