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.
The signature header
X-BeepClock-Signature: t=1791788292,v1=9f2b...a1,v1=4c8e...77
tis the time the request was signed, as a Unix timestamp in seconds (UTC).v1is a version 1 signature. The header can contain more than onev1value 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))
tis exactly the value from the header.raw_bodyis the raw request body: the bytes received from the network, before any JSON parsing.secretis the whole signing secret including thewhsec_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.
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
- Read
tand everyv1value from the header. - Compute HMAC-SHA256 of
{t}.{raw_body}with each secret you currently accept. - Compare the result with each
v1value using a constant-time comparison. A match with anyv1value means the signature is valid. - Reject requests without a valid signature, for example with
401. - Reject requests whose
tdiffers 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
- Node.js (Express)
- C# (ASP.NET Core)
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.
using System.Security.Cryptography;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secrets = new[] { builder.Configuration["BeepClock:WebhookSecret"]! };
app.MapPost("/beepclock/webhooks", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var body = buffer.ToArray();
if (!BeepClockSignature.IsValid(request.Headers["X-BeepClock-Signature"].ToString(), body, secrets,
DateTimeOffset.UtcNow))
return Results.Unauthorized();
var deliveryId = request.Headers["X-BeepClock-Delivery"].ToString();
var payload = System.Text.Json.JsonDocument.Parse(body);
// Store the event, skip delivery IDs that were already processed and handle it in the background.
return Results.NoContent();
});
app.Run();
static class BeepClockSignature
{
private static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);
public static bool IsValid(string header, byte[] body, IEnumerable<string> secrets, DateTimeOffset now)
{
long? timestamp = null;
var signatures = new List<byte[]>();
foreach (var part in header.Split(',', StringSplitOptions.TrimEntries))
{
var index = part.IndexOf('=');
if (index <= 0) continue;
var key = part[..index];
var value = part[(index + 1)..];
if (key == "t" && long.TryParse(value, out var t))
timestamp = t;
else if (key == "v1" && value.Length == 64 && value.All(Uri.IsHexDigit))
signatures.Add(Convert.FromHexString(value));
}
if (timestamp is not { } seconds || signatures.Count == 0) return false;
if ((now - DateTimeOffset.FromUnixTimeSeconds(seconds)).Duration() > Tolerance) return false;
var signedPayload = Encoding.UTF8.GetBytes($"{seconds}.").Concat(body).ToArray();
return secrets.Any(secret =>
{
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signedPayload);
return signatures.Any(signature => CryptographicOperations.FixedTimeEquals(signature, expected));
});
}
}
Test values
Use these values to check your implementation. Disable the timestamp tolerance for this check, because t is a fixed time.
| Input | Value |
|---|---|
| Secret | whsec_test-secret |
t | 1791788292 |
| Raw body | {"id":"0b9e6c41-5d2a-4f78-93e1-c6a8b2f4d071","event":"webhook.test"} |
Expected v1 | 81e8ba359713e992413513ead157645df8e568201531f6ef5b3c1764fc5658bc |
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:
- Choose Generate new signing secret and copy the new secret.
- Add it to your receiver next to the old one. The code examples above accept a list of secrets.
- Remove the old secret once it is no longer needed, at the latest after 24 hours.
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.