ByDomuDevelopers

Verify webhook signatures

X-Domu-Signature in Node, Python, PHP and Go, with a test vector to check your code.

GuideNode · Python · PHP · GoHMAC-SHA256

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-Signature
Legacy signature: HMAC of the body onlyX-Webhook-Signature
Event type (or legacy name)X-Domu-Event
Attempt number (1 to 5)X-Webhook-Attempt
Subscription idX-Webhook-Id
Only on test deliveriesX-Webhook-Test: true
SenderUser-Agent: Domu-Webhooks/2

Verify 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.

Test vector
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: 1028740337f17ec37cd46ba461bec24105784b249b9280f87f56436376a869c9

If 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.