Skip to main content

Verify signatures

BeepClock signs every webhook request with the webhook's signing secret. Verify the signature before you trust the payload: it proves that the request comes from BeepClock and that the body was not changed on the way.

X-BeepClock-Signature: t=1791788292,v1=9f2b...a1,v1=4c8e...77
  • t is the time the request was signed, as a Unix timestamp in seconds (UTC).
  • v1 is a version 1 signature. The header can contain more than one v1 value during a secret rotation.

How the signature is computed​

The signature is an HMAC-SHA256 of the timestamp, a dot and the raw request body:

signed_payload = "{t}" + "." + raw_body
signature = lowercase_hex(HMAC_SHA256(key = secret, message = signed_payload))
  • t is exactly the value from the header.
  • raw_body is the raw request body: the bytes received from the network, before any JSON parsing.
  • secret is the whole signing secret including the whsec_ prefix, encoded as UTF-8. Do not strip the prefix and do not Base64-decode the secret.
  • The result is lowercase hexadecimal, 64 characters long.

Every attempt is signed when it is sent, so t and the signature differ between attempts of the same delivery while the body stays the same.

Use the raw body

A signature computed over a re-serialised payload, for example after JSON.parse followed by JSON.stringify, never matches. Read the body as bytes, verify the signature, and only then parse the JSON.

Verification steps​

  1. Read t and every v1 value from the header.
  2. Compute HMAC-SHA256 of {t}.{raw_body} with each secret you currently accept.
  3. Compare the result with each v1 value using a constant-time comparison. A match with any v1 value means the signature is valid.
  4. Reject requests without a valid signature, for example with 401.
  5. Reject requests whose t differs from your current time by more than a tolerance, for example 5 minutes. This protects against replayed requests. BeepClock does not enforce a tolerance itself, so keep your server's clock synchronised (NTP).

Code examples​

const crypto = require("node:crypto");
const express = require("express");

const secrets = [process.env.BEEPCLOCK_WEBHOOK_SECRET];
const toleranceSeconds = 300;

function verifySignature(header, rawBody, secrets) {
if (!header) return false;

let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const index = part.indexOf("=");
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key === "t") timestamp = value;
if (key === "v1") signatures.push(Buffer.from(value, "hex"));
}
if (!/^\d+$/.test(timestamp ?? "") || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;

const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, "utf8"), rawBody]);
return secrets.some((secret) => {
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest();
return signatures.some(
(signature) => signature.length === expected.length && crypto.timingSafeEqual(signature, expected),
);
});
}

const app = express();

app.post("/beepclock/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(req.get("X-BeepClock-Signature"), req.body, secrets)) {
return res.sendStatus(401);
}

const deliveryId = req.get("X-BeepClock-Delivery");
const event = JSON.parse(req.body.toString("utf8"));
enqueue(deliveryId, event);
res.sendStatus(204);
});

app.listen(3000);

express.raw keeps the body as a Buffer. Do not use express.json() on this route: the signature must be computed over the original bytes. enqueue stands for your own code that stores the event, skips delivery IDs it has already processed and handles the event in the background.

Test values​

Use these values to check your implementation. Disable the timestamp tolerance for this check, because t is a fixed time.

InputValue
Secretwhsec_test-secret
t1791788292
Raw body{"id":"0b9e6c41-5d2a-4f78-93e1-c6a8b2f4d071","event":"webhook.test"}
Expected v181e8ba359713e992413513ead157645df8e568201531f6ef5b3c1764fc5658bc

The quickest end-to-end check is Send test event on the webhook page: a valid receiver responds with 2xx, and a signature error shows up as your 401.

Secret rotation​

Generate new signing secret creates a new secret and shows it once. For the next 24 hours every request is signed with both secrets, so the header contains two v1 values: the first for the new secret, the second for the previous one. After 24 hours only the new secret is used.

To rotate without downtime:

  1. Choose Generate new signing secret and copy the new secret.
  2. Add it to your receiver next to the old one. The code examples above accept a list of secrets.
  3. Remove the old secret once it is no longer needed, at the latest after 24 hours.
tip

Rotate the secret whenever it may have been exposed, for example after it was pasted into a ticket or a chat, or when someone who knew it leaves the team.