ZapyFlow/Documentación de la APIopenapi.json

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

CabeceraSignificado
X-ZapyFlow-EventEl nombre público del evento, por ejemplo order.created.
X-ZapyFlow-Event-IdEstable en todos los intentos del mismo evento. Es tu clave de deduplicación.
X-ZapyFlow-Delivery-IdEsta entrega a este endpoint. Cambia por endpoint.
X-ZapyFlow-AttemptContador de intento, empezando en 1.
X-ZapyFlow-TimestampSegundos unix. Forma parte de la cadena firmada.
X-ZapyFlow-Signaturev1=<hex>. Lista separada por comas durante una rotación de secreto.

El esquema de firma

La cadena firmada es exactamente:

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

Verifica contra el cuerpo crudo. El fallo de integración más común de todos es calcular el HMAC sobre 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

javascript
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

bash
printf 'v1:%s:%s' "$TIMESTAMP" "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex

Vectores 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

CasoTimestampCuerpoFirma esperada
ascii simples1755600000{"event":"order.created","data":{"id":"ord_1"}}7af23a8a23693a3882586a61c28abebf193dc7a7bdf985ce3a93599e8a3f713d
utf-8 com acento e emoji1755600000{"event":"contact.updated","data":{"name":"Conceição 🇧🇷"}}6641c9068cfed3f201349adfb41c39d8375f1d65d12af53b378f050d716120d1
corpo vazio1755600000{}699e300ddb2512d8d76d3b7b53573193719ee3bd672088b1f934c3bbffb5e812
barra e menor-que, nao escapados1755600000{"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 respuestaQué hacemos
2xxEntrega exitosa. El contador de fallos consecutivos se pone a cero.
3xxFallo 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ónSe reintenta según el calendario de abajo.
410 GoneEndpoint desactivado al instante. Eso es lo que significa 410.
Otros 4xxFallo 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:

text
intento 1  inmediatamente
intento 2  +30 segundos
intento 3  +5 minutos
intento 4  +30 minutos
intento 5  +4 horas
intento 6  +24 horas

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

Un límite, declarado en vez de escondido: resolvemos el nombre y luego conectamos, así que una entrada DNS que cambia entre esos dos pasos no está cubierta. Cerrarlo exige fijar la dirección resuelta en el momento de conectar, algo que nuestro runtime no admite. Preferimos escribir la frontera a dejar que parezca completa.