Verify signatures
Check that every webhook comes from ChatBeds and was not changed or replayed, with code for Node.js and Python.
Anyone can send a POST to your webhook address. Before you trust one, check its ChatBeds-Signature header. It proves the request was made by ChatBeds with your secret, that the body was not changed on the way, and when it was sent.
The header
ChatBeds-Signature: t=1791608987,v1=6900418cbb9ac7a69603cc6751a85d6abad4b93767052291f909c243aff58bc6| Part | Meaning |
|---|---|
t | When ChatBeds signed the request, in Unix seconds |
v1 | HMAC-SHA256 of the signed text, keyed with your webhook secret (whsec_...), in lowercase hex |
The signed text is the timestamp, a full stop, then the raw body exactly as received:
1791608987.{"id":"c8839bfe-aa2f-4ac4-9847-d30e112516d6","type":"reservation.created",...}Checking it
Read the raw body
Take the body as bytes, before any JSON parsing. Parsing and re-serialising changes spacing and key order, and the signature will no longer match.
Split the header
Read t and v1 from the comma-separated key=value pairs. Ignore any parts you don't know, so a future signature scheme can be added without breaking you.
Compute and compare
Compute HMAC-SHA256(secret, t + "." + body) as hex and compare it with v1 using a constant-time comparison.
Check the time
Refuse the request if t is more than 5 minutes from your clock. A retry is signed again when it is sent, so a genuine request is never old.
Code
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.CHATBEDS_WEBHOOK_SECRET; // whsec_...
const TOLERANCE = 5 * 60; // seconds
function verify(rawBody, header) {
const parts = Object.fromEntries(
(header || "").split(",").map((p) => p.trim().split("=", 2)),
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as a Buffer, exactly as sent.
app.post("/chatbeds/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("ChatBeds-Signature"))) {
return res.status(400).send("Bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
queue.add(event); // do the work later; answer now
res.sendStatus(204);
});Test values
Check your function against these before you go live. With the time check turned off (the timestamp is old), it must accept them:
| Input | Value |
|---|---|
| Secret | whsec_test_secret_for_docs |
t | 1791608987 |
| Body | {"id":"c8839bfe-aa2f-4ac4-9847-d30e112516d6","type":"reservation.created"} |
Expected v1 | 6a3e3fbc70884d929d4e1aa13728bd3df7d6d1369d41214bed413987e8bc3085 |
printf '%s.%s' 1791608987 '{"id":"c8839bfe-aa2f-4ac4-9847-d30e112516d6","type":"reservation.created"}' \
| openssl dgst -sha256 -hmac "whsec_test_secret_for_docs"Secrets
- The secret is shown once, when you save the webhook address. Store it with your other secrets, never in client code.
- Saving the address again makes a new secret, and the old one stops working at once. Deploy the new secret first if you can, or accept that deliveries fail (and are retried) for the few minutes in between.
- An app has one secret for all its connections. A private key has its own.
Common mistakes
- Verifying a body your framework has already parsed and re-serialised.
- Comparing with
==instead of a constant-time compare. - Using the API key or client secret instead of the
whsec_webhook secret.
Building something?