Webhooks
Um endpoint assinado por conta. Verificação de assinatura, catálogo de eventos, política de novas tentativas e rotação do segredo.
Aponte o WASync para o seu endpoint e você recebe um segredo de assinatura de volta. Usa
whatsapp.events. Não há registro de aplicativo nem etapa de consentimento.
Um único slot de webhook por conta
Uma conta tem exatamente uma URL de webhook. PUT /webhook a define — chamá-lo
de novo com outra URL move o endpoint em vez de adicionar um segundo,
e não existe exclusão. Isso importa assim que mais de uma coisa quer
eventos: um gatilho do n8n e o seu próprio backend não podem ter o
slot ao mesmo tempo. Faça a distribuição (fan-out) do seu lado.
Defina o endpoint
curl -X PUT https://developers.wasync.app/api/v1/webhook \
-H "Authorization: Bearer $WASYNC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/wasync/webhook"}'{
"url": "https://example.com/wasync/webhook",
"events": [
"message.received",
"message.status",
"connection.disconnected",
"connection.connected"
],
"secret": "whsec_3f9a…"
}Guarde o segredo. Ele é emitido aqui porque este é o momento em que você precisa dele
para escrever o código de verificação, e GET /webhook não o retornará de novo —
esse endpoint informa { url, events, secretSet }, deliberadamente sem o
segredo, porque um caminho de leitura é registrado em logs e colado em tickets. Perdeu? Chame
POST /webhook/rotate para obter um novo. A URL deve ser HTTPS em um host acessível
publicamente; http, localhost e faixas privadas são rejeitados com
400 invalid_webhook.
A rotação não tem janela de sobreposição: o segredo antigo deixa de valer no instante em que o novo é emitido. Implante o novo segredo rapidamente — a política de novas tentativas abaixo cobre uma troca medida em segundos, não em horas.
Catálogo de eventos
| Event | Fires when | Direction |
|---|---|---|
message.received | Uma mensagem do WhatsApp chega em uma conexão autorizada (texto ou mídia). | Somente recebidas — as mensagens que você envia não disparam este evento. |
message.status | O estado de entrega de uma das suas mensagens enviadas muda. | Somente enviadas. |
connection.disconnected | Uma conexão parou de funcionar e o proprietário precisa pareá-la novamente. | Uma vez por queda, não repetidamente enquanto continuar com problema. |
connection.connected | Uma conexão que tinha caído voltou a funcionar. | Uma vez, e somente para uma conexão sobre a qual você foi avisado que tinha caído. |
Ignore valores de event desconhecidos em vez de tratá-los como erros — novos tipos
são anunciados no changelog antes de entrarem em produção.
Os eventos disparam somente para conexões qr. As conexões cloud_api não emitem
webhooks hoje; consulte GET /messages e GET /connections/{id} periodicamente para elas.
Payload
{
"event": "message.received",
"connection_id": "conn_8f3a21",
"message": {
"id": "cmqj3k2ab0001xyz",
"wa_id": "[email protected]_3EB0A1B2C3",
"from": "40700000000",
"text": "Hello!",
"media_url": null,
"media_type": null,
"timestamp": 1766138640000
}
}message.id— o id estável do WASync (o mesmo valor queGET /messagesretorna comoid). Faça a deduplicação e os joins por ele — a entrega é at-least-once.message.wa_id— o id próprio do WhatsApp,nullquando desconhecido. Use apenas para suporte e depuração, nunca como chave de deduplicação ou de join.from— somente dígitos internacionais, sem+.texténullem mensagens somente de mídia;media_url/media_typesãonullem mensagens de texto ou quando o arquivo não pode ser obtido publicamente.timestamp— epoch em milissegundos, como número, não como string ISO.
Um message.status atualiza uma das suas mensagens enviadas:
{
"event": "message.status",
"connection_id": "conn_8f3a21",
"message": {
"id": "cmqj3k2ab0001xyz",
"wa_id": "[email protected]_3EB0A1B2C3",
"status": "read",
"timestamp": 1766138641000
}
}Verifique a assinatura
Toda entrega traz X-WASync-Timestamp (epoch em ms) e
X-WASync-Signature: sha256=<hex>, que é
HMAC-SHA256(timestamp + "." + rawBody, secret). Calcule-a sobre o corpo bruto (raw)
— antes de qualquer parsing de JSON — e compare em tempo constante. Rejeite entregas
cujo timestamp tenha mais de ~5 minutos, o que impede replays.
import crypto from "node:crypto";
// rawBody MUST be the exact bytes you received (verify BEFORE JSON.parse).
export function verifyWASync(headers, rawBody, secret) {
const ts = headers["x-wasync-timestamp"];
const sig = headers["x-wasync-signature"]; // "sha256=<hex>"
const expected =
"sha256=" +
crypto.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
return Boolean(sig) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}import express from "express";
import { verifyWASync } from "./verify.js";
const app = express();
// Capture the RAW body — signature verification needs the exact bytes.
app.post("/webhooks/wasync", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWASync(req.headers, req.body.toString("utf8"), process.env.WASYNC_WEBHOOK_SECRET)) {
return res.status(401).end();
}
// Reject stale deliveries (replay window) — 5 minutes is a sane tolerance.
if (Math.abs(Date.now() - Number(req.headers["x-wasync-timestamp"])) > 5 * 60_000) {
return res.status(401).end();
}
res.status(200).end(); // ACK fast — under 10s — then process async
const { message } = JSON.parse(req.body.toString("utf8"));
// ... your logic (dedupe on message.id — retries can deliver duplicates)
});Entrega e política de novas tentativas
| Property | Value |
|---|---|
| Success criterion | Qualquer 2xx. Todo o resto, inclusive redirecionamentos, é falha. |
| Attempts | 3 por evento — imediata, depois após 500 ms, depois após 1 s. |
| Per-attempt timeout | 10 segundos. Confirme (ACK) rápido e processe de forma assíncrona. |
| After the last failure | O evento é descartado. Não há fila longa de reentrega. |
| Duplicates | Possíveis. Faça a deduplicação por message.id. |
| Ordering | Não garantida. Ordene por timestamp se for importante. |
| Signature on retries | O mesmo corpo e a mesma assinatura são reenviados — verifique cada tentativa da mesma forma. |
Escada de status
Os valores de message.status formam uma escada de mão única: sent (1) → delivered (2) →
read (3), com failed como resultado terminal que não vem depois de read.
Aplique as atualizações de forma monotônica. Só aplique um novo status se o seu nível for maior
que o do status armazenado. Uma mensagem pode chegar com read antes de
delivered (uma entrega tardia), e eventos duplicados são possíveis. Nunca faça uma
mensagem retroceder na sua interface.
Reconciliação — consulte periodicamente como rede de segurança
As entregas são tentadas três vezes e depois descartadas, o que faz do seu handler de webhook
o caminho rápido, não a garantia. Execute um loop em segundo plano que consulta
GET /messages?connectionId=… a cada 30–60 segundos e faz upsert de tudo que o
handler não viu, usando o cursor para buscas incrementais. O mesmo loop
recupera as atualizações de status que chegaram enquanto o seu endpoint estava fora do ar.
Webhooks = atualizações da interface em tempo real. Consulta periódica = correção. Use os dois.
Ciclo de vida da conexão
{
"event": "connection.disconnected",
"connection_id": "conn_8f2a…",
"connection": {
"id": "conn_8f2a…",
"phone_number": "393331234567",
"label": "Studio Rossi",
"status": "disconnected",
"needs_reconnect": true,
"license_status": "active",
"license_expires": "2027-08-08T09:00:00.000Z",
"reason": "AUTH_LOST"
},
"timestamp": 1786000000000
}Uma conexão qr pode parar de funcionar por motivos fora do controle de qualquer pessoa — o
telefone fica desligado por um dia, o WhatsApp é aberto em outro dispositivo, a
sessão expira. O número então fica mudo nos dois sentidos e, sem um
evento, a primeira pessoa a perceber é o seu cliente.
O QR code deliberadamente não vai no payload. Um QR do WhatsApp é uma
credencial de login por leitura: quem o lê pode assumir a sessão. O
evento avisa que é preciso parear de novo — busque o QR em si pelo seu
GET /connections/{id} autenticado e mostre-o ao proprietário do número, exatamente
como na configuração inicial. Conduza a sua interface por needs_reconnect; trate reason como
uma dica informativa para logs, pois novos valores podem aparecer.
Latência de detecção: uma desconexão é encontrada por uma sondagem de status a cada 5 minutos (~5 min). Quando a sessão parece recuperável, uma recuperação automática é tentada primeiro, então esse caminho leva ~15–20 minutos.
Não está recebendo eventos?
- A URL de entrega é HTTPS em um host público? Localhost, IPs privados e
http://são rejeitados. Use um túnel durante o desenvolvimento e confira o que está de fato armazenado comGET /webhook. - A credencial tem
whatsapp.eventse cobre a conexão em que a mensagem chegou? - A licença da conexão ainda está ativa? Uma licença expirada significa nenhum evento
e
402nos envios. - O seu endpoint responde
2xxem até 10 segundos? Handlers lentos parecem falhas e consomem as três tentativas. - É uma conexão
qr? As conexõescloud_apinão emitem webhooks. - Somente mensagens recebidas disparam
message.received— os seus próprios envios nunca disparam.