Pular para o conteúdo
WhatsApp

Webhook da Evolution API: receber e saber se foi lida

Ilustração colorida de uma coruja mensageira entregando um pergaminho brilhante a um unicórnio de crina luminosa, ao lado de um painel flutuante com três sinos de aviso acesos e marcas de visto de leitura no ar, com um castelo de velas flutuantes ao fundo

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. 😅

WhatsApp: enviar mensagens grátis com a Evolution APIComo instalar a Evolution API numa VPS com Docker, conectar o WhatsApp pelo QR Code e mandar a primeira mensagem.blog.palomamacetko.com.br

🎯 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. O Ctrl+V comum não funciona no terminal, e é aqui que muita gente acha que travou.
  • Para salvar: Ctrl+O e depois Enter (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:

CampoPara que serve
enabledLiga e desliga o webhook sem perder a configuração
urlOnde a Evolution vai bater. Precisa ser alcançável por ela
byEventsSe true, acrescenta o nome do evento no fim da URL (cuidado, veja abaixo)
base64Se true, manda o conteúdo de áudios e imagens embutido no JSON
eventsA 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 recusadaO pacote chegou, e não tinha ninguém escutando naquela porta
Tempo esgotadoO 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:

CampoO que é
data.key.remoteJidCom quem é a conversa. É para cá que você responde
data.key.fromMefalse se a pessoa enviou; true se foi você
data.key.idO código da mensagem. Guarde-o para cruzar com o status depois
data.pushNameO nome que a pessoa configurou no WhatsApp dela
data.message.conversationO texto, quando a mensagem é texto simples
data.messageTypeO 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ê configuraChega no JSON como
MESSAGES_UPSERTmessages.upsert
MESSAGES_UPDATEmessages.update
CONNECTION_UPDATEconnection.update
SEND_MESSAGEsend.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:

StatusO que aconteceuNo WhatsApp
PENDINGAceita pela API, ainda não saiurelógio
SERVER_ACKChegou ao servidor do WhatsAppum visto
DELIVERY_ACKChegou ao celular da pessoadois vistos cinza
READA pessoa abriu a conversadois vistos azuis
ERRORNão foi entregueponto 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:

EstadoSignifica
openConectado. É o único em que enviar funciona
connectingTentando. Ou esperando alguém ler o QR Code
closeCaiu. 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?
Configure o webhook com o evento 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?
Na maioria dos casos é firewall. Se a Evolution roda em Docker e o seu receptor roda no servidor, o contêiner precisa de permissão para alcançar o endereço da ponte do Docker. O sintoma que identifica isso é o tempo de espera esgotado em vez de conexão recusada: o pacote é descartado em silêncio, sem erro nenhum no log da Evolution.
Como saber se a mensagem enviada foi lida?
Pelo evento 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?
O 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?
Com 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?
Vem um envelope com 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