ByDomuDevelopers

Verifica la firma de los webhooks

X-Domu-Signature en Node, Python, PHP y Go, con un vector de prueba para comprobar tu código.

GuíaNode · Python · PHP · GoHMAC-SHA256

Cómo se firma cada entrega

X-Domu-Signature: t=<unix>,v1=<hex>. v1 es el HMAC-SHA256, en hexadecimal, de «t» + «.» + el cuerpo crudo, con el secret de la suscripción (el que devolvió POST /webhooks una sola vez, o el de Tickets en su pantalla). Rechaza marcas de más de 5 minutos para que nadie repita una entrega vieja.

EncabezadoValor
Firma con marca de tiempo (úsala)X-Domu-Signature
Firma legada: HMAC del cuerpo soloX-Webhook-Signature
Tipo del evento (o nombre legado)X-Domu-Event
Número de intento (1 a 5)X-Webhook-Attempt
Id de la suscripciónX-Webhook-Id
Solo en las entregas de pruebaX-Webhook-Test: true
Quién envíaUser-Agent: Domu-Webhooks/2

Verifícala en tu servidor

Los cuatro hacen lo mismo: leen el cuerpo crudo, separan t y v1, revisan la edad, calculan el HMAC y comparan en tiempo constante.

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);
}

Prueba tu código con este vector

Con este secret, esta marca y este cuerpo, tu función debe calcular exactamente estas firmas (sin la revisión de edad, porque la marca es vieja). Luego usa «Enviar una prueba» del webhook: esa entrega trae X-Webhook-Test: true y data con un mensaje.

Vector de prueba
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

Si la firma no coincide

  • bodyEstás firmando el JSON parseado. Usa los bytes tal como llegaron (express.raw, request.get_data(), php://input, io.ReadAll).
  • keyEl secret no es el de esa suscripción, o lo rotaste y tu servidor tiene el anterior.
  • timeEl reloj de tu servidor está corrido más de 5 minutos: sincronízalo (NTP).
  • 2xxSi respondes lento o con error, se reintenta: Hasta 5 intentos (0 s, 5 s, 30 s, 2 min y 10 min) ante error de red, 429 o 5xx. Responde 2xx en menos de 10 s.