Skip to content

Webhooks

Every webhook channel delivery is a POST of a JSON body, signed. Two headers ride every request:

  • X-Wirewatch-Timestamp: <unix-seconds> — when the request was signed.
  • X-Wirewatch-Signature: sha256=<hex> — hex = HMAC_SHA256(secret, "<timestamp>.<rawBody>"), where secret is the value you configured for the channel, <timestamp> is the exact string sent in X-Wirewatch-Timestamp, and <rawBody> is the exact bytes of the request body — before any reformatting or JSON re-serialisation.

Verify the signature before you trust a delivery: recompute the HMAC over "<timestamp>.<rawBody>" with your channel’s secret, and compare it to X-Wirewatch-Signature. Anyone who has not seen your secret cannot produce a signature that matches.

A matching signature proves the delivery was produced with your secret. It does not prove the delivery is recent: a signature has no expiry, so anyone who captures one legitimate delivery can replay the same body, timestamp and signature later, and it still verifies. X-Wirewatch-Timestamp exists to close that gap — check it against the current time, and reject a delivery that falls outside your tolerance window, not only one whose signature is wrong. The sample below uses a 300-second (5-minute) window; that width is your allowance for clock skew between your server and ours, not a security margin to widen without reason.

async function verifyWebhookSignature(
secret,
timestamp,
rawBody,
signatureHeader,
toleranceSeconds = 300,
) {
const timestampSeconds = Number(timestamp);
if (!timestamp || !Number.isFinite(timestampSeconds)) {
return false;
}
if (Math.abs(Date.now() / 1000 - timestampSeconds) > toleranceSeconds) {
return false;
}
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, encoder.encode(`${timestamp}.${rawBody}`));
const hex = Array.from(new Uint8Array(mac))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
return signatureHeader === `sha256=${hex}`;
}

Call it with the raw request body exactly as received. Parsing it to JSON and re-serialising it can reorder keys or change whitespace and produce a body that no longer matches the signature.

A delivery whose signature does not verify was not produced with your channel’s secret. Reject it — do not act on the incident it claims, and do not skip verification “just this once”: that is the whole gap this page exists to close. The same applies to a delivery whose timestamp is missing, unparseable, or outside the tolerance window: verifyWebhookSignature already returns false for all three, so there is nothing extra to wire up beyond calling it and honouring the result.