Webhooks idempotentes para agências: assinatura, duplicatas e retentativas
Com dezenas de clientes, um único endpoint de webhook recebe eventos de todos eles. Duas coisas são certas: alguém vai tentar enviar eventos falsos para a sua URL, e o mesmo evento vai chegar mais de uma vez quando a sua resposta demorar ou falhar. A resposta para as duas é a mesma disciplina: validar a assinatura sobre o corpo bruto e tratar cada evento como repetível. Este guia mostra o formato real do envelope e da assinatura da ZapZap e um código Node.js que valida e deduplica.
Publicado em · 4 min de leitura
O que a ZapZap envia: envelope e cabeçalhos
Cada entrega é um POST com um envelope JSON com os campos delivery_id, event_id, version, instance_id, instance_name, event e data. Os cabeçalhos trazem X-ZapZap-Id (o delivery_id), X-ZapZap-Event e, quando a instância tem segredo, X-ZapZap-Signature no formato t=<unix>,v1=<hmac>.
O segredo (prefixo whsec_) é gerado quando você configura o webhook da instância e vem na resposta da configuração. A assinatura é um HMAC SHA-256, com esse segredo, sobre o texto t, um ponto e o corpo exato da requisição. Por isso o corpo tem de ser lido em bytes, antes de qualquer parse de JSON.
Passo 1: validar a assinatura e rejeitar replays
Recalcule o HMAC e compare com o v1 usando comparação em tempo constante. Rejeite com 401 se a assinatura não bater, e rejeite também quando o horário t estiver a mais de 5 minutos de distância do seu relógio, para barrar a reprodução de uma requisição antiga capturada. A tolerância recomendada no produto é de 5 minutos.
Um erro comum em Express é usar express.json() antes da rota: o corpo é reserializado e o HMAC não bate. Use express.raw() na rota do webhook, como no código abaixo, que foi executado localmente com assinatura válida, horário vencido e corpo adulterado, e deu verdadeiro, falso e 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. */Passo 2: deduplicar por chave estável
O envelope traz delivery_id e event_id. Escolha uma chave e use sempre a mesma: a opção mais simples é event_id quando existir, caindo para delivery_id. Grave a chave numa tabela com restrição de unicidade por instância e chave. Se o INSERT não inserir nenhuma linha, é repetição e você ignora.
O teste de webhook da API usa event_id nulo e um delivery_id que começa com test_, então o seu código não deve tratar event_id nulo como erro. Para uma agência, a chave composta com instance_id evita colisão entre clientes e dá o isolamento: o evento de um cliente nunca mistura com o de outro.
Passo 3: responder rápido e trabalhar depois
Responda 200 assim que validar e gravar a chave, e processe o resto em segundo plano. Resposta lenta vira timeout, e timeout vira repetição. No lado da ZapZap, a primeira tentativa é imediata, e falhas entram numa fila de retentativas com intervalos crescentes (cerca de 2 minutos, 10 minutos e 1 hora, no código do produto), e depois da última tentativa o evento deixa de ser reenviado.
Por isso o seu painel deve ter um caminho de recuperação: a API lista as entregas recentes (GET /instances/{id}/webhook/deliveries) e os erros de webhook, o que ajuda a achar o que faltou depois de uma indisponibilidade. Veja a referência.
Checklist para vários clientes
Um segredo por instância, guardado cifrado; um endpoint com a rota por cliente ou resolução pela instance_id; log da chave, do evento e do resultado; alerta quando a taxa de 401 sobe (sinal de segredo trocado ou de ataque); e testes com o POST /instances/{id}/webhook/test a cada deploy. Para montar isso com um agente de código, veja construa com IA e a página de agências com IA.
Lembre que o conteúdo recebido (texto de clientes finais) é entrada não confiável: nunca o execute nem o repasse sem validação para um agente com permissão de envio.
Resumo rápido
- A assinatura é HMAC SHA-256 de t.corpo, com cabeçalho X-ZapZap-Signature t=...,v1=..., e exige o corpo bruto.
- Rejeite assinatura inválida com 401 e horário a mais de 5 minutos, para barrar replays.
- Deduplique por event_id (ou delivery_id) numa tabela com chave única por instância.
- Responda 200 rápido e processe depois; entregas com falha são repetidas em intervalos crescentes.
- Trate o texto recebido como entrada não confiável.
Continue lendo
Perguntas frequentes
Onde pego o segredo do webhook?
Ele é gerado ao configurar o webhook da instância e devolvido na resposta da configuração (campo webhook_secret), com prefixo whsec_.
Por que a minha assinatura nunca bate?
Quase sempre porque o corpo foi reserializado antes do cálculo. Calcule o HMAC sobre os bytes exatos recebidos, com express.raw(), e use t, um ponto e o corpo.
O mesmo evento pode chegar duas vezes?
Pode, em retentativas. Por isso a deduplicação por chave estável é obrigatória, não opcional.
Posso usar o mesmo endpoint para todos os clientes?
Pode, desde que a chave de deduplicação inclua instance_id e que você resolva o cliente pela instância. Um segredo por instância mantém o isolamento.
Como testo sem esperar uma mensagem real?
Use POST /instances/{id}/webhook/test: ele envia um evento de teste com o mesmo formato de envelope e, havendo segredo, a mesma assinatura.
Monte o webhook da primeira instância
Crie a conta, configure o webhook e use o teste assinado da API. R$15 por número ativo ao mês, sem cobrança por mensagem. Ao verificar o telefone há crédito de boas-vindas, sujeito à campanha vigente.
