Verify webhook signatures
X-Domu-Signature in Node, Python, PHP and Go, with a test vector to check your code.
How each delivery is signed
X-Domu-Signature: t=<unix>,v1=<hex>. v1 is the hex HMAC-SHA256 of “t” + “.” + the raw body, keyed with the subscription secret (the one POST /webhooks returned once, or the Tickets one on its screen). Reject timestamps older than 5 minutes so nobody can replay an old delivery.
HeaderValue
Timestamped signature (use this)
X-Domu-SignatureLegacy signature: HMAC of the body only
X-Webhook-SignatureEvent type (or legacy name)
X-Domu-EventAttempt number (1 to 5)
X-Webhook-AttemptSubscription id
X-Webhook-IdOnly on test deliveries
X-Webhook-Test: trueSender
User-Agent: Domu-Webhooks/2Verify it on your server
All four do the same: read the raw body, split t and v1, check the age, compute the HMAC and compare in constant time.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SECRET = process.env.BYDOMU_WEBHOOK_SECRET;
// El cuerpo CRUDO: firmar el JSON ya parseado y vuelto a serializar no coincide.
app.post("/webhooks/bydomu", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString("utf8"), req.get("X-Domu-Signature") ?? "")) {
return res.status(401).end();
}
res.status(200).end(); // responde antes de 10 s…
handle(JSON.parse(req.body.toString())); // …y procesa aparte
});
function verify(rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}Test your code with this vector
With this secret, timestamp and body, your function must compute exactly these signatures (skip the age check, since the timestamp is old). Then use the webhook’s “Send a test”: that delivery carries X-Webhook-Test: true and data with a message.
secret whsec_prueba_0000000000000000
timestamp 1790000000
body {"event":"deal.won","id":"5f3a0c1e-7d2b-4f7e-9a51-0b6c1d2e3f40","type":"deal.won"}
X-Domu-Signature: t=1790000000,v1=d855533ad9162950679a857a7fa61761e42dd7ac6438bc64b4640097d51540b3
X-Webhook-Signature: 1028740337f17ec37cd46ba461bec24105784b249b9280f87f56436376a869c9If the signature doesn’t match
- bodyYou’re signing the parsed JSON. Use the bytes as they arrived (express.raw, request.get_data(), php://input, io.ReadAll).
- keyThe secret isn’t that subscription’s, or you rotated it and your server still has the old one.
- timeYour server’s clock is more than 5 minutes off: sync it (NTP).
- 2xxIf you answer slowly or with an error, it’s retried: Up to 5 attempts (0 s, 5 s, 30 s, 2 min and 10 min) on network errors, 429 or 5xx. Reply 2xx in under 10 s.