Firmas de webhooks
Cada entrega va firmada con HMAC-SHA256 y el secreto de tu webhook. Verifícala antes de hacer nada con el cuerpo.
Cómo se calcula
firmado = X-WAT-Timestamp + "." + cuerpo_crudo
firma = "v1=" + hex( HMAC_SHA256( secret, firmado ) )
cabecera X-WAT-Signature: v1=3f7a… (64 caracteres hexadecimales tras "v1=")
durante una rotación con solape llegan VARIAS, separadas por coma:
X-WAT-Signature: v1=<nueva>,v1=<anterior>
→ parte por comas y acepta si ALGUNA cuadra con tu secreto.| Pieza | Detalle |
|---|---|
| secret | El whsec_… que devolvió POST /v1/webhooks. Se usa tal cual, como texto UTF-8. |
| X-WAT-Timestamp | Segundos Unix (texto). Se firma como llega, sin convertir. |
| cuerpo_crudo | Los bytes exactos del cuerpo. No lo parsees y vuelvas a serializar: el JSON puede cambiar de forma. |
| Comparación | En tiempo constante y con la misma longitud. Nunca con ==. |
| Ventana | Rechaza si |ahora − timestamp| > 300 s. Protege de reenvíos. |
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const VENTANA_S = 5 * 60;
export function verificarWebhook({ secret, headers, rawBody }) {
const ts = headers['x-wat-timestamp'];
const cabecera = headers['x-wat-signature'];
if (!ts || !cabecera) return false;
if (!/^\d{1,12}$/.test(ts)) return false; // solo segundos Unix: `Number('abc')` es NaN y NaN nunca es > 300
// 1 · ventana de tiempo (reenvíos)
const ahora = Math.floor(Date.now() / 1000);
if (Math.abs(ahora - Number(ts)) > VENTANA_S) return false;
// 2 · HMAC sobre timestamp + "." + cuerpo crudo
const esperada = 'v1=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`, 'utf8').digest('hex');
// 3 · la cabecera puede traer VARIAS firmas (rotación con solape): vale si alguna cuadra.
// Comparación en tiempo constante, sin cortocircuitar en la primera.
const a = Buffer.from(esperada);
return cabecera.split(',').reduce((ok, trozo) => {
const b = Buffer.from(trozo.trim());
return (a.length === b.length && timingSafeEqual(a, b)) || ok;
}, false);
}
// Express: el cuerpo tiene que llegar CRUDO
import express from 'express';
const app = express();
app.post('/hooks/wat', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verificarWebhook({ secret: process.env.WAT_WEBHOOK_SECRET, headers: req.headers, rawBody })) {
return res.status(401).end();
}
const evento = JSON.parse(rawBody);
// dedup por evento.id … encolar … y responder YA
res.status(200).end();
});Python
import hmac, hashlib, time
def verificar(secret: str, timestamp: str, raw_body: bytes, cabecera: str, ventana_s: int = 300) -> bool:
if not timestamp.isdigit() or abs(int(time.time()) - int(timestamp)) > ventana_s:
return False
esperada = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
# La cabecera puede traer varias firmas separadas por coma (rotación con solape).
return any(hmac.compare_digest(esperada, f.strip()) for f in cabecera.split(","))Probar la verificación
Con la firma de un evento de prueba puedes comprobar tu implementación sin esperar a una reserva real: Pruebas. Con este secreto y este cuerpo la firma es reproducible:
secret whsec_test
timestamp 1758189600
cuerpo {"ok":true}
firmado 1758189600.{"ok":true}
firma v1=f4d27bf77b91cba15c056f58e6556853d2c042007d4e430db6c3ad6b1c328baeRotar el secreto sin corte
POST /v1/webhooks/{id}/rotate-secret con { "overlap_minutes": 60 } devuelve un secreto nuevo (una sola vez, como al crear) y abre una ventana de solape: durante esos minutos cada entrega lleva las dos firmas, la nueva y la anterior, separadas por coma. Despliega el secreto nuevo cuando quieras dentro de la ventana; no pierdes eventos y no recibes nada duplicado. overlap_minutes es obligatorio (0 a 1440); con 0 el anterior deja de valer en el acto.
| Momento | X-WAT-Signature | Quién valida |
|---|---|---|
| Dentro de la ventana | v1=nueva,v1=anterior | El que ya migró y el que todavía no. |
| Pasada la ventana | v1=nueva | Solo el secreto nuevo. |
La alternativa de siempre sigue valiendo: crear un webhook nuevo, desplegar y borrar el viejo. Pero entonces durante el solape recibirás cada evento dos veces, una por cada destino, y tendrás que deduplicar por X-WAT-Event-Id.