Saltar al contenido
Firmas
Contrato OpenAPI 3.1: openapi.yaml
Webhooks

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

text
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.
PiezaDetalle
secretEl whsec_… que devolvió POST /v1/webhooks. Se usa tal cual, como texto UTF-8.
X-WAT-TimestampSegundos Unix (texto). Se firma como llega, sin convertir.
cuerpo_crudoLos bytes exactos del cuerpo. No lo parsees y vuelvas a serializar: el JSON puede cambiar de forma.
ComparaciónEn tiempo constante y con la misma longitud. Nunca con ==.
VentanaRechaza si |ahora − timestamp| > 300 s. Protege de reenvíos.

Node.js

javascript
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

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:

text
secret     whsec_test
timestamp  1758189600
cuerpo     {"ok":true}
firmado    1758189600.{"ok":true}
firma      v1=f4d27bf77b91cba15c056f58e6556853d2c042007d4e430db6c3ad6b1c328bae

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

MomentoX-WAT-SignatureQuién valida
Dentro de la ventanav1=nueva,v1=anteriorEl que ya migró y el que todavía no.
Pasada la ventanav1=nuevaSolo el secreto nuevo.
Si prefieres no rotar

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.

Firmas de webhooks · API de WAT