DevZap · Guias de integração Voltar ao painel

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:

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:

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:

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

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.


← Todos os guias