Webhooks
É assim que você recebe as mensagens dos seus contatos. Sem webhook, a integração só fala e nunca ouve.
Registrando
Duas formas, ambas equivalentes:
Junto com a instância (recomendado, a operação vira tudo-ou-nada):
{
"name": "Atendimento",
"webhook": { "url": "https://seu-sistema.com.br/webhooks/whatsapp" }
}
Depois, a qualquer momento:
curl -X POST https://apidevzap.com/api/v1/instances/INST-XXX/webhook \
-H "Authorization: Bearer ten_sua_chave" \
-H "Content-Type: application/json" \
-d '{"url": "https://seu-sistema.com.br/webhooks/whatsapp"}'
A operação é idempotente: chamar de novo atualiza a configuração, não cria outra.
Se você não informar secret, um é gerado e devolvido em claro nessa resposta, a única vez
que ele aparece. Depois disso, as leituras mostram só os últimos 4 caracteres. Perdeu? Gere um
novo em POST /api/v1/instances/INST-XXX/webhook/rotate-secret (ou no painel, em Webhooks).
A URL precisa ser pública e acessível pela internet. Endereços privados e reservados são recusados (proteção contra SSRF), então para testar localmente use um túnel como o ngrok.
Quais eventos existem de verdade
O registro aceita oito eventos. Sete disparam sempre; um dispara só em parte dos casos:
| Evento | Dispara? | Observação |
|---|---|---|
message.received |
✅ Sim | O caminho principal; mensagens que chegam para você |
message.sent |
⚠️ Parcialmente | Só para mensagens enviadas pela fila, nunca pelo envio direto |
message.delivered |
✅ Sim | Sua mensagem chegou ao aparelho do destinatário |
message.read |
✅ Sim | Sua mensagem foi lida (ou o áudio/vídeo foi reproduzido) |
message.failed |
✅ Sim | Sua mensagem falhou depois de o envio já ter respondido 200 |
message.reaction |
✅ Sim | Alguém reagiu com emoji a uma mensagem, inclusive às que você enviou |
label.updated |
✅ Sim | Etiqueta do WhatsApp Business criada, renomeada ou com cor nova |
label.deleted |
✅ Sim | Etiqueta apagada |
label.chat.added |
✅ Sim | Etiqueta posta num contato ou grupo, no celular ou pela API |
label.chat.removed |
✅ Sim | Etiqueta tirada de um contato ou grupo |
connection.changed |
✅ Sim | Toda mudança de estado: QR gerado, conectado, desconectado |
qr.updated |
✅ Sim | A cada QR novo; o WhatsApp rotaciona o código a cada ~20s |
Status de entrega
message.delivered, message.read e message.failed contam o que acontece com a mensagem
depois do envio. O data deles é enxuto:
{
"event": "message.delivered",
"instanceId": "INST-A1B2C3",
"timestamp": "2026-08-12T14:30:05.000Z",
"data": {
"instanceId": "INST-A1B2C3",
"messageId": "true_5511123456789@c.us_3EB0...",
"externalId": "true_5511123456789@c.us_3EB0...",
"internalId": 4217,
"status": "delivered",
"deliveredAt": "2026-08-12T14:30:05.000Z"
}
}
Case com a sua mensagem por messageId: é o ID do WhatsApp, o mesmo que a resposta do envio
devolveu. internalId é o ID da linha no nosso log; serve para abrir chamado com a gente, não para
casar mensagem.
Três coisas que poupam trabalho de quem consome:
- O status só avança.
sent→delivered→read, efailedé terminal. O WhatsApp repete e embaralha as confirmações; nós filtramos antes de disparar, então cada avanço chega uma vez só e nunca fora de ordem. Você não precisa de guarda contra regressão do nosso lado, mas manter a sua não custa nada. message.readinclui áudio reproduzido. O WhatsApp às vezes salta a confirmação de leitura e manda só a de reprodução; tratamos as duas como leitura, senão a mensagem de voz travaria emdeliveredpara sempre.message.readdepende do destinatário. Quem desliga a confirmação de leitura no aparelho não gera esse ack. A mensagem para emmessage.deliverede está tudo certo.
Só mensagens enviadas por você geram status. O WhatsApp também confirma leitura de mensagens que você recebeu; essas nós filtramos.
O motivo do message.failed
message.failed traz três campos a mais que os outros status, sempre presentes:
| Campo | O que é |
|---|---|
errorCode |
Motivo estável: FIRST_CONTACT_BLOCKED, ACCOUNT_RESTRICTED ou DELIVERY_FAILED |
providerCode |
Código cru do WhatsApp (ex.: "463"), ou null |
reason |
Explicação em português, para mostrar a uma pessoa |
{
"event": "message.failed",
"instanceId": "INST-A1B2C3",
"timestamp": "2026-09-27T14:30:05.000Z",
"data": {
"instanceId": "INST-A1B2C3",
"messageId": "true_5511123456789@c.us_3EB0...",
"externalId": "true_5511123456789@c.us_3EB0...",
"internalId": 4217,
"status": "failed",
"failedAt": "2026-09-27T14:30:05.000Z",
"errorCode": "ACCOUNT_RESTRICTED",
"providerCode": "463",
"reason": "O WhatsApp restringiu temporariamente este número para iniciar conversas novas (erro 463). ..."
}
}
Os dois primeiros códigos são o erro 463: FIRST_CONTACT_BLOCKED quando o contato ainda não
tem conversa com o número, ACCOUNT_RESTRICTED quando o próprio número está restrito para iniciar
conversas. Não reenvie nenhum deles: cada tentativa conta como mais um contato novo. O guia
Erro 463 explica as causas e o que fazer. Decida pelo
errorCode; trate valor desconhecido como DELIVERY_FAILED.
Reações com emoji
message.reaction dispara quando alguém coloca, troca ou remove uma reação em qualquer mensagem
da conversa, inclusive nas que você enviou. É o que permite fluxos do tipo "responda com 👍 para
confirmar":
{
"event": "message.reaction",
"instanceId": "INST-A1B2C3",
"timestamp": "2026-08-31T14:30:05.000Z",
"data": {
"instanceId": "INST-A1B2C3",
"messageId": "false_5511123456789@c.us_9C4D...",
"reactedMessageId": "true_5511123456789@c.us_3EB0...",
"emoji": "👍",
"from": "5511123456789@c.us",
"fromMe": false,
"isGroup": false,
"groupId": null,
"timestamp": "2026-08-31T14:30:04.000Z"
}
}
Quatro coisas para não tropeçar:
- Case pela mensagem reagida.
reactedMessageIdé o ID da mensagem que recebeu a reação, o mesmo que a resposta do envio devolveu. Guarde-o ao enviar e o casamento é direto. emojié livre e vazio é remoção. Qualquer emoji passa (não há lista fixa; quem dá significado a cada um é você). O WhatsApp emite o mesmo evento para colocar, trocar e tirar o emoji; trocar chega como um evento novo com o emoji novo.fromMe: trueé reação sua. O evento dispara também para reações feitas pelo próprio número da instância; filtre se só quiser as dos seus contatos.- Deduplique por
messageId, o ID da própria reação: cada reação, troca ou remoção chega com um ID novo, e entregas podem repetir, como qualquer webhook.
Reações não são persistidas no histórico de mensagens: existem só como evento.
connection.changed traz previousState, currentState e, quando existe, phone (ao conectar) ou
reason (ao cair, LOGOUT, AUTH_FAILURE, NETWORK, PASSKEY_REQUIRED…). Repetição do mesmo estado não gera
evento: uma instância que fica minutos em qr emite connection.changed uma vez e qr.updated a
cada rotação.
reason: "PASSKEY_REQUIRED" significa que o QR foi lido, mas o WhatsApp pediu para confirmar a
conexão com passkey no WhatsApp Web, passo que o DevZap ainda não conclui. O pareamento é
interrompido, a instância fica em disconnected e não é religada sozinha: ela só volta quando
alguém pedir a conexão de novo. Se a sua interface mostra o QR, troque o aviso genérico de queda
por uma orientação ao usuário (a ajuda do painel explica o
que fazer). Se você perdeu o evento, GET /instances/{id} traz o mesmo motivo no campo
disconnectReason enquanto ele vale: instância disconnected, pedido feito há até 30 minutos e
sem mudança de status depois.
O expiresAt do qr.updated é a nossa estimativa de validade (~20s) para você saber quando
descartar a imagem, não é uma promessa do WhatsApp.
Para verificação pontual de conexão, GET /instances/{id} continua valendo; para uma interface ao
vivo, prefira o WebSocket.
Etiquetas
Os quatro eventos label.* contam o que muda nas etiquetas do WhatsApp Business do número:
{
"event": "label.chat.added",
"instanceId": "INST-A1B2C3",
"timestamp": "2026-10-05T20:10:00.000Z",
"data": {
"instanceId": "INST-A1B2C3",
"label": { "id": "4", "name": "LEAD", "color": 1, "kind": "custom" },
"chat": { "chatId": "5511123450000@c.us", "phone": "5511123450000", "isGroup": false },
"timestamp": "2026-10-05T20:10:00.000Z"
}
}
label.updated e label.deleted trazem só label. O chat.chatId usa o mesmo vocabulário do
from do message.received. O guia Etiquetas explica de onde vêm as
mudanças e como listar as etiquetas.
O que chega no seu endpoint
Um POST com este corpo:
{
"event": "message.received",
"instanceId": "INST-1",
"timestamp": "2026-06-11T03:00:00.000Z",
"data": {
"instanceId": "INST-1",
"messageId": "false_5511123458888@c.us_3EB0DEF456",
"externalId": "false_5511123458888@c.us_3EB0DEF456",
"from": "5511123458888",
"to": "5511123456789",
"body": "Recebi a cobrança, posso parcelar?",
"type": "chat",
"hasMedia": false,
"isGroup": false,
"groupId": null,
"timestamp": "2026-06-11T02:59:58.000Z"
}
}
Notas sobre os campos:
type: "chat"é o vocabulário do WhatsApp para texto. Mídia usaimage,document, etc.hasMedia: truesignifica que existe um arquivo, que não vem no payload. Busque emGET /instances/{instanceId}/messages/{messageId}/media. Arquivos acima de 16 MB não são baixados (MEDIA_TOO_LARGE).- Em grupos,
isGroupétrue,groupIdvem preenchido e há um campoauthorcom quem escreveu. - Quando a mensagem é uma resposta (reply citando outra mensagem), vem
quotedMessageIdcom o ID da mensagem citada. Se a citada foi enviada por você, é o mesmo ID que a resposta do envio devolveu: dá para saber exatamente a qual das suas mensagens a pessoa respondeu, sem pedir código nenhum no texto. - Campos opcionais (
quotedMessageId,author,lidOriginal,authorLidOriginal) são omitidos quando ausentes, não enviados comonull. Seu parser precisa tolerar a ausência.
Validando a assinatura
Cada entrega leva o cabeçalho X-Webhook-Signature com o HMAC-SHA256 do corpo, em hexadecimal,
calculado com o seu secret.
Há duas armadilhas aqui, e as duas produzem o mesmo sintoma, "a assinatura nunca bate":
1. Assine os bytes recebidos, não o JSON reserializado. JSON.stringify(req.body) não é
garantidamente igual ao que chegou: a ordem das chaves e o espaçamento podem mudar. Você precisa do
corpo cru.
2. Compare em tempo constante. Usar === abre uma janela de ataque de timing. Use
crypto.timingSafeEqual.
Em Express:
const crypto = require('crypto');
// Guarde o corpo cru antes do parse de JSON
app.use('/webhooks/whatsapp', express.raw({ type: 'application/json' }));
app.post('/webhooks/whatsapp', (req, res) => {
const assinatura = req.headers['x-webhook-signature'];
const esperada = crypto
.createHmac('sha256', process.env.DEVZAP_WEBHOOK_SECRET)
.update(req.body) // Buffer cru, não o objeto
.digest('hex');
const a = Buffer.from(assinatura || '', 'utf8');
const b = Buffer.from(esperada, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('assinatura inválida');
}
const evento = JSON.parse(req.body.toString('utf8'));
// Responda AGORA e processe depois, veja a seção seguinte
res.status(200).send('ok');
processarAssincrono(evento);
});
Em Fastify, use rawBody (via fastify-raw-body) ou registre um content type parser que preserve
o buffer original.
Entrega e repetição
- Timeout de 10 segundos. Demorou mais, conta como falha.
- Qualquer resposta 2xx encerra a entrega com sucesso. Qualquer outra coisa agenda nova tentativa, a primeira em cerca de 30 segundos.
- A mesma entrega pode chegar duas vezes. Se o seu servidor processou mas a resposta se perdeu, o retry acontece de qualquer forma, não há como distinguir.
Daí as duas regras que fazem um receptor de webhook ser confiável:
Responda rápido, processe depois. Devolva 200 assim que validar a assinatura e jogue o
trabalho para uma fila. Processar antes de responder é o jeito mais fácil de estourar o timeout e
gerar duplicatas.
Deduplique por messageId. Guarde os IDs já processados e ignore repetições. É a única
proteção real contra o processamento duplo.
Consultando, trocando o secret e removendo
# Ver a configuração (o secret vem mascarado)
curl https://apidevzap.com/api/v1/instances/INST-XXX/webhook \
-H "Authorization: Bearer ten_sua_chave"
# Trocar o secret (o antigo para de valer na hora; o novo volta só nesta resposta)
curl -X POST https://apidevzap.com/api/v1/instances/INST-XXX/webhook/rotate-secret \
-H "Authorization: Bearer ten_sua_chave"
# Parar de receber
curl -X DELETE https://apidevzap.com/api/v1/instances/INST-XXX/webhook \
-H "Authorization: Bearer ten_sua_chave"
Remover o webhook não afeta o recebimento: as mensagens continuam chegando e ficando no histórico, você é que deixa de ser avisado.
Testando localmente
ngrok http 3000
# use a URL https://xxxx.ngrok.io/webhooks/whatsapp no registro
Sem túnel não funciona: a proteção contra SSRF recusa endereços privados.
Guia verificado em 05/08/2026 contra a API v1. O exemplo de payload é comparado automaticamente com a fixture de contrato em cada build.