Dev

Webhook de WhatsApp em produção: idempotência e retry

Em produção, um webhook de WhatsApp precisa fazer quatro coisas: validar a assinatura, responder 2xx em poucos segundos, tratar a mesma entrega duas vezes sem duplicar efeito e deixar rastro para depurar. O servidor da ZapZap tenta entregar cada evento, e se o seu endpoint falhar ele reenvia em uma escada de tempo. Isso torna a duplicidade um caso normal, não um erro raro. Este guia mostra o formato do evento, a verificação da assinatura, a deduplicação e como depurar com os endpoints de entregas e erros.

Publicado em · 7 min de leitura

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

O que chega no seu servidor a cada evento?

Cada evento chega como um POST com corpo JSON em um envelope. Os campos de topo são delivery_id (identifica a tentativa de entrega), event_id (o ID da mensagem quando o evento é de mensagem, ou nulo), version, instance_id, instance_name, event (messages, messages_update, connection e assim por diante) e data com o conteúdo. Se você associou metadata à instância, ele também vem no corpo. Em uma mensagem de texto recebida, o conteúdo está em data.message.text, o contato em data.message.chatid e a flag de mensagem própria em data.message.fromMe.

Os cabeçalhos trazem X-ZapZap-Id (o mesmo delivery_id), X-ZapZap-Event (o nome do evento) e, quando a instância tem segredo, X-ZapZap-Signature no formato t=<unix>,v1=<hmac>. O segredo (começa com whsec_) é gerado na primeira vez que você configura o webhook e volta no campo webhook_secret da resposta de configurar webhook e da de ver webhook. Para o passo básico de receber mensagens, veja antes o guia de receber mensagens por webhook e a página de webhooks na documentação.

Os eventos vêm desativados por padrão. Ligue só os que for consumir: messages, messages_update e connection cobrem a maioria das integrações. Eventos de volume alto, como presence e history, só valem se você realmente for tratá-los.

Configurar a URL, os eventos e pegar o segredo

curl -X PUT "https://api.zapzapapi.com/api/v1/instances/$INSTANCE_ID/webhook" \
  -H "x-api-key: $ZAPZAP_API_KEY" \
  -H "x-api-secret: $ZAPZAP_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://api.seudominio.com/webhooks/zapzap",
    "enabled": true,
    "events": ["messages", "messages_update", "connection"]
  }'
# A resposta traz "webhook_secret": "whsec_..." (guarde em variável de ambiente)

Como validar a assinatura HMAC do webhook?

A assinatura prova que o evento veio da ZapZap e que o corpo não foi alterado. O cálculo é um HMAC SHA-256 sobre a string timestamp, ponto e corpo bruto (t + '.' + corpo), usando o segredo da instância. O resultado em hexadecimal deve ser igual ao v1 do cabeçalho. O timestamp t também serve contra replay: rejeite entregas com mais de 5 minutos de diferença do seu relógio.

O ponto que mais quebra essa validação é calcular o HMAC sobre o JSON já parseado. O corpo precisa ser o texto exato recebido, byte a byte. No Express isso significa usar express.raw na rota do webhook e só então fazer o JSON.parse. Compare com timingSafeEqual (ou hmac.compare_digest em Python) para não vazar informação por tempo de resposta.

Um cuidado de leitura: se você devolver 4xx para uma entrega legítima, a ZapZap trata como falha e reenvia. Use 401 só para quem realmente não passou na validação, e não para erro seu de processamento.

Node.js (Express): assinatura, deduplicação e resposta rápida

const crypto = require("crypto");
const express = require("express");
const app = express();

const SECRET = process.env.ZAPZAP_WEBHOOK_SECRET; // whsec_...
const seen = new Map(); // em produção: Redis SET NX EX ou índice único no banco

function assinaturaValida(raw, header) {
  const partes = Object.fromEntries(
    String(header || "").split(",").map((p) => p.trim().split("="))
  );
  const t = Number(partes.t);
  if (!t || !partes.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;
  const esperado = crypto
    .createHmac("sha256", SECRET)
    .update(t + "." + raw)
    .digest("hex");
  const a = Buffer.from(esperado);
  const b = Buffer.from(partes.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/zapzap",
  express.raw({ type: "application/json", limit: "2mb" }),
  (req, res) => {
    const raw = req.body.toString("utf8");
    if (!assinaturaValida(raw, req.get("X-ZapZap-Signature"))) {
      return res.sendStatus(401);
    }
    const evento = JSON.parse(raw);
    const chave = req.get("X-ZapZap-Id") || evento.delivery_id;
    if (seen.has(chave)) return res.sendStatus(200); // reentrega: já tratada
    seen.set(chave, Date.now());

    res.sendStatus(200); // confirma primeiro
    setImmediate(() => processar(evento).catch(console.error)); // trabalha depois
  }
);

async function processar(evento) {
  const msg = evento.data && evento.data.message;
  if (evento.event !== "messages" || !msg || msg.fromMe) return;
  console.log("mensagem de", msg.chatid, ":", msg.text);
}

app.listen(3000);

Python (Flask): a mesma validação

import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["ZAPZAP_WEBHOOK_SECRET"].encode()

def assinatura_valida(raw: bytes, header: str) -> bool:
    try:
        partes = dict(p.strip().split("=", 1) for p in header.split(","))
        t = int(partes["t"])
    except (KeyError, ValueError, AttributeError):
        return False
    if abs(time.time() - t) > 300:
        return False
    esperado = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperado, partes.get("v1", ""))

@app.post("/webhooks/zapzap")
def webhook():
    raw = request.get_data()  # corpo bruto, antes de qualquer parse
    if not assinatura_valida(raw, request.headers.get("X-ZapZap-Signature", "")):
        return "", 401
    evento = request.get_json(force=True)
    # deduplique por request.headers["X-ZapZap-Id"] e enfileire o trabalho
    return "", 200

Como funciona o retry e o que conta como entrega?

Valores conferidos no sistema em 27/10/2026, sujeitos a ajuste. A primeira tentativa acontece na hora, com timeout de 10 segundos. Status abaixo de 400 conta como entregue. Qualquer 4xx, 5xx, timeout ou erro de rede entra numa fila de retentativas: depois da primeira tentativa, a fila tenta de novo após 30 segundos, 2 minutos, 10 minutos e 1 hora. Cada retentativa leva o mesmo delivery_id e é assinada de novo com timestamp novo.

Há dois freios de segurança. Se o endpoint falhar muitas vezes seguidas, o sistema para de enfileirar novas retentativas por alguns minutos para não encher a fila com um destino morto. E se as falhas forem permanentes e seguidas, você recebe um aviso e, depois de mais algumas, o webhook é desativado até você corrigir e reativar. Por isso monitorar a saúde do endpoint não é opcional.

A URL precisa ser HTTPS e pública. Endereços privados, localhost e IPs internos são recusados na configuração, e o servidor faz uma checagem de alcance ao salvar. Esse é o motivo mais comum de 'salvei e não chega nada': veja o guia de webhook que não chega.

Idempotência: como evitar processar duas vezes?

Idempotente significa que processar a mesma entrega duas vezes produz o mesmo resultado que processar uma. Com retentativas, a duplicidade vai acontecer: seu servidor processa, a resposta se perde, a ZapZap reenvia. A defesa é gravar uma chave única antes de agir e recusar a segunda.

Use duas camadas. Para retentativas, a chave é o X-ZapZap-Id (delivery_id), que se repete na reentrega. Para duplicidade na origem, a chave é a combinação de event com event_id: a mesma mensagem pode gerar eventos diferentes (messages, depois messages_update de entregue e de lido), então não use só o event_id. Guarde a chave com validade (por exemplo, alguns dias) em Redis com SET NX EX, ou em uma tabela com índice único.

Também filtre fromMe logo no início. Quando o seu próprio sistema envia uma mensagem, o evento volta para o webhook, e responder a ele cria loop. A regra é: se data.message.fromMe for verdadeiro, responda 200 e pare.

Por fim, separe 'confirmar' de 'processar'. O endpoint só valida, grava a chave, coloca o evento numa fila e responde 200. Quem consome a fila faz o trabalho pesado (IA, banco, envio de resposta). Se o processamento falhar depois do 200, o evento já está na sua fila e você reprocessa de lá, sem depender do retry da ZapZap.

Como depurar quando algo não chega?

A API tem três endpoints de diagnóstico. O teste de webhook envia um evento de teste assinado para a URL configurada (ou para uma URL alternativa) e devolve o status HTTP e a latência. As entregas recentes listam, por evento, se foi entregue, o status, a latência e o número da tentativa. Os erros do webhook mostram as últimas falhas com o motivo, como timeout.

A sequência prática: rode o teste, confira o status devolvido, abra as entregas para ver se os eventos reais chegam com 2xx e, se houver falha, leia os erros. Um status 200 no teste com eventos reais falhando costuma indicar tempo de resposta alto sob carga. Um timeout constante indica que o processamento está dentro do caminho da resposta.

Testar, ver entregas e ver erros

BASE="https://api.zapzapapi.com/api/v1"
H1="x-api-key: $ZAPZAP_API_KEY"
H2="x-api-secret: $ZAPZAP_API_SECRET"

# 1) evento de teste assinado
curl -X POST "$BASE/instances/$INSTANCE_ID/webhook/test" -H "$H1" -H "$H2" \
  -H "Content-Type: application/json" -d '{}'

# 2) últimas entregas (máximo 100)
curl "$BASE/instances/$INSTANCE_ID/webhook/deliveries?limit=20" -H "$H1" -H "$H2"

# 3) últimos erros de entrega
curl "$BASE/$INSTANCE_ID/webhook/errors" -H "$H1" -H "$H2"

Checklist de produção

[ ] URL HTTPS pública, com certificado válido, em domínio seu

[ ] Assinatura validada sobre o corpo bruto, com tolerância de 5 minutos

[ ] Resposta 2xx em poucos segundos, com trabalho pesado numa fila

[ ] Chave de deduplicação (X-ZapZap-Id e event mais event_id) com validade

[ ] fromMe filtrado para não responder a si mesmo

[ ] Só os eventos que você consome ligados no webhook

[ ] Alerta quando as entregas recentes mostram falhas seguidas

Para o fluxo sem código, o nó e os templates de n8n já recebem o webhook e a integração por webhook descreve o formato. A visão geral dos recursos está na página de API de WhatsApp.

Resumo rápido

  • Valide a assinatura X-ZapZap-Signature sobre o corpo bruto (HMAC SHA-256 de t + ponto + corpo) e rejeite timestamps com mais de 5 minutos de diferença.
  • Responda 2xx rápido e leve o trabalho pesado para uma fila. O timeout da entrega é de 10 segundos.
  • Entregas com falha são reenviadas após 30 segundos, 2 minutos, 10 minutos e 1 hora, com o mesmo delivery_id. Deduplique por ele.
  • Filtre fromMe e combine event com event_id: a mesma mensagem gera mais de um evento.
  • Depure com o teste de webhook, as entregas recentes e os erros do webhook.

Perguntas frequentes

O webhook da ZapZap tem retry automático?

Tem. A primeira tentativa é imediata, com timeout de 10 segundos. Se falhar (status 400 ou maior, timeout ou erro de rede), a fila tenta de novo após 30 segundos, 2 minutos, 10 minutos e 1 hora. Valores conferidos em 27/10/2026.

Como sei que o webhook veio mesmo da ZapZap?

Pelo cabeçalho X-ZapZap-Signature, no formato t=<unix>,v1=<hmac>. Recalcule o HMAC SHA-256 de t, ponto e corpo bruto com o webhook_secret da instância e compare. Rejeite timestamps antigos para evitar replay.

Posso receber o mesmo evento duas vezes?

Pode, principalmente depois de uma falha de rede ou de uma resposta lenta. Guarde o X-ZapZap-Id e recuse o que já foi tratado. Para duplicidade na origem, use a combinação de event e event_id.

Por que meu webhook foi desativado?

Falhas permanentes e seguidas do endpoint geram aviso e, depois de mais algumas, a desativação do webhook. Corrija o endpoint, rode o teste de webhook e configure a URL de novo.

O webhook funciona em HTTP ou em localhost?

Não. A URL precisa ser HTTPS e pública. Endereços locais e privados são recusados na configuração. Para testar em desenvolvimento, use um túnel HTTPS ou um servidor de homologação.

Configure o webhook e teste em minutos

Crie a conta, verifique o telefone e receba o crédito de boas-vindas, sem depositar (um por telefone, sujeito à campanha vigente). Conecte um número e use o endpoint de teste para ver a primeira entrega assinada.