ChatBedsDevelopers
Webhooks

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
ChatBeds-Signature: t=1791608987,v1=6900418cbb9ac7a69603cc6751a85d6abad4b93767052291f909c243aff58bc6
PartMeaning
tWhen ChatBeds signed the request, in Unix seconds
v1HMAC-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:

Signed text
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

webhooks.js (Express)
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:

InputValue
Secretwhsec_test_secret_for_docs
t1791608987
Body{"id":"c8839bfe-aa2f-4ac4-9847-d30e112516d6","type":"reservation.created"}
Expected v16a3e3fbc70884d929d4e1aa13728bd3df7d6d1369d41214bed413987e8bc3085
The same check with openssl
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?

On this page