Webhook do WhatsApp: assinatura, novas tentativas e duplicatas
Como tratar webhooks do WhatsApp em um agente de IA: verifique a assinatura Standard Webhooks, responda em 10 segundos, trate novas tentativas e duplicatas. TypeScript.

O essencial
- Um webhook da whasp é assinado com Standard Webhooks, entregue pelo menos uma vez e sem garantia de ordem, então o receptor precisa verificar, deduplicar e tolerar a troca de ordem.
- Verifique a assinatura no corpo bruto da requisição, nunca em um JSON serializado de novo, e rejeite timestamps com mais de cinco minutos de diferença.
- Grave o evento em armazenamento durável, depois responda 2xx em até 10 segundos e faça o trabalho lento depois: a whasp guarda o corpo de um evento só até ele ser entregue ou abandonado.
- Se o seu endpoint continua falhando, a whasp tenta 12 vezes ao longo de cerca de oito horas e depois desiste, e o conteúdo de um message.received que falhou não pode ser reenviado.
- Deduplique pelo id do evento e envie cada resposta com um Idempotency-Key montado com o id da mensagem recebida.
Um webhook da whasp é um HTTPS POST por evento, assinado com Standard Webhooks, entregue pelo menos uma vez e sem ordem garantida. Um receptor confiável faz quatro coisas: verifica a assinatura no corpo bruto, grava o evento em armazenamento durável, responde 2xx em até 10 segundos e faz o trabalho lento depois, deduplicando pelo id do evento. Este guia monta esse receptor em TypeScript.
O que uma entrega de webhook contém?
Uma entrega da whasp é um POST com um envelope JSON e três headers de assinatura. O envelope traz o id do evento, o type, um horário created_at e os data do evento. Os headers são webhook-id (o id do evento, igual em toda nova tentativa), webhook-timestamp (segundos unix) e webhook-signature (v1, seguido de um HMAC-SHA256 em base64).
Existem oito tipos de evento:
| Tipo | Quando dispara | O que o receptor faz |
|---|---|---|
message.received |
Uma pessoa escreveu para um número. Traz text, media e mais. |
Grave e rode o agente. Esta é a única cópia do conteúdo. |
message.sent |
Uma mensagem enviada saiu do celular. | Atualize o seu próprio registro. |
message.failed |
Uma mensagem enviada falhou; o motivo está em message.error. |
Registre no log. Tente de novo só se o motivo for temporário. |
reaction.received |
Alguém reagiu a uma mensagem; um emoji vazio significa que a reação foi removida. |
Em geral, ignore. |
number.ready |
Um número que estava em provisioning agora está ativo. |
Comece a direcionar usuários para ele. |
number.status_changed |
health ou online mudou. |
Dispare um alerta em critical. |
number.replaced |
Um número banido foi substituído. Os dois números estão no evento. | Mova seus usuários para o novo número. |
webhook.test |
Você chamou POST /v1/webhook/test. |
Responda 2xx. |
Como verificar a assinatura?
A verificação da assinatura prova que uma requisição veio da whasp e que ninguém alterou o corpo. A biblioteca standardwebhooks faz isso em uma chamada, e o projeto publica bibliotecas para JavaScript, Python, Go, Java, Ruby, Rust e .NET. Este é o algoritmo da especificação Standard Webhooks escrito com node:crypto, para stacks sem biblioteca (no Hono, passe c.req.raw.headers e o corpo em texto):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, headers: Headers, rawBody: string): boolean {
const id = headers.get("webhook-id") ?? "";
const timestamp = headers.get("webhook-timestamp") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!(age <= 300)) return false; // mais velho ou mais novo que 5 minutos; também pega NaN
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
// O header pode conter várias assinaturas separadas por espaço, por exemplo "v1,abc= v1,def=".
return (headers.get("webhook-signature") ?? "").split(" ").some((part) => {
const [version, signature] = part.split(",");
if (version !== "v1" || !signature) return false;
const got = Buffer.from(signature, "base64");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
Cinco erros comuns quebram a verificação:
- JSON já interpretado. A assinatura cobre os bytes exatos que a whasp enviou. Leia o corpo como texto primeiro, verifique e só então faça o parse. Um objeto serializado de novo pode diferir em espaços em branco ou na ordem das chaves.
- A chave errada. Um segredo é
whsec_mais base64. A chave são os bytes decodificados, não a string. A bibliotecastandardwebhookse o código acima removem o prefixo e decodificam por você. - Relógio fora de sincronia. Timestamps com mais de cinco minutos de diferença do seu relógio são rejeitados. Mantenha o servidor no NTP.
- Uma comparação simples com
===. UsetimingSafeEqual, que também lança erro com tamanhos diferentes, e por isso a checagem de tamanho. - Ler só a primeira assinatura. O header pode conter várias assinaturas separadas por espaço. Aceite a requisição se qualquer uma
v1bater.
Responda 401 quando a verificação falhar. A whasp trata isso como qualquer falha e tenta de novo, então um segredo errado não perde eventos de imediato, mas perde depois que as tentativas se esgotam. POST /v1/webhook/rotate-secret emite um segredo novo. A API não diz se a whasp assina com os dois segredos durante uma rotação, então atualize a sua configuração na hora; o que for rejeitado no intervalo é tentado de novo 30 segundos depois.
Por que responder primeiro e trabalhar depois?
Um receptor deve gravar o evento e responder primeiro, porque a whasp espera 10 segundos por um 2xx e o trabalho de um agente, uma chamada ao modelo ou a uma ferramenta, costuma levar mais. Responder tarde cria uma nova tentativa, e uma nova tentativa cria uma duplicata.
Gravar vem primeiro porque a whasp guarda o corpo de um evento só até ele ser entregue ou abandonado, e os servidores da whasp não guardam mensagens. Quando o seu endpoint devolve 2xx, você passa a ter a única cópia de um message.received. O padrão é uma fila de entrada (inbox) durável: insira o evento bruto, responda 204 e deixe um worker processar as linhas da tabela. O receptor completo está abaixo, com Hono e node:sqlite. Instale hono, @hono/node-server e standardwebhooks em um projeto com "type": "module", defina WHASP_WEBHOOK_SECRET e rode com node --env-file=.env webhook.ts no Node 22.18 ou mais novo.
import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { Webhook } from "standardwebhooks";
type WhaspEvent = { id: string; type: string; created_at: string; data: any };
const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!); // whsec_...
const MAX_ATTEMPTS = 6;
const db = new DatabaseSync("events.db");
db.exec(`
CREATE TABLE IF NOT EXISTS inbox (
id TEXT PRIMARY KEY, type TEXT NOT NULL, chat TEXT, body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending | done | dead
attempts INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0, error TEXT,
received_at INTEGER NOT NULL DEFAULT (unixepoch()));
`);
const app = new Hono();
app.post("/whasp/webhook", async (c) => {
const body = await c.req.text(); // os bytes brutos que foram assinados
let event: WhaspEvent;
try {
event = wh.verify(body, c.req.header()) as WhaspEvent;
} catch {
return c.text("Invalid signature", 401);
}
const chat = event.data.message?.chat ?? event.data.chat ?? null;
// Durável antes do 2xx. Um id repetido não insere nada e ainda recebe um 2xx.
db.prepare("INSERT OR IGNORE INTO inbox (id, type, chat, body) VALUES (?, ?, ?, ?)").run(
event.id, event.type, chat, body,
);
void drain(); // comece já; o timer abaixo só pega as novas tentativas
return c.body(null, 204);
});
// O worker: um evento por vez em cada chat, vários chats em paralelo.
const busy = new Set<string>();
async function drain() {
const due = db
.prepare("SELECT id, chat, body, attempts FROM inbox WHERE status = 'pending' AND next_at <= ? ORDER BY rowid")
.all(Date.now()) as { id: string; chat: string | null; body: string; attempts: number }[];
for (const row of due) {
const lane = row.chat ?? row.id;
if (busy.has(lane)) continue;
busy.add(lane);
void work(row).finally(() => busy.delete(lane));
}
}
async function work(row: { id: string; body: string; attempts: number }) {
try {
await handle(JSON.parse(row.body));
db.prepare("UPDATE inbox SET status = 'done' WHERE id = ?").run(row.id);
} catch (err) {
const attempts = row.attempts + 1;
const status = attempts >= MAX_ATTEMPTS ? "dead" : "pending";
const next = Date.now() + Math.min(2 ** attempts * 5_000, 300_000); // 10 s, 20 s, 40 s...
db.prepare("UPDATE inbox SET status = ?, attempts = ?, next_at = ?, error = ? WHERE id = ?").run(
status, attempts, next, String(err), row.id,
);
}
}
async function handle(event: WhaspEvent) {
switch (event.type) {
case "message.received":
console.log("inbound from", event.data.message.chat, event.data.message.text);
return; // chame o seu agente aqui, depois POST /v1/messages
case "message.failed":
console.warn("send failed", event.data.message.id, event.data.message.error);
return;
case "number.status_changed":
if (event.data.number.health === "critical") console.error("number critical", event.data.number.number);
return;
case "number.replaced":
console.warn("replaced", event.data.old_number, "->", event.data.new_number.number);
return;
default:
return; // message.sent, reaction.received, number.ready, webhook.test
}
}
setInterval(drain, 1000);
serve({ fetch: app.fetch, port: 3000 });
Três partes dele sustentam o resto. O INSERT OR IGNORE no id do evento é a deduplicação e a durabilidade em um único comando. O loop drain roda um evento por vez em cada chat e vários chats em paralelo, então uma conversa lenta nunca bloqueia as outras. E o work registra cada falha com uma contagem de tentativas e uma espera crescente, depois marca a linha como dead após seis tentativas, de modo que um evento problemático para de ficar em loop e fica na tabela para você ler.
Apague as linhas concluídas todo dia com DELETE FROM inbox WHERE status = 'done' AND received_at < unixepoch() - 86400. Guarde os ids por mais tempo que a janela de tentativas da whasp, ou uma tentativa tardia parece nova. Depois de uma queda no seu provedor de modelo, recoloque na fila as linhas dead com UPDATE inbox SET status = 'pending', attempts = 0 WHERE status = 'dead'.
O que acontece quando o seu endpoint falha?
Quando o seu endpoint falha, a whasp tenta de novo o mesmo evento com intervalos crescentes. Uma falha é qualquer resposta que não seja 2xx, um erro de conexão ou nenhuma resposta em 10 segundos. Um 401 por assinatura inválida também conta.
| Tentativa | Espera depois da falha anterior |
|---|---|
| 1 | 30 segundos |
| 2 | 2 minutos |
| 3 | 10 minutos |
| 4 | 30 minutos |
| 5 a 12 | 1 hora cada |
As doze tentativas somam cerca de oito horas. Depois da última, a entrega é marcada como failed e, como a whasp descarta o corpo de um evento quando ele é entregue ou abandonado, o texto de um message.received que falhou todas as vezes não pode ser reenviado. Sem uma URL de webhook, os eventos só aparecem na lista como no_endpoint e nunca são enviados.
Dois hábitos decorrem disso. Mantenha o endpoint independente do seu provedor de modelo: com a inbox, uma queda ali aparece como linhas na sua tabela, não como entregas com falha. E acompanhe GET /v1/webhook/deliveries, que lista os últimos 7 dias com status, attempts, last_status_code e last_error de cada entrega.
Como lidar com duplicatas e com a ordem?
Eventos duplicados são normais, porque a entrega é pelo menos uma vez. O mesmo evento chega duas vezes quando o seu 2xx se perde na rede ou chega depois de 10 segundos. Deduplique pelo id do evento (o mesmo valor de webhook-id), continue respondendo 2xx para as repetições, e o seu handler roda uma vez por evento.
A ordem também não é garantida. Uma mensagem que precisou de uma nova tentativa pode chegar depois da que a pessoa enviou em seguida. O receptor acima já roda um chat por vez. Ordene o histórico que você monta para o modelo por message.created_at e não pela ordem de chegada, e espere rajadas: três mensagens em dez segundos produzem três eventos, então considere esperar dois ou três segundos por mais mensagens antes de responder uma vez.
As duplicatas também chegam ao que você envia de volta. Envie toda resposta com um Idempotency-Key montado com o id da mensagem recebida, por exemplo reply-msg_..., e não com um valor aleatório por tentativa. A whasp guarda uma chave por 7 dias por conta: a mesma chave devolve a primeira mensagem e nunca envia duas vezes. Um envio recusado (4xx) não gasta a chave, e um 409 idempotency_conflict significa que a primeira requisição com aquela chave ainda está em andamento, então tente de novo daqui a pouco. O mesmo cuidado vale para ferramentas com efeitos colaterais, como reservas ou reembolsos: um evento reenviado roda o seu agente de novo, então proteja-as com o id da mensagem recebida, como o tutorial do Claude explica.
Como testar e depurar webhooks?
Você testa um webhook da whasp em quatro passos, e o último dispensa conta na whasp.
- Rode o receptor e exponha-o.
cloudflared tunnel --url http://localhost:3000dá um endereço HTTPS temporário e não exige conta na Cloudflare. - Registre-o com
PUT /v1/webhooke um corpo{ "url": "https://.../whasp/webhook" }. A resposta traz o segredo. - Chame
POST /v1/webhook/test. Ele envia umwebhook.testna hora e responde{ "delivered", "status_code", "error" }, então você vê o que o seu servidor devolveu. - Envie um evento falso assinado ao localhost.
Webhook.signproduz o valor do header, então este script não precisa de conta na whasp nem de túnel:
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.WHASP_WEBHOOK_SECRET!);
const event = {
id: `evt_local_${Date.now()}`,
type: "message.received",
created_at: new Date().toISOString(),
data: { message: { id: "msg_local_1", chat: "+15555550111", text: "Olá, isto é um teste", media: null } },
};
const body = JSON.stringify(event);
const res = await fetch("http://localhost:3000/whasp/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
"webhook-id": event.id,
"webhook-timestamp": String(Math.floor(Date.now() / 1000)),
"webhook-signature": wh.sign(event.id, new Date(), body),
},
body,
});
console.log(res.status); // 204
Rode com node --env-file=.env send-test-event.ts com o receptor no ar. Mude type e data para ensaiar message.failed ou number.replaced do mesmo jeito.
O que construir em seguida?
Ligue o receptor a um agente: o tutorial da OpenAI e o tutorial do Claude partem deste padrão. Dê ao agente uma memória ou monte o mesmo fluxo sem código no n8n. Quando um número não basta, planeje a capacidade para vários.
Perguntas
Como verifico a assinatura de um webhook da whasp?
Recalcule um HMAC-SHA256 sobre a string webhook-id, um ponto, webhook-timestamp, um ponto e o corpo bruto, usando como chave a parte do segredo whsec_ decodificada de base64, e compare em tempo constante com o valor v1 de webhook-signature. A biblioteca standardwebhooks da sua linguagem faz isso em uma chamada.
Quantas vezes a whasp tenta de novo um webhook que falhou?
Até 12 vezes, depois de 30 segundos, 2 minutos, 10 minutos, 30 minutos e então de hora em hora. Depois da última falha, a entrega é marcada como failed e o corpo do evento é descartado.
O mesmo evento de webhook pode chegar duas vezes?
Pode. A entrega é pelo menos uma vez, então um 2xx perdido ou uma resposta lenta produz uma duplicata com o mesmo id de evento. Guarde o id com uma constraint de unicidade e responda 2xx para as repetições.
O que um endpoint de webhook deve devolver?
Qualquer status 2xx em até 10 segundos conta como entregue, e um 204 sem corpo basta. Um 4xx, um 5xx, um timeout ou um erro de conexão é uma falha e gera nova tentativa.
Como testo um webhook da whasp localmente?
Exponha a sua porta com um túnel, registre a URL com PUT /v1/webhook e chame POST /v1/webhook/test. Você também pode assinar um evento falso com a biblioteca standardwebhooks e enviá-lo ao localhost.


