# Verifique a assinatura dos webhooks

X-Domu-Signature em Node, Python, PHP e Go, com um vetor de teste para conferir seu código.

Guia Node · Python · PHP · Go HMAC-SHA256

## Como cada entrega é assinada

`X-Domu-Signature: t=<unix>,v1=<hex>`. v1 é o HMAC-SHA256, em hexadecimal, de «t» + «.» + o corpo cru, com o secret da assinatura (o que POST /webhooks devolveu uma única vez, ou o de Tickets na sua tela). Rejeite carimbos com mais de 5 minutos para que ninguém repita uma entrega antiga.

Cabeçalho Valor

Assinatura com carimbo de tempo (use esta) `X-Domu-Signature`

Assinatura legada: HMAC só do corpo `X-Webhook-Signature`

Tipo do evento (ou nome legado) `X-Domu-Event`

Número da tentativa (1 a 5) `X-Webhook-Attempt`

Id da assinatura `X-Webhook-Id`

Só nas entregas de teste `X-Webhook-Test: true`

Quem envia `User-Agent: Domu-Webhooks/2`

## Verifique no seu servidor

Os quatro fazem o mesmo: leem o corpo cru, separam t e v1, conferem a idade, calculam o HMAC e comparam em tempo constante.

Node.js:

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

Python:

```python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["BYDOMU_WEBHOOK_SECRET"].encode()

def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if "v1" not in parts or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

@app.post("/webhooks/bydomu")
def bydomu():
    if not verify(request.get_data(), request.headers.get("X-Domu-Signature", "")):
        abort(401)
    event = request.get_json()
    # encola event y responde ya
    return "", 200
```

PHP:

```php
<?php
$secret = getenv('BYDOMU_WEBHOOK_SECRET');
$raw    = file_get_contents('php://input');          // cuerpo crudo
$header = $_SERVER['HTTP_X_DOMU_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$k, $v] = array_pad(explode('=', trim($pair), 2), 2, '');
    $parts[$k] = $v;
}
$t = isset($parts['t']) ? (int) $parts['t'] : 0;
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);

if (!$t || abs(time() - $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);
http_response_code(200);
```

Go:

```go
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func Verify(raw []byte, header string, secret []byte) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(strings.TrimSpace(p), "="); ok {
			parts[k] = v
		}
	}
	t, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil || parts["v1"] == "" || math.Abs(float64(time.Now().Unix()-t)) > 300 {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
	mac.Write(raw)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}

func Handler(w http.ResponseWriter, r *http.Request) {
	raw, _ := io.ReadAll(r.Body) // cuerpo crudo
	if !Verify(raw, r.Header.Get("X-Domu-Signature"), []byte(os.Getenv("BYDOMU_WEBHOOK_SECRET"))) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	w.WriteHeader(http.StatusOK)
}
```

## Teste seu código com este vetor

Com este secret, este carimbo e este corpo, sua função deve calcular exatamente estas assinaturas (sem a checagem de idade, porque o carimbo é antigo). Depois use «Enviar um teste» do webhook: essa entrega traz X-Webhook-Test: true e data com uma mensagem.

```
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
```

## Se a assinatura não bater

- **body** Você está assinando o JSON parseado. Use os bytes como chegaram (express.raw, request.get_data(), php://input, io.ReadAll).
- **key** O secret não é o dessa assinatura, ou você o rotacionou e o servidor tem o anterior.
- **time** O relógio do seu servidor está mais de 5 minutos fora: sincronize (NTP).
- **2xx** Se você responder devagar ou com erro, há nova tentativa: Até 5 tentativas (0 s, 5 s, 30 s, 2 min e 10 min) em erro de rede, 429 ou 5xx. Responda 2xx em menos de 10 s.
