Webhook da Evolution API: receber e saber se foi lida
Olá meus Unicórnios! 🦄✨
Mandar mensagem pela Evolution API é a parte fácil. Você dispara um POST, a mensagem chega no celular da pessoa, e a sensação é de que está tudo resolvido. 😄
Até o dia em que alguém responde.
Porque aí você descobre uma coisa meio óbvia que ninguém avisa: a Evolution não guarda um "caixa de entrada" esperando você buscar. Se você quer saber que chegou mensagem, quem tem que estar de ouvido em pé é o seu programa. E o jeito de fazer isso se chama webhook.
Webhook é o nome bonito para uma ideia simples: em vez de você ficar perguntando "chegou algo? chegou algo? chegou algo?" a cada dez segundos, você deixa um endereço com a Evolution e diz "quando acontecer alguma coisa, me avisa aqui". Ela então manda um POST para o seu servidor com um JSON contando o que aconteceu. 📬
Neste artigo eu vou configurar os três avisos que resolvem quase todo projeto de WhatsApp:
- Chegou mensagem nova (o evento
MESSAGES_UPSERT) - A mensagem que eu mandei foi entregue ou lida (
MESSAGES_UPDATE) - A conexão com o WhatsApp mudou (
CONNECTION_UPDATE)
E vou mostrar o JSON de ida (o que você manda para configurar) e o JSON de volta (o que cai no seu servidor), que é a parte que a documentação mostra pela metade. Prepare-se para duas armadilhas de nome que me fizeram olhar para um if perfeitamente correto sem entender por que ele nunca era verdadeiro. 😅
🎯 O que o webhook precisa para funcionar
Antes do código, vale entender a geografia do problema, porque é aqui que quase todo mundo trava.
Quem faz a chamada do webhook é a Evolution, não você. Ela é o cliente; o seu programa é o servidor. Isso inverte o sentido que a gente está acostumado, e traz uma consequência prática: o endereço que você informa tem que ser alcançável de onde a Evolution está, não de onde você está.
Parece detalhe, e é a causa número um de "configurei e não recebo nada". Se a Evolution roda dentro de um contêiner Docker e você aponta o webhook para http://localhost:3333, o localhost dela é o contêiner dela. O seu programa está fora, e ela nunca vai encontrá-lo ali.
No meu caso a Evolution roda em Docker num servidor, e o receptor roda no próprio servidor, fora do contêiner. O endereço que atravessa essa fronteira é o endereço da ponte do Docker: um IP que o contêiner enxerga e que aponta de volta para a máquina hospedeira.
Para descobrir qual é o seu, pergunte ao Docker qual é o gateway da rede do contêiner da Evolution:
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} gateway={{$v.Gateway}}{{end}}' evolution-api
A resposta vem assim, com o nome da rede e o endereço:
evolution_default gateway=172.18.0.1
Guarde esse número. Ele é o "de dentro para fora" da sua instalação, e é ele que vai no webhook. No artigo eu vou chamá-lo de 172.18.0.1, que foi o meu, mas confira o seu: dependendo de quantas redes o Docker criou na sua máquina, pode ser 172.17.0.1, 172.19.0.1 ou outro.
📥 Um receptor de webhook em Node puro
Vamos começar pelo lado que recebe, porque não faz sentido configurar um aviso sem ter quem o escute. Se você configurar primeiro, a Evolution vai tentar entregar num endereço morto e você não vai ver nada acontecer.
O receptor mais simples do mundo cabe em vinte linhas e não precisa instalar nada: o Node já vem com um servidor HTTP embutido. Sem Express, sem npm install, sem package.json. 🙌
Primeiro, entre no servidor e crie uma pasta para o teste:
mkdir /opt/webhook-teste
cd /opt/webhook-teste
O mkdir cria a pasta e o cd entra nela. Agora crie o arquivo com o editor nano, que já vem no Ubuntu:
nano receber.js
Vai abrir um editor de texto dentro do próprio terminal. Cole o código abaixo e salve assim:
- Para colar: use
Ctrl+Shift+V, ou clique com o botão direito do mouse. OCtrl+Vcomum não funciona no terminal, e é aqui que muita gente acha que travou. - Para salvar:
Ctrl+Oe depoisEnter(confirma o nome do arquivo). - Para sair:
Ctrl+X.
Aquele ^ que aparece no rodapé do nano quer dizer justamente a tecla Ctrl: onde está escrito ^O Write Out, leia "Ctrl+O para gravar".
Este é o receptor completo:
const http = require("http");
const PORTA = 3333;
const servidor = http.createServer(function (requisicao, resposta) {
let corpo = "";
// O corpo chega em pedacos, nao de uma vez. Sem juntar tudo antes do
// JSON.parse, mensagem grande quebra com "Unexpected end of JSON input".
requisicao.on("data", function (pedaco) {
corpo = corpo + pedaco;
});
requisicao.on("end", function () {
// Responder 200 antes de processar. Se o seu codigo demorar, a
// Evolution desiste e tenta de novo, e o evento chega duplicado.
resposta.writeHead(200, { "Content-Type": "text/plain" });
resposta.end("ok");
try {
const evento = JSON.parse(corpo);
console.log(JSON.stringify(evento, null, 4));
} catch (erro) {
console.log("Nao consegui ler o JSON recebido: " + erro.message);
}
});
});
servidor.listen(PORTA, "0.0.0.0", function () {
console.log("Esperando webhook na porta " + PORTA);
});
Três linhas aí merecem atenção, porque cada uma evita um bug específico:
O "0.0.0.0" no listen. Se você omitir e deixar só a porta, em muitos ambientes o Node escuta apenas em 127.0.0.1, ou seja, só aceita conexão vinda de dentro da própria máquina. O contêiner da Evolution é "de fora" nessa conta, e o aviso nunca chega. O 0.0.0.0 quer dizer "aceito de qualquer endereço".
O corpo = corpo + pedaco. O Node entrega o corpo da requisição em pedaços, conforme eles chegam pela rede. Numa mensagem curta o pedaço vem inteiro e parece que dava para ler direto; numa mensagem longa, não vem, e você recebe um JSON cortado no meio. Juntar antes de interpretar é o certo sempre, e não só quando dá erro.
O resposta.end("ok") antes do JSON.parse. A Evolution espera uma resposta rápida. Se o seu programa for salvar no banco, chamar outra API e só então responder, ela pode considerar que a entrega falhou e mandar o mesmo evento outra vez. Responda primeiro, trabalhe depois.
Agora suba o receptor:
node receber.js
Ele imprime a mensagem de que está esperando e fica ali, parado, sem devolver o comando. Isso é o certo: um servidor não termina, ele fica de plantão. Deixe esse terminal aberto e abra uma segunda janela para os próximos comandos. Para desligá-lo depois, é Ctrl+C nessa janela.
Esperando webhook na porta 3333
🔧 O JSON que configura o webhook
Com o receptor de pé, agora sim vamos avisar a Evolution para onde mandar as coisas. O endpoint é o /webhook/set/ seguido do nome da sua instância:
curl -X POST "http://SEU_IP_AQUI:8080/webhook/set/SUA_INSTANCIA" \
-H "apikey: SUA_CHAVE_AQUI" \
-H 'Content-Type: application/json' \
-d '{
"webhook": {
"enabled": true,
"url": "http://172.18.0.1:3333",
"byEvents": false,
"base64": false,
"events": [
"MESSAGES_UPSERT",
"MESSAGES_UPDATE",
"CONNECTION_UPDATE"
]
}
}'
Repare que tudo vive dentro de um objeto webhook. Mandar os campos soltos na raiz do JSON é um erro fácil de cometer, e a Evolution reclama com um 400 falando de campo obrigatório.
O que cada campo faz:
| Campo | Para que serve |
|---|---|
enabled | Liga e desliga o webhook sem perder a configuração |
url | Onde a Evolution vai bater. Precisa ser alcançável por ela |
byEvents | Se true, acrescenta o nome do evento no fim da URL (cuidado, veja abaixo) |
base64 | Se true, manda o conteúdo de áudios e imagens embutido no JSON |
events | A lista do que você quer ser avisado |
Se der tudo certo, a resposta é a configuração gravada:
{
"id": "cmsyvih9y004fna4xqg3ftofg",
"url": "http://172.18.0.1:3333",
"headers": null,
"enabled": true,
"events": [
"MESSAGES_UPSERT",
"MESSAGES_UPDATE",
"CONNECTION_UPDATE"
],
"webhookByEvents": false,
"webhookBase64": false,
"createdAt": "2026-08-13T22:24:55.793Z",
"updatedAt": "2026-08-13T22:24:55.793Z",
"instanceId": "0000aaaa-1111-2222-3333-444455556666"
}
Para conferir depois o que está valendo, sem alterar nada, existe o /webhook/find/:
curl -s "http://SEU_IP_AQUI:8080/webhook/find/SUA_INSTANCIA" \
-H "apikey: SUA_CHAVE_AQUI"
E para desligar o webhook, mande o mesmo set com enabled em false. A configuração fica guardada, só para de disparar.
📋 A lista completa de eventos (e como descobri-la)
A Evolution aceita bem mais que três eventos, e a lista muda de versão para versão. Em vez de procurar na documentação, tem um truque que eu gosto: peça um evento que não existe e leia a bronca. 😏
curl -X POST "http://SEU_IP_AQUI:8080/webhook/set/SUA_INSTANCIA" \
-H "apikey: SUA_CHAVE_AQUI" \
-H 'Content-Type: application/json' \
-d '{"webhook": {"enabled": true, "url": "http://172.18.0.1:3333", "events": ["EVENTO_INVENTADO"]}}'
A resposta de erro traz a lista inteira que aquela instalação aceita, o que é melhor que qualquer documentação porque vem da sua própria versão:
{
"status": 400,
"error": "Bad Request",
"response": {
"message": [
[
"webhook.events[0] is not one of enum values: APPLICATION_STARTUP,QRCODE_UPDATED,MESSAGES_SET,MESSAGES_UPSERT,MESSAGES_EDITED,MESSAGES_UPDATE,MESSAGES_DELETE,SEND_MESSAGE,SEND_MESSAGE_UPDATE,CONTACTS_SET,CONTACTS_UPSERT,CONTACTS_UPDATE,PRESENCE_UPDATE,CHATS_SET,CHATS_UPSERT,CHATS_UPDATE,CHATS_DELETE,GROUPS_UPSERT,GROUP_UPDATE,GROUP_PARTICIPANTS_UPDATE,CONNECTION_UPDATE,LABELS_EDIT,LABELS_ASSOCIATION,CALL,TYPEBOT_START,TYPEBOT_CHANGE_STATUS,REMOVE_INSTANCE,LOGOUT_INSTANCE,INSTANCE_CREATE,INSTANCE_DELETE,STATUS_INSTANCE"
]
]
}
}
São trinta e um. Os que mais aparecem no dia a dia, além dos nossos três: SEND_MESSAGE (a instância enviou algo), QRCODE_UPDATED (saiu QR Code novo para ler), PRESENCE_UPDATE (a pessoa está digitando) e CALL (alguém está ligando).
💥 O silêncio que era firewall
Aqui está a parte que me custou tempo, e que eu queria ter lido antes de começar.
Com o receptor de pé e o webhook configurado, mandei uma mensagem de teste. E não aconteceu nada. O receptor continuou imprimindo apenas o "Esperando webhook na porta 3333", como se ninguém tivesse batido na porta. Nenhum erro no log da Evolution. Nenhuma reclamação. Silêncio absoluto. 😐
Quando isso acontece, o primeiro passo é parar de adivinhar e perguntar diretamente ao contêiner da Evolution se ele consegue enxergar o seu receptor. O comando docker exec roda algo de dentro do contêiner:
docker exec evolution-api sh -c 'wget -q -O- --timeout=5 http://172.18.0.1:3333'
E a resposta explicou tudo:
wget: download timed out
Repare no detalhe cruel dessa mensagem, porque ela é o diagnóstico inteiro: deu tempo esgotado, não conexão recusada. A diferença importa muito. 🔍
| O que você vê | O que significa |
|---|---|
| Conexão recusada | O pacote chegou, e não tinha ninguém escutando naquela porta |
| Tempo esgotado | O pacote foi engolido no caminho. Quase sempre firewall |
Era o firewall do próprio servidor. O ufw do Ubuntu, quando ativo, descarta por padrão o que não foi explicitamente liberado, e isso inclui os pacotes que vêm da rede interna do Docker. O contêiner batia na porta, o firewall jogava o pacote no lixo sem avisar ninguém, e a Evolution ficava esperando uma resposta que nunca vinha.
A correção é liberar só a rede do Docker para aquela porta, e nada mais:
ufw allow from 172.18.0.0/16 to 172.18.0.1 port 3333 proto tcp
Leia o comando em voz alta que ele se explica: permita, vindo da rede 172.18.0.0/16 (os contêineres), para o endereço 172.18.0.1 (a ponte), na porta 3333. Ninguém da internet ganha acesso ao seu receptor com essa regra, e é isso que a torna segura. 🔒
Com a regra no lugar, o mesmo teste responde na hora:
ok
📨 Evento 1: chegou mensagem nova
Esse é o MESSAGES_UPSERT, e é o coração de qualquer atendimento automático. Ele dispara quando alguém escreve para você.
O JSON que cai no seu servidor tem sempre a mesma forma: um envelope com informações da entrega, e um data dentro dele com o conteúdo de verdade.
{
"event": "messages.upsert",
"instance": "minha-instancia",
"data": {
"key": {
"remoteJid": "[email protected]",
"fromMe": false,
"id": "ACC43BDDBFB007F29616391F3FB7D49E",
"participant": "",
"addressingMode": "lid"
},
"pushName": "Cliente Teste",
"status": "DELIVERY_ACK",
"message": {
"conversation": "Bom dia! O pedido ja saiu para entrega?"
},
"messageType": "conversation",
"messageTimestamp": 1787070512,
"instanceId": "0000aaaa-1111-2222-3333-444455556666",
"source": "android"
},
"destination": "http://172.18.0.1:3333",
"date_time": "2026-08-13T22:28:32.988Z",
"sender": "[email protected]",
"server_url": "http://SEU_IP_AQUI:8080",
"apikey": "SUA_CHAVE_AQUI"
}
Os campos que você vai usar de fato:
| Campo | O que é |
|---|---|
data.key.remoteJid | Com quem é a conversa. É para cá que você responde |
data.key.fromMe | false se a pessoa enviou; true se foi você |
data.key.id | O código da mensagem. Guarde-o para cruzar com o status depois |
data.pushName | O nome que a pessoa configurou no WhatsApp dela |
data.message.conversation | O texto, quando a mensagem é texto simples |
data.messageType | O tipo: conversation, imageMessage, audioMessage... |
Quem for consultar a especificação vai encontrar esses nomes em inglês, exatamente como estão na tabela: são as chaves do JSON e não podem ser traduzidas no código.
Duas coisas sobre o remoteJid que economizam confusão. O sufixo diz o tipo de conversa: @s.whatsapp.net é pessoa, @g.us é grupo. E em instalações recentes você também vai ver @lid, um identificador interno novo do WhatsApp que aparece em vez do número. Não se assuste: para responder, use o remoteJid exatamente como ele veio, sem tentar "consertar" o formato.
🪞 O fromMe, ou como um robô conversa consigo mesmo
Essa merece seção própria porque é a pegadinha mais divertida (e mais dolorosa) do MESSAGES_UPSERT. 😳
O evento não avisa só das mensagens que chegam. Ele avisa das mensagens que aparecem na conversa, e as suas próprias respostas também aparecem na conversa. Ou seja: você responde ao cliente, e a Evolution educadamente te avisa que apareceu uma mensagem nova. Que é a sua. Que o seu código vai tratar como pergunta. E responder. Gerando outro aviso. 🔁
Parabéns, você criou um robô que conversa consigo mesmo até alguém desligar o servidor.
A defesa é uma linha, e ela tem que ser a primeira coisa da sua função:
function mostrarMensagemRecebida(dados) {
// Sem esta checagem, a sua propria resposta volta como se fosse
// pergunta do cliente, e o robo entra em loop respondendo a si mesmo.
if (dados.key.fromMe === true) {
return;
}
console.log("Mensagem de " + dados.pushName + ": " + dados.message.conversation);
}
Repare que eu comparo com === true em vez de escrever só if (dados.key.fromMe). As duas formas funcionam aqui, mas a explícita deixa claro para quem lê depois que o campo é um verdadeiro/falso, e não algo que "existe ou não existe".
🔤 A armadilha do nome do evento
Agora a que me fez desconfiar da minha própria sanidade. 🤯
Você configura o webhook pedindo MESSAGES_UPSERT, em maiúsculas com sublinhado. Faz sentido, é o padrão de constante.
Só que o JSON que chega no seu servidor diz:
"event": "messages.upsert"
Minúsculas, com ponto. Isso mesmo, duas grafias diferentes para a mesma coisa: uma para pedir, outra para receber. Se você escrever o óbvio, aquilo que qualquer pessoa escreveria depois de configurar em maiúsculas, o if nunca é verdadeiro e você fica olhando para um código correto sem entender nada:
// ERRADO: nunca vai ser verdadeiro
if (evento.event === "MESSAGES_UPSERT") {
// CERTO: e assim que o nome chega no JSON
if (evento.event === "messages.upsert") {
A tabela de tradução dos nossos três, para você copiar:
| Você configura | Chega no JSON como |
|---|---|
MESSAGES_UPSERT | messages.upsert |
MESSAGES_UPDATE | messages.update |
CONNECTION_UPDATE | connection.update |
SEND_MESSAGE | send.message |
A regra é mecânica: baixa tudo, e o primeiro sublinhado vira ponto. Mas ninguém adivinha isso, e o pior é que não dá erro: o evento chega, o seu if não casa, e o programa simplesmente não faz nada. É o tipo de bug que a gente procura no lugar errado por meia hora. 😤
Por isso o receptor que eu mostrei imprime o JSON inteiro. Antes de escrever qualquer if, olhe o que realmente chegou. Vale mais que qualquer tabela, inclusive a minha.
E tem um else que salva a vida aqui: um aviso para os eventos que você não tratou. Sem ele, um nome errado é invisível; com ele, aparece na tela.
function tratarEvento(evento) {
if (evento.event === "messages.upsert") {
mostrarMensagemRecebida(evento.data);
} else if (evento.event === "messages.update") {
mostrarStatusDaMensagem(evento.data);
} else if (evento.event === "connection.update") {
mostrarConexao(evento.data);
} else {
// Este else e o que denuncia o nome escrito errado.
console.log("evento sem tratamento: " + evento.event);
}
}
Foi exatamente esse else que me mostrou o send.message na tela quando eu esperava um SEND_MESSAGE. 🙏
✅ Evento 2: foi entregue? foi lida?
Esse é o MESSAGES_UPDATE, e é o que responde a pergunta que todo cliente faz: "ele viu minha mensagem?".
O JSON dele é bem menor, porque não repete o conteúdo da mensagem. Ele só diz qual mensagem mudou, e para que estado:
{
"event": "messages.update",
"instance": "minha-instancia",
"data": {
"keyId": "3EB0F1CD754284B72254CC",
"remoteJid": "[email protected]",
"fromMe": true,
"status": "READ",
"instanceId": "0000aaaa-1111-2222-3333-444455556666",
"messageId": "cmsyvnh43005tna4xsnij8ymv"
},
"destination": "http://172.18.0.1:3333",
"date_time": "2026-08-13T22:31:12.104Z",
"sender": "[email protected]",
"server_url": "http://SEU_IP_AQUI:8080",
"apikey": "SUA_CHAVE_AQUI"
}
Repare num detalhe que confunde: aqui o campo se chama keyId, na raiz do data. No evento de mensagem nova, o mesmo código vinha como data.key.id, dentro de um objeto key. É o mesmo identificador, com nome e lugar diferentes em cada evento, e é ele que costura os dois: você guarda o key.id quando envia, e procura por ele no keyId quando o status muda.
Já o messageId é outra coisa: é o código interno do banco de dados da Evolution, não o do WhatsApp. Não é por ele que você cruza as informações.
Os estados por onde uma mensagem passa, na ordem:
| Status | O que aconteceu | No WhatsApp |
|---|---|---|
PENDING | Aceita pela API, ainda não saiu | relógio |
SERVER_ACK | Chegou ao servidor do WhatsApp | um visto |
DELIVERY_ACK | Chegou ao celular da pessoa | dois vistos cinza |
READ | A pessoa abriu a conversa | dois vistos azuis |
ERROR | Não foi entregue | ponto de exclamação |
Duas honestidades sobre esse evento, que evitam frustração:
Nem sempre chegam todos os estados. Você pode receber o READ sem ter visto o DELIVERY_ACK passar, porque os avisos dependem do que o WhatsApp resolve informar e de quando o celular da pessoa se conecta. Trate cada aviso como "o que eu sei agora", não como um passo de uma fila garantida.
O READ pode nunca vir. Se a pessoa desligou a confirmação de leitura nas configurações do WhatsApp dela, ninguém no mundo vai saber que ela leu, e a sua integração não é exceção. Nesse caso o status para no DELIVERY_ACK e está tudo funcionando corretamente.
O tratamento é curtinho:
function mostrarStatusDaMensagem(dados) {
console.log("Mensagem " + dados.keyId + " agora esta: " + dados.status);
if (dados.status === "READ") {
console.log(" (a pessoa leu)");
}
if (dados.status === "ERROR") {
console.log(" (nao foi entregue, vale tentar de novo)");
}
}
🔌 Evento 3: a conexão caiu
O CONNECTION_UPDATE é o mais ignorado dos três e, na minha opinião, o mais importante de todos. 🙏
Porque a Evolution automatiza o WhatsApp Web, e a sessão do WhatsApp Web cai. A pessoa desconecta o aparelho, o celular fica dias sem internet, alguém desvincula o dispositivo sem avisar. Quando isso acontece, os seus envios começam a falhar em silêncio, e sem esse evento você só descobre quando um cliente reclama que não recebeu nada. 😱
Ele dispara duas vezes numa reconexão. Primeiro o aviso de que está tentando:
{
"event": "connection.update",
"instance": "minha-instancia",
"data": {
"instance": "minha-instancia",
"state": "connecting",
"statusReason": 200
},
"destination": "http://172.18.0.1:3333",
"date_time": "2026-08-13T22:28:19.412Z",
"server_url": "http://SEU_IP_AQUI:8080",
"apikey": "SUA_CHAVE_AQUI"
}
E depois o de que conseguiu, agora com os dados de quem está conectado:
{
"event": "connection.update",
"instance": "minha-instancia",
"data": {
"instance": "minha-instancia",
"wuid": "[email protected]",
"profileName": "Atendimento Loja",
"profilePictureUrl": "https://pps.whatsapp.net/v/t61.00000-00/foto.jpg",
"state": "open",
"statusReason": 200
},
"destination": "http://172.18.0.1:3333",
"date_time": "2026-08-13T22:28:24.870Z",
"server_url": "http://SEU_IP_AQUI:8080",
"apikey": "SUA_CHAVE_AQUI"
}
Os três estados possíveis:
| Estado | Significa |
|---|---|
open | Conectado. É o único em que enviar funciona |
connecting | Tentando. Ou esperando alguém ler o QR Code |
close | Caiu. Provavelmente vai precisar ler o QR Code de novo |
Esse é o evento que vale ligar a um aviso de verdade, daqueles que te acordam:
function mostrarConexao(dados) {
console.log("Conexao agora esta: " + dados.state);
if (dados.state === "close") {
// Aqui vale mandar e-mail, Telegram, o que voce usar. Enquanto
// estiver "close", todo envio vai falhar.
console.log(" ATENCAO: o WhatsApp desconectou!");
}
}
Um detalhe prático: no meio de uma reinicialização o connecting aparece por alguns segundos antes do open, e isso é normal. Se você disparar alerta a cada connecting, vai receber aviso à toa toda vez que reiniciar o serviço. O estado que merece susto é o close.
🕵️ Duas armadilhas escondidas no envelope
Duas coisas que só se descobre olhando o JSON inteiro, e que a documentação não destaca.
A primeira: a sua chave viaja em cada chamada. Olhe de novo o fim de qualquer um dos JSONs acima. Está lá, o campo apikey, com a chave da instância dentro. A Evolution manda isso em todo aviso.
Isso é útil, porque permite ao seu receptor conferir que quem bateu na porta é mesmo a sua Evolution, e não alguém que descobriu o endereço:
const MINHA_CHAVE = process.env.EVOLUTION_APIKEY;
function tratarEvento(evento) {
// Confere que o aviso veio mesmo da sua Evolution.
if (evento.apikey !== MINHA_CHAVE) {
console.log("aviso recusado: chave nao confere");
return;
}
// ... o resto do tratamento
}
Repare que a chave sai de uma variável de ambiente (process.env), nunca escrita no meio do código. É o mínimo para não subir credencial para o GitHub por descuido.
Mas é útil e perigoso ao mesmo tempo: se o seu webhook responde em http:// sem o "s", essa chave está atravessando a internet em texto puro, legível por qualquer um no caminho. Em teste na rede interna, tudo bem. Exposto na internet, use HTTPS.
A segunda: o byEvents muda a URL sem avisar. Aquele campo que parecia inofensivo na tabela de configuração faz uma coisa surpreendente quando ligado. Eu configurei com byEvents: true, mandei uma mensagem, e o receptor não imprimiu nada. De novo o silêncio. 😑
Para descobrir o motivo, troquei o receptor por um espião de três linhas, que só anota o método e o caminho de tudo que bate nele:
const http = require("http");
http.createServer(function (requisicao, resposta) {
console.log(requisicao.method + " " + requisicao.url);
resposta.writeHead(200);
resposta.end("ok");
}).listen(3334, "0.0.0.0");
E o segredo apareceu:
POST /messages-upsert
Com byEvents: true, a Evolution acrescenta o nome do evento no fim da URL. Um receptor que só atende a raiz nunca vê o aviso passar, e nada acusa o problema.
E olhe a crueldade do detalhe: no caminho da URL o nome vem com hífen (messages-upsert), enquanto dentro do JSON ele vem com ponto (messages.upsert), e na configuração vinha com sublinhado (MESSAGES_UPSERT). Três grafias para o mesmo evento, cada uma no seu lugar. 🙃
A opção é útil para quem quer rotas separadas, cada evento no seu endereço. Mas para começar, deixe byEvents: false e receba tudo num lugar só: é bem mais fácil de depurar.
🧪 O receptor completo, funcionando
Juntando tudo, este é o arquivo inteiro. São umas setenta linhas, sem dependência nenhuma além do Node:
const http = require("http");
const PORTA = 3333;
function mostrarMensagemRecebida(dados) {
// Sem esta checagem, a sua propria resposta volta como se fosse
// pergunta do cliente, e o robo entra em loop respondendo a si mesmo.
if (dados.key.fromMe === true) {
console.log("(ignorado) mensagem que EU enviei");
return;
}
const dequem = dados.key.remoteJid;
const nome = dados.pushName;
// O texto muda de lugar conforme o tipo da mensagem: uma mensagem
// simples vem em "conversation", uma resposta a outra mensagem vem
// em "extendedTextMessage".
let texto = "";
if (dados.message && dados.message.conversation) {
texto = dados.message.conversation;
} else if (dados.message && dados.message.extendedTextMessage) {
texto = dados.message.extendedTextMessage.text;
} else {
texto = "(nao e texto)";
}
console.log("MENSAGEM de " + nome + " (" + dequem + "): " + texto);
}
function mostrarStatusDaMensagem(dados) {
console.log("STATUS da mensagem " + dados.keyId + ": " + dados.status);
}
function mostrarConexao(dados) {
console.log("CONEXAO agora esta: " + dados.state);
}
function tratarEvento(evento) {
// Nomes em minusculas e com ponto: e assim que eles chegam no JSON,
// e nao como o MESSAGES_UPSERT que voce escreveu na configuracao.
if (evento.event === "messages.upsert") {
mostrarMensagemRecebida(evento.data);
} else if (evento.event === "messages.update") {
mostrarStatusDaMensagem(evento.data);
} else if (evento.event === "connection.update") {
mostrarConexao(evento.data);
} else {
// Este else e o que denuncia nome de evento escrito errado.
console.log("evento sem tratamento: " + evento.event);
}
}
const servidor = http.createServer(function (requisicao, resposta) {
let corpo = "";
// O corpo chega em pedacos. Sem juntar tudo antes do JSON.parse,
// mensagem grande quebra com "Unexpected end of JSON input".
requisicao.on("data", function (pedaco) {
corpo = corpo + pedaco;
});
requisicao.on("end", function () {
// Responder 200 na hora. Se demorar, a Evolution tenta de novo
// e o mesmo evento chega duas vezes.
resposta.writeHead(200, { "Content-Type": "text/plain" });
resposta.end("ok");
try {
const evento = JSON.parse(corpo);
tratarEvento(evento);
} catch (erro) {
console.log("Nao consegui ler o JSON recebido: " + erro.message);
}
});
});
servidor.listen(PORTA, "0.0.0.0", function () {
console.log("Esperando webhook na porta " + PORTA);
});
Rodando ele e mandando uma mensagem para a instância, a saída fica assim:
Esperando webhook na porta 3333
MENSAGEM de Cliente Teste ([email protected]): Ta
STATUS da mensagem 3EB0F1CD754284B72254CC: READ
MENSAGEM de Cliente Teste ([email protected]): Vou ver ele me arruma
E reiniciando a instância, os dois avisos de conexão aparecem em sequência:
CONEXAO agora esta: connecting
CONEXAO agora esta: open
😅 O erro que me pegou com o navegador
Para fechar, um deslize bobo que rende uma lição sobre aquele try/catch.
Com o receptor de pé, eu quis conferir se ele estava vivo do jeito mais natural do mundo: abrindo o endereço no navegador. E apareceu isto na tela do servidor:
Nao consegui ler o JSON recebido: Unexpected end of JSON input
Nada estava quebrado. O navegador faz um GET sem corpo nenhum, e o JSON.parse de uma string vazia explode. Se o try/catch não estivesse ali, o processo inteiro teria morrido, o receptor sairia do ar e o próximo aviso de verdade se perderia.
É por isso que aquele catch não é enfeite de "boas práticas": ele é o que mantém o seu receptor vivo diante de qualquer coisa que bata na porta e não seja um webhook, do navegador curioso ao robô que varre a internet procurando portas abertas. E é por isso que ele imprime a mensagem do erro em vez de engolir em silêncio: um catch vazio esconderia justamente o JSON malformado que você precisa ver. 🛡️
Se quiser ser mais elegante, basta responder direto a quem não trouxe corpo:
if (corpo === "") {
// GET do navegador, ou robo varrendo portas. Nao e webhook.
return;
}
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Perguntas frequentes
Como saber se chegou uma mensagem nova no WhatsApp pela Evolution API?
MESSAGES_UPSERT. A cada mensagem que chega, a Evolution manda um POST com o texto, o número de quem enviou e o nome que a pessoa usa no WhatsApp. Nesse mesmo evento também voltam as mensagens que você enviou, então é preciso checar o campo fromMe para não responder a si mesmo.Por que meu webhook não recebe nada, mesmo configurado?
Como saber se a mensagem enviada foi lida?
MESSAGES_UPDATE. Ele chega quando o estado da mensagem muda e traz o campo status, que passa por PENDING, SERVER_ACK, DELIVERY_ACK e READ. O READ é o visto azul duplo. Note que ele só chega se a pessoa não desligou a confirmação de leitura nas configurações dela.Qual a diferença entre MESSAGES_UPSERT e SEND_MESSAGE?
MESSAGES_UPSERT avisa das mensagens que chegam de outras pessoas. O SEND_MESSAGE avisa das mensagens que a sua própria instância acabou de enviar. Se você quer só atender quem escreve para você, o MESSAGES_UPSERT basta, e vale checar o fromMe dentro dele.Para que serve a opção byEvents do webhook?
byEvents: true a Evolution acrescenta o nome do evento no fim da URL, então o aviso de mensagem nova vai para /messages-upsert em vez de /. Repare que ali o nome vem com hífen. É uma armadilha silenciosa: quem deixa essa opção ligada e escuta só a raiz nunca recebe nada, e não aparece erro em lugar nenhum.O que vem dentro do JSON que a Evolution manda para o webhook?
event, instance, data, destination, date_time, sender, server_url e apikey. Preste atenção nesse último: a chave da sua instância viaja dentro de cada chamada. Se o seu webhook não usa HTTPS, ela vai em texto puro pela internet.Leia também
WhatsApp: enviar mensagens grátis com a Evolution API
A Meta passa a cobrar por mensagem em 1/10/2026. Como instalar a Evolution API numa VPS com Docker e enviar mensagens no WhatsApp sem pagar por envio.
Evolution API: enviando todos os tipos de mensagem
Texto, imagem, áudio, vídeo, localização, contato, botões, listas e enquetes pela Evolution API: a rota de cada um, os limites e o erro que engana.