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
- Limit the incoming body size and read the raw bytes.
- Parse the signature header strictly and check the timestamp against your configured tolerance, in both past and future directions.
- Compute the HMAC and compare it in constant time.
- Only after verification, parse JSON and validate the event envelope.
- Deduplicate using
event_idfrom the signed body. Check thatWebhook-Idagrees with it if you use that header. - Ignore
data.test=truefor 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.