Webhooks
Hacemos POST de un cuerpo JSON a tu URL cuando algo ocurre. Toda entrega va firmada, lleva un id de evento estable y se reintenta según un calendario publicado.
Cabeceras de toda entrega
| Cabecera | Significado |
|---|---|
| X-ZapyFlow-Event | El nombre público del evento, por ejemplo order.created. |
| X-ZapyFlow-Event-Id | Estable en todos los intentos del mismo evento. Es tu clave de deduplicación. |
| X-ZapyFlow-Delivery-Id | Esta entrega a este endpoint. Cambia por endpoint. |
| X-ZapyFlow-Attempt | Contador de intento, empezando en 1. |
| X-ZapyFlow-Timestamp | Segundos unix. Forma parte de la cadena firmada. |
| X-ZapyFlow-Signature | v1=<hex>. Lista separada por comas durante una rotación de secreto. |
El esquema de firma
La cadena firmada es exactamente:
v1:<X-ZapyFlow-Timestamp>:<los bytes exactos del cuerpo de la peticion>HMAC-SHA256, en hexadecimal. La clave es la cadena entera del secreto, tal como aparece en el panel, prefijo incluido — no hay nada que quitar ni decodificar.
JSON.stringify(req.body) después de que un framework lo haya analizado. Re-serializar reordena claves y cambia espaciado, y la firma nunca coincidirá. En Express, usa express.raw() en la ruta del webhook.Verificando en Node
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, headers, secret) {
const timestamp = Number(headers["x-zapyflow-timestamp"]);
const header = String(headers["x-zapyflow-signature"] ?? "");
// Rechaza cualquier cosa de mas de cinco minutos: una firma valida sigue
// siendo valida para siempre, asi que el timestamp es lo que limita el replay.
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`v1:${timestamp}:${rawBody}`, "utf8")
.digest("hex");
// Un === normal filtra tiempo. Compara en tiempo constante.
return header
.split(",")
.map((part) => part.trim().replace(/^v1=/, ""))
.some((candidate) =>
candidate.length === expected.length &&
timingSafeEqual(Buffer.from(candidate, "hex"), Buffer.from(expected, "hex")),
);
}Verificando desde la terminal
printf 'v1:%s:%s' "$TIMESTAMP" "$RAW_BODY" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hexVectores de respuesta conocida
Prueba tu implementación contra estos antes de salir a producción. Son los mismos vectores que verifica nuestra propia batería de pruebas — incluidos los dos que atrapan fallos reales: UTF-8 más allá de ASCII, y un cuerpo con / y <, que no escapamos.
Secreto: whsec_exemplo_de_segredo_para_os_vetores
| Caso | Timestamp | Cuerpo | Firma esperada |
|---|---|---|---|
| ascii simples | 1755600000 | {"event":"order.created","data":{"id":"ord_1"}} | 7af23a8a23693a3882586a61c28abebf193dc7a7bdf985ce3a93599e8a3f713d |
| utf-8 com acento e emoji | 1755600000 | {"event":"contact.updated","data":{"name":"Conceição 🇧🇷"}} | 6641c9068cfed3f201349adfb41c39d8375f1d65d12af53b378f050d716120d1 |
| corpo vazio | 1755600000 | {} | 699e300ddb2512d8d76d3b7b53573193719ee3bd672088b1f934c3bbffb5e812 |
| barra e menor-que, nao escapados | 1755600000 | {"url":"https://a/b","note":"<b>"} | a8f589e48d487396f634b2d59915b543f61ec088d55b591e1d30b6a58ac0f643 |
Respondiendo
Devuelve cualquier 2xx en 10 segundos. Haz el trabajo real de forma asíncrona — contamos el timeout como fallo y reintentamos, así que un handler lento convierte un evento en seis.
| Tu respuesta | Qué hacemos |
|---|---|
| 2xx | Entrega exitosa. El contador de fallos consecutivos se pone a cero. |
| 3xx | Fallo terminal, sin reintento. Apunta el endpoint a la URL final — nunca seguimos redirecciones, porque seguir una reabriría las protecciones SSRF que aplicamos a tu URL. |
| 408, 425, 429, 5xx, timeout, error de conexión | Se reintenta según el calendario de abajo. |
| 410 Gone | Endpoint desactivado al instante. Eso es lo que significa 410. |
| Otros 4xx | Fallo terminal, sin reintento — una petición mala será igual de mala la segunda vez. |
Calendario de reintentos
Seis intentos a lo largo de unas 31 horas, con jitter:
intento 1 inmediatamente
intento 2 +30 segundos
intento 3 +5 minutos
intento 4 +30 minutos
intento 5 +4 horas
intento 6 +24 horasSi devuelves Retry-After en un 429, lo respetamos en su lugar.
Desactivación automática
Tras 20 entregas fallidas consecutivas el endpoint se desactiva y el propietario del workspace recibe un correo. El contador es por entrega, no por intento — un endpoint que falla un evento seis veces ha fallado una vez, no seis.
Lo que nos negamos a llamar
La URL del endpoint se valida al guardarla y otra vez antes de cada intento: un nombre que hoy resuelve a una dirección pública puede reapuntarse mañana a una privada. Rechazamos direcciones privadas, de loopback, link-local y de metadatos, exigimos https y rechazamos nuestros propios nombres.