Webhooks
Fazemos POST de um corpo JSON na sua URL quando algo acontece. Toda entrega é assinada, carrega um id de evento estável e é retentada num calendário publicado.
Cabeçalhos de toda entrega
| Cabeçalho | Significado |
|---|---|
| X-ZapyFlow-Event | O nome público do evento, por exemplo order.created. |
| X-ZapyFlow-Event-Id | Estável entre todas as tentativas do mesmo evento. É a sua chave de deduplicação. |
| X-ZapyFlow-Delivery-Id | Esta entrega para este endpoint. Muda por endpoint. |
| X-ZapyFlow-Attempt | Contador de tentativa, começando em 1. |
| X-ZapyFlow-Timestamp | Segundos unix. Faz parte da string assinada. |
| X-ZapyFlow-Signature | v1=<hex>. Lista separada por vírgula durante rotação de segredo. |
O esquema de assinatura
A string assinada é exatamente:
v1:<X-ZapyFlow-Timestamp>:<os bytes exatos do corpo da requisicao>HMAC-SHA256, em hexadecimal. A chave é a string inteira do segredo, como aparece no painel, com prefixo e tudo — não há nada para tirar nem decodificar.
JSON.stringify(req.body) depois de um framework ter feito o parsing. Re-serializar reordena chaves e muda espaçamento, e a assinatura nunca vai bater. No Express, use express.raw() na rota do webhook.Verificando em 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"] ?? "");
// Recusa qualquer coisa com mais de cinco minutos: uma assinatura valida
// continua valida para sempre, entao o timestamp e o que limita 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");
// Um === comum vaza tempo. Compare em tempo 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 pelo terminal
printf 'v1:%s:%s' "$TIMESTAMP" "$RAW_BODY" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hexVetores de resposta conhecida
Rode a sua implementação contra estes antes de subir. São os mesmos vetores que a nossa própria suíte de teste verifica — incluindo os dois que pegam bug de verdade: UTF-8 além do ASCII, e um corpo com / e <, que nós nãoescapamos.
Segredo: whsec_exemplo_de_segredo_para_os_vetores
| Caso | Timestamp | Corpo | Assinatura 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 |
Respondendo
Devolva qualquer 2xx em até 10 segundos. Faça o trabalho de verdade de forma assíncrona — contamos timeout como falha e retentamos, então um handler lento transforma um evento em seis.
| Sua resposta | O que fazemos |
|---|---|
| 2xx | Entrega bem-sucedida. O contador de falhas consecutivas zera. |
| 3xx | Falha terminal, sem retentativa. Aponte o endpoint para a URL final — nunca seguimos redirecionamento, porque seguir um reabriria as proteções de SSRF que aplicamos à sua URL. |
| 408, 425, 429, 5xx, timeout, erro de conexão | Retentado no calendário abaixo. |
| 410 Gone | Endpoint desligado na hora. É isso que 410 significa. |
| Outros 4xx | Falha terminal, sem retentativa — uma requisição ruim vai ser igualmente ruim na segunda vez. |
Calendário de retentativa
Seis tentativas ao longo de aproximadamente 31 horas, com jitter:
tentativa 1 imediatamente
tentativa 2 +30 segundos
tentativa 3 +5 minutos
tentativa 4 +30 minutos
tentativa 5 +4 horas
tentativa 6 +24 horasSe você devolver Retry-After num 429, respeitamos ele no lugar.
Desligamento automático
Depois de 20 entregas falhas consecutivas, o endpoint é desligado e o proprietário do workspace recebe um e-mail. O contador é por entrega, não por tentativa — um endpoint que falha um evento seis vezes falhou uma, não seis.
O que recusamos chamar
A URL do endpoint é validada ao ser salva e de novo antes de cada tentativa: um nome que resolve para endereço público hoje pode ser reapontado para um privado amanhã. Recusamos endereços privados, de loopback, link-local e de metadados, exigimos https e recusamos os nossos próprios nomes.