Docs

Verifying signatures

Verify the exact request bytes, reject stale requests and deduplicate the signed event ID.

Calberry sends Webhook-Signature: t=<Unix-seconds>,v1=<hex> and Webhook-Id. The signature is HMAC-SHA256 over the exact byte sequence <t>.<raw request body>.

Use the whole secret as the issued UTF-8 string, including whsec_. Do not Base64-decode it. Capture the original body bytes before JSON parsing; parsing and serializing again changes the signed input.

Receiver sequence

  1. Limit the incoming body size and read the raw bytes.
  2. Parse the signature header strictly and check the timestamp against your configured tolerance, in both past and future directions.
  3. Compute the HMAC and compare it in constant time.
  4. Only after verification, parse JSON and validate the event envelope.
  5. Deduplicate using event_id from the signed body. Check that Webhook-Id agrees with it if you use that header.
  6. Ignore data.test=true for real business effects. Persist normal events durably before returning a 2xx acknowledgement.

Do not use an unsigned header alone as your deduplication identity. The current attempt's signature timestamp is separate from created_at, which remains the original event time.

Node.js verifier

This function has no external dependencies. Pass a Buffer/Uint8Array for rawBody, the actual current Unix time for nowSeconds, and a non-negative tolerance from your receiver's configuration. true validates only the signature and timestamp, not the business payload.

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

function verifyWebhookSignature(rawBody, header, secret, nowSeconds, toleranceSeconds) {
  if (typeof header !== "string" || typeof secret !== "string" || !secret) return false;
  const match = /^t=(0|[1-9]\d*),v1=([0-9a-f]{64})$/.exec(header);
  if (!match) return false;
  const timestamp = Number(match[1]);
  if (!Number.isSafeInteger(timestamp) ||
      !Number.isSafeInteger(nowSeconds) || nowSeconds < 0 ||
      !Number.isSafeInteger(toleranceSeconds) || toleranceSeconds < 0 ||
      Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  return timingSafeEqual(expected, Buffer.from(match[2], "hex"));
}

Test vector

This is deliberately a minimal synthetic body for testing the signature protocol, not a complete business event. Keep rawBody exactly as shown, without a trailing newline. The secret is public example data.

{
  "secret": "whsec_example_for_documentation_only",
  "nowSeconds": 1791892800,
  "toleranceSeconds": 300,
  "rawBody": "{\"event_id\":\"ev_test\",\"type\":\"booking.created\",\"created_at\":\"2026-10-13T12:00:00.000Z\",\"data\":{\"schema_version\":1,\"consent\":false}}",
  "header": "t=1791892800,v1=ef3a74f0492fd65c0a1e75a7845016368f0a31072bcd45fe568251c39b2e1bf1"
}

Calling verifyWebhookSignature(Buffer.from(vector.rawBody, 'utf8'), vector.header, vector.secret, vector.nowSeconds, vector.toleranceSeconds) returns true. Changing the body, appending a newline, changing the secret or using a time outside the example tolerance returns false. In a live handler, use Math.floor(Date.now() / 1000), not the example's fixed time.

Rotation, retries and replay

Retries and replay preserve the body and event ID but get a fresh timestamp and signature. Timestamp checks alone do not prevent duplicate effects; persist processed event IDs.

Secret rotation replaces the active secret immediately. Already-running attempts may still use the previous one, so temporarily verify against both secrets. Keep secrets in server-side configuration and never put them in logs or browser code.

For a no-code tool that does not expose the raw bytes and signature verification, place a verifying receiver in front and forward only verified events. A normal webhook catch step does not automatically implement this protocol.

On this page