ZapyFlow/Documentação da APIopenapi.json

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çalhoSignificado
X-ZapyFlow-EventO nome público do evento, por exemplo order.created.
X-ZapyFlow-Event-IdEstável entre todas as tentativas do mesmo evento. É a sua chave de deduplicação.
X-ZapyFlow-Delivery-IdEsta entrega para este endpoint. Muda por endpoint.
X-ZapyFlow-AttemptContador de tentativa, começando em 1.
X-ZapyFlow-TimestampSegundos unix. Faz parte da string assinada.
X-ZapyFlow-Signaturev1=<hex>. Lista separada por vírgula durante rotação de segredo.

O esquema de assinatura

A string assinada é exatamente:

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

Verifique contra o corpo cru. A falha de integração mais comum de todas é calcular o HMAC em cima de 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

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"] ?? "");

  // 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

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

Vetores 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

CasoTimestampCorpoAssinatura 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

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 respostaO que fazemos
2xxEntrega bem-sucedida. O contador de falhas consecutivas zera.
3xxFalha 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ãoRetentado no calendário abaixo.
410 GoneEndpoint desligado na hora. É isso que 410 significa.
Outros 4xxFalha 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:

text
tentativa 1  imediatamente
tentativa 2  +30 segundos
tentativa 3  +5 minutos
tentativa 4  +30 minutos
tentativa 5  +4 horas
tentativa 6  +24 horas

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

Um limite, declarado em vez de escondido: nós resolvemos o nome e depois conectamos, então uma entrada de DNS que muda entre esses dois passos não está coberta. Fechar isso exige fixar o endereço resolvido no momento da conexão, o que o nosso runtime não suporta. Preferimos escrever a fronteira a deixá-la parecer completa.