Dev

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

Este artigo também está disponível em: English · Español

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.

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.