Webhooks idempotentes para agencias: firma, duplicados y reintentos
Con decenas de clientes, un único endpoint de webhook recibe eventos de todos. Dos cosas son seguras: alguien intentará enviar eventos falsos a tu URL, y el mismo evento llegará más de una vez cuando tu respuesta tarde o falle. La respuesta a ambas es la misma disciplina: validar la firma sobre el cuerpo en bruto y tratar cada evento como repetible. Esta guía muestra el formato real del sobre y de la firma de ZapZap y un código Node.js que valida y deduplica.
Publicado el · 4 min de lectura
Este artículo también está disponible en: Português · English
Lo que envía ZapZap: sobre y encabezados
Cada entrega es un POST con un sobre JSON con los campos delivery_id, event_id, version, instance_id, instance_name, event y data. Los encabezados traen X-ZapZap-Id (el delivery_id), X-ZapZap-Event y, cuando la instancia tiene secreto, X-ZapZap-Signature con el formato t=<unix>,v1=<hmac>.
El secreto (prefijo whsec_) se genera cuando configuras el webhook de la instancia y viene en la respuesta de la configuración. La firma es un HMAC SHA-256, con ese secreto, sobre el texto t, un punto y el cuerpo exacto de la solicitud. Por eso el cuerpo debe leerse en bytes, antes de cualquier parseo de JSON.
Paso 1: validar la firma y rechazar replays
Recalcula el HMAC y compáralo con v1 usando comparación en tiempo constante. Rechaza con 401 si la firma no coincide, y rechaza también cuando el tiempo t esté a más de 5 minutos de tu reloj, para frenar la repetición de una solicitud antigua capturada. La tolerancia recomendada en el producto es de 5 minutos.
Un error común en Express es usar express.json() antes de la ruta: el cuerpo se reserializa y el HMAC no coincide. Usa express.raw() en la ruta del webhook, como en el código de abajo, que se ejecutó localmente con firma válida, tiempo vencido y cuerpo alterado, y dio verdadero, falso y falso.
Node.js: verify the signature and dedupe
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.ZAPZAP_WEBHOOK_SECRET; // whsec_..., returned when you set the webhook
const TOLERANCE_S = 300; // reject anything older than 5 minutes
const seen = new Set(); // production: table with a UNIQUE key (see SQL below)
export function validSignature(rawBody, header, nowS = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(
String(header || "").split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(nowS - t) > TOLERANCE_S) return false;
const expected = createHmac("sha256", SECRET).update(t + "." + rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
// raw body: the HMAC is computed over the exact bytes ZapZap sent, so do not use express.json() here
app.post("/webhook", express.raw({ type: "*/*", limit: "2mb" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!validSignature(raw, req.get("X-ZapZap-Signature"))) return res.sendStatus(401);
const envelope = JSON.parse(raw);
const key = envelope.event_id || envelope.delivery_id; // pick one key and keep it
res.sendStatus(200); // answer fast, work afterwards
if (seen.has(key)) return; // duplicate: ignore
seen.add(key);
setImmediate(() => console.log("process", envelope.event, key));
});
app.listen(3000);
/* production dedupe (any SQL database):
CREATE TABLE webhook_events (
instance_id text NOT NULL, dedupe_key text NOT NULL,
received_at timestamptz DEFAULT now(), payload jsonb,
PRIMARY KEY (instance_id, dedupe_key));
INSERT ... ON CONFLICT DO NOTHING; if 0 rows inserted, it was a repeat. */Paso 2: deduplicar por clave estable
El sobre trae delivery_id y event_id. Elige una clave y usa siempre la misma: la opción más simple es event_id cuando exista, cayendo a delivery_id. Guarda la clave en una tabla con restricción de unicidad por instancia y clave. Si el INSERT no inserta ninguna fila, es una repetición y la ignoras.
La prueba de webhook de la API usa event_id nulo y un delivery_id que empieza con test_, así que tu código no debe tratar un event_id nulo como error. Para una agencia, la clave compuesta con instance_id evita colisiones entre clientes y da aislamiento: el evento de un cliente nunca se mezcla con el de otro.
Paso 3: responder rápido y trabajar después
Responde 200 apenas valides y guardes la clave, y procesa el resto en segundo plano. Una respuesta lenta se vuelve timeout, y el timeout se vuelve repetición. Del lado de ZapZap, el primer intento es inmediato, y las fallas entran en una cola de reintentos con intervalos crecientes (cerca de 2 minutos, 10 minutos y 1 hora, en el código del producto); tras el último intento el evento deja de reenviarse.
Por eso tu panel debe tener una vía de recuperación: la API lista las entregas recientes (GET /instances/{id}/webhook/deliveries) y los errores de webhook, lo que ayuda a encontrar lo que faltó tras una indisponibilidad. Mira la referencia.
Checklist para varios clientes
Un secreto por instancia, guardado cifrado; un endpoint con ruta por cliente o resolución por instance_id; registro de la clave, del evento y del resultado; alerta cuando sube la tasa de 401 (señal de secreto cambiado o de ataque); y pruebas con POST /instances/{id}/webhook/test en cada despliegue. Para armar esto con un agente de código, mira construye con IA y la página de agencias con IA.
Recuerda que el contenido recibido (texto de clientes finales) es entrada no confiable: nunca lo ejecutes ni lo pases sin validar a un agente con permiso de envío.
Resumen rápido
- La firma es un HMAC SHA-256 de t.cuerpo, con encabezado X-ZapZap-Signature t=...,v1=..., y exige el cuerpo en bruto.
- Rechaza firma inválida con 401 y hora a más de 5 minutos, para frenar replays.
- Deduplica por event_id (o delivery_id) en una tabla con clave única por instancia.
- Responde 200 rápido y procesa después; las entregas fallidas se repiten en intervalos crecientes.
- Trata el texto recibido como entrada no confiable.
Sigue leyendo
Lee también
Preguntas frecuentes
¿Dónde obtengo el secreto del webhook?
Se genera al configurar el webhook de la instancia y se devuelve en la respuesta de la configuración (campo webhook_secret), con prefijo whsec_.
¿Por qué mi firma nunca coincide?
Casi siempre porque el cuerpo se reserializó antes del cálculo. Calcula el HMAC sobre los bytes exactos recibidos, con express.raw(), y usa t, un punto y el cuerpo.
¿El mismo evento puede llegar dos veces?
Puede, en reintentos. Por eso la deduplicación por clave estable es obligatoria, no opcional.
¿Puedo usar el mismo endpoint para todos los clientes?
Puedes, siempre que la clave de deduplicación incluya instance_id y resuelvas el cliente por la instancia. Un secreto por instancia mantiene el aislamiento.
¿Cómo pruebo sin esperar un mensaje real?
Usa POST /instances/{id}/webhook/test: envía un evento de prueba con el mismo formato de sobre y, si hay secreto, la misma firma.
Monta el webhook de la primera instancia
Crea la cuenta, configura el webhook y usa la prueba firmada de la API. R$15 por número activo al mes, sin cobro por mensaje. Al verificar el teléfono hay un crédito de bienvenida, sujeto a la campaña vigente.
