Pular para o conteúdo
WhatsApp

Evolution API: enviando todos os tipos de mensagem

Ilustração colorida de uma coruja mágica de entrega sobre um balcão de correio encantado, distribuindo ao mesmo tempo um envelope, um quadro com paisagem, um chifre musical, um mapa com alfinete de localização, um cartão de visita e uma urna com cédulas de votação, com um unicórnio de crina luminosa ao lado

Olá meus Unicórnios! 🦄✨

Mandar um "Olá, mundo" pelo WhatsApp é fácil: uma rota, dois campos, pronto. Só que quase nenhum sistema de verdade manda só texto. 😅 Ele manda o comprovante em imagem, o áudio do atendente, o vídeo do produto, o endereço da loja, o cartão do suporte, e aquela enquete para o time decidir onde vai o almoço.

Então fui atrás dos nove tipos de mensagem da Evolution API, um por um, com o objetivo de ter no fim um arquivo só, simples, que manda qualquer um deles. E encontrei pelo caminho uma armadilha que me deixou de olho arregalado: a API responde que deu tudo certo em um caso em que o WhatsApp recusa a mensagem. 🤯

Se você só for ler um pedaço deste artigo, leia a seção do status: ERROR. 🙏

🗺️ O mapa: uma rota por tipo

A primeira coisa que ajuda a organizar a cabeça é que não existe uma rota genérica de "enviar mensagem" com um campo de tipo. A Evolution tem uma rota por tipo, e todas seguem o mesmo desenho:

POST http://SEU_IP_AQUI:8080/message/<rota>/<nome-da-instancia>

O que muda de um tipo para o outro é o nome da rota e o corpo em JSON. O cabeçalho é sempre o mesmo: a sua chave em apikey e o Content-Type dizendo que vai JSON.

Este é o mapa completo, e vale deixar aberto numa aba enquanto você lê o resto:

Tipo de mensagem     Rota                  Campo principal
──────────────────   ───────────────────   ───────────────────
Texto                sendText              text
Imagem               sendMedia             media + mediatype
Áudio de voz         sendWhatsAppAudio     audio
Vídeo                sendMedia             media + mediatype
Localização          sendLocation          latitude/longitude
Contato              sendContact           contact (lista)
Botões               sendButtons           buttons (lista)
Lista                sendList              sections (lista)
Enquete              sendPoll              values (lista)

Repare que imagem e vídeo dividem a mesma rota, o sendMedia, e que o áudio tem uma rota só dele. Isso não é capricho, e é a primeira coisa que confunde: já explico o porquê na seção do áudio.

💬 Texto: o mais simples de todos

Vamos começar pelo que tem dois campos, para você conferir que a sua instância está mesmo conversando antes de partir para os tipos complicados. O number é para quem vai, e o text é o que vai:

curl -X POST "http://SEU_IP_AQUI:8080/message/sendText/tutorial" \
  -H "apikey: SUA_CHAVE_AQUI" \
  -H 'Content-Type: application/json' \
  -d '{"number":"SEU_NUMERO_AQUI","text":"Ola! Mensagem de texto simples."}'

Se você nunca colou um comando desses no terminal: o \ no fim da linha significa "o comando continua na linha de baixo", então copie o bloco inteiro de uma vez. E no terminal do Linux o atalho de colar é Ctrl+Shift+V ou o botão direito do mouse, porque o Ctrl+V comum não funciona ali e trava todo mundo na primeira tentativa. 😉

A resposta, formatada para ficar legível, vem assim:

{
    "key": {
        "remoteJid": "SEU_NUMERO_AQUI",
        "fromMe": true,
        "id": "3EB01470519A97AD59A70A"
    },
    "pushName": "Você",
    "status": "PENDING",
    "message": {
        "conversation": "Ola! Mensagem de texto simples."
    },
    "messageType": "conversation",
    "messageTimestamp": 1787070218,
    "source": "web"
}

Guarde dois campos desse JSON, porque eles voltam no fim do artigo e são a chave da armadilha:

  • id: o identificador da mensagem dentro do WhatsApp. É por ele que você descobre depois se ela chegou.
  • status: PENDING: repare que não diz "enviada". Diz pendente. A API está avisando, com todas as letras, que a história ainda não acabou.

Sobre o number: para uma pessoa, o formato é o número com código do país e sem nada mais (5547999990000), que a Evolution completa para [email protected]. Para um grupo, é o identificador dele com o sufixo @g.us. É um detalhe bobo com uma consequência séria, então volto a ele no fim.

🖼️ Imagem: base64 ou URL, e quem baixa o arquivo

A imagem vai pelo sendMedia, com o mediatype dizendo que é imagem. O campo media aceita duas coisas bem diferentes: o arquivo em base64 ou uma URL.

Começando pelo base64, que é o caminho que não depende de nada externo:

{
    "number": "SEU_NUMERO_AQUI",
    "mediatype": "image",
    "mimetype": "image/jpeg",
    "caption": "Uma imagem com legenda",
    "fileName": "imagem.jpg",
    "media": "/9j/4AAQSkZJRgABAQAAAQABAAD..."
}

⚠️ Aqui mora a primeira pegadinha: o base64 vai puro. Se você está acostumado com HTML, a tentação é colar aquele valor que começa com data:image/jpeg;base64,, do jeito que se usa num src de <img>. Com o prefixo, a chamada volta com 400. Corte tudo até a vírgula, inclusive.

A legenda vai no caption e é opcional. O fileName é o nome que aparece se a pessoa salvar o arquivo.

A URL e a surpresa do 403

Passar uma URL no media parece mais prático, e é, mas tem um detalhe que muda tudo: quem baixa a imagem é o servidor da Evolution, não o WhatsApp e não a máquina que fez a chamada. É o seu servidor que sai na internet buscar aquele endereço.

Descobri isso do jeito mais direto possível: apontei para uma imagem de um site grande e recebi isto de volta.

{
    "status": 500,
    "error": "Internal Server Error",
    "response": {
        "message": [
            "AxiosError: Request failed with status code 403"
        ]
    }
}

Repare no detalhe cruel: são dois códigos de erro na mesma resposta. O 500 é o que a Evolution te devolve, e o 403, escondido lá dentro, é o que o site respondeu para ela. Muitos sites recusam download feito por servidor, sem navegador nem referência, e é exatamente o que aconteceu.

A consequência prática é maior do que parece: quando a URL falha, a mensagem inteira se perde, não só a imagem. Se o arquivo é seu e está na máquina, o base64 evita esse risco de uma vez, porque não depende de o servidor conseguir alcançar site nenhum.

E se você omitir o mimetype, funciona: a Evolution deduz pelo mediatype e pela extensão do fileName. Mas preencher é mais seguro, e não custa nada.

🎤 Áudio: por que existe uma rota só para ele

Esta é a parte que mais gera dúvida, e a resposta é bem concreta. O áudio tem duas formas de chegar no WhatsApp:

  • Pelo sendWhatsAppAudio: chega como mensagem de voz, aquele balão com a ondinha e o botão de tocar, igual a um áudio gravado na hora.
  • Pelo sendMedia com mediatype: "audio": chega como arquivo anexado, que o WhatsApp mostra como documento com um clipe do lado.

Ou seja: a rota não muda o arquivo, muda o que a mensagem é. Para o áudio parecer gravado, é o sendWhatsAppAudio. E ele é simpático de usar, porque tem só dois campos:

{
    "number": "SEU_NUMERO_AQUI",
    "audio": "T2dnUwACAAAAAAAAAAA..."
}

O formato que o WhatsApp usa para voz é OGG com codec Opus. Se você tem um arquivo em outro formato, o ffmpeg converte numa linha:

ffmpeg -i audio-original.mp3 -c:a libopus -b:a 32k audio.ogg

Uma coisa boa que descobri testando: a Evolution converte sozinha. Mandei um arquivo .m4a (que é AAC, não Opus) e a resposta veio com o áudio já reempacotado:

"mimetype": "audio/ogg; codecs=opus"

Ela pegou o AAC e devolveu OGG/Opus, sem eu pedir nada. Isso salva bastante trabalho, mas continua valendo converter você mesmo quando o áudio é longo ou o volume é grande: a conversão consome CPU do seu servidor, e é melhor que ela aconteça uma vez, na sua máquina, do que a cada envio.

🎬 Vídeo: mesma rota da imagem, um campo diferente

Depois de entender o sendMedia, o vídeo é de graça: é o mesmo corpo, trocando duas palavras.

{
    "number": "SEU_NUMERO_AQUI",
    "mediatype": "video",
    "mimetype": "video/mp4",
    "caption": "Um video com legenda",
    "fileName": "video.mp4",
    "media": "AAAAIGZ0eXBpc29tAAACAGlzb21pc28y..."
}

Vale um aviso de bom senso sobre tamanho. O base64 engorda o arquivo em cerca de 33%, porque ele representa cada 3 bytes com 4 caracteres. Um vídeo de 15 MB viaja como uns 20 MB de texto dentro do JSON, e isso passa pela memória do seu servidor. Para vídeo grande, a URL é mais sensata que o base64, desde que ela esteja num lugar que responda ao seu servidor sem 403.

📍 Localização: quatro campos e nenhuma surpresa

A localização é o tipo mais tranquilo do artigo. Dois campos de texto para a pessoa ler, e as duas coordenadas:

{
    "number": "SEU_NUMERO_AQUI",
    "name": "Praca Central",
    "address": "Rua das Flores, 100",
    "latitude": -26.3044,
    "longitude": -48.8487
}

As coordenadas vão como número, não como texto: -26.3044, sem aspas, com ponto decimal e não vírgula. E são obrigatórias, com um erro bem claro quando faltam:

{
    "status": 400,
    "error": "Bad Request",
    "response": {
        "message": [
            [
                "instance requires property \"latitude\""
            ]
        ]
    }
}

Esse formato de erro vale a pena reconhecer, porque ele volta em todos os tipos: a Evolution valida o corpo contra um esquema antes de tentar mandar qualquer coisa, e a mensagem diz literalmente qual propriedade está faltando. É o tipo de erro que se resolve lendo. 🙂

O name e o address são só rótulos: quem manda no alfinete do mapa são a latitude e a longitude. Se você trocar as duas de lugar, a mensagem chega perfeita, com o nome certinho, apontando para o meio do oceano. Não há erro nenhum para te avisar.

👤 Contato: o vCard que a Evolution monta para você

O contato tem uma particularidade: o campo contact é uma lista, porque você pode mandar vários de uma vez, cada um sendo um objeto.

{
    "number": "SEU_NUMERO_AQUI",
    "contact": [
        {
            "fullName": "Suporte da Loja",
            "wuid": "5547999990000",
            "phoneNumber": "+55 47 99999-0000",
            "organization": "Loja Exemplo",
            "email": "[email protected]"
        }
    ]
}

A dúvida certa aqui é: por que dois campos de telefone? Isso mesmo, dois! Eles têm papéis diferentes:

  • wuid: o número limpo, só dígitos. É o que o WhatsApp usa internamente para reconhecer que aquele contato tem conta, e é o que faz aparecer o botão de conversar.
  • phoneNumber: o número como a pessoa vai ler na tela. Aqui pode ter espaço, parêntese e traço.

A parte boa é que você não escreve vCard nenhum: a Evolution monta e mostra na resposta o que foi gerado.

BEGIN:VCARD
VERSION:3.0
N:Suporte da Loja
FN:Suporte da Loja
ORG:Loja Exemplo;
EMAIL:[email protected]
item1.TEL;waid=5547999990000:+55 47 99999-0000
item1.X-ABLabel:Celular
END:VCARD

Repare na linha do telefone, que é onde os dois campos se encontram: o waid= recebeu o wuid, e depois dos dois-pontos ficou o texto formatado. É exatamente por isso que os dois existem.

🔘 Botões: três, e nem um a mais

Agora entramos nos tipos interativos, que são os mais úteis e os mais cheios de regra. Os botões vão assim:

{
    "number": "SEU_NUMERO_AQUI",
    "title": "Pedido #1234",
    "description": "Confirma o seu pedido?",
    "footer": "Loja Exemplo",
    "buttons": [
        { "type": "reply", "displayText": "Confirmar", "id": "sim" },
        { "type": "reply", "displayText": "Cancelar", "id": "nao" }
    ]
}

O displayText é o que a pessoa lê no botão, e o id é o código que volta para você quando ela toca. Esse id é a peça que faz o botão valer a pena: é ele que o seu sistema recebe e usa para saber o que fazer, sem tentar adivinhar o que a pessoa digitou.

E aqui vem o primeiro limite duro, com a mensagem de erro mais direta que encontrei em toda a API. Tentei quatro botões:

{
    "status": 400,
    "error": "Bad Request",
    "response": {
        "message": [
            "Maximum of 3 reply buttons allowed"
        ]
    }
}

Três botões de resposta, é o teto. Não é limitação da Evolution sendo conservadora: é o que o WhatsApp aceita. Se você tem mais de três opções para oferecer, o caminho é a lista, que vem na próxima seção.

Os outros três tipos de botão

O type: "reply" é o que devolve resposta, mas não é o único. Há três outros, e cada um usa um campo próprio em vez do id:

{
    "number": "SEU_NUMERO_AQUI",
    "title": "Loja Exemplo",
    "description": "Fale com a gente",
    "buttons": [
        { "type": "url",  "displayText": "Abrir o site",   "url": "https://exemplo.com" },
        { "type": "call", "displayText": "Ligar",          "phoneNumber": "5547999990000" },
        { "type": "copy", "displayText": "Copiar cupom",   "copyCode": "UNICORNIO10" }
    ]
}

O url abre um link, o call abre o discador com o número pronto, e o copy copia um código para a área de transferência, que é ótimo para cupom e para código de confirmação. Nenhum dos três devolve nada para o seu sistema, porque a ação acontece no celular da pessoa.

Uma curiosidade sobre o que a Evolution monta

Se você olhar a resposta de um envio de botões, vai encontrar uma estrutura bem diferente do que mandou:

{
    "message": {
        "viewOnceMessage": {
            "message": {
                "interactiveMessage": {
                    "body": {
                        "text": "*Pedido #1234*\n\nConfirma o seu pedido?\n"
                    },
                    "footer": { "text": "Loja Exemplo" },
                    "nativeFlowMessage": {
                        "buttons": [
                            {
                                "name": "quick_reply",
                                "buttonParamsJson": "{\"display_text\":\"Confirmar\",\"id\":\"sim\"}"
                            }
                        ]
                    }
                }
            }
        }
    },
    "messageType": "viewOnceMessage"
}

Duas coisas aparecem aí. A primeira: o title que você mandou virou negrito no corpo do texto (*Pedido #1234*), seguido de duas quebras de linha e da descrição. Não existe campo de título separado na mensagem final; a Evolution simplesmente monta um texto só. Saber isso evita você quebrar a cabeça achando que o título não apareceu do jeito certo, quando é assim que ele funciona.

A segunda: o tipo é viewOnceMessage, aquele mesmo do "ver uma vez" das fotos. É um empacotamento que o WhatsApp usa para as mensagens interativas, e não significa que a mensagem vai desaparecer.

O esquema antigo que você vai achar em tutorial velho

Se você pesquisar botões da Evolution, vai encontrar exemplos com buttonText e buttonId. Esse era o formato da versão 1, e ele foi trocado. Hoje ele responde:

{
    "status": 400,
    "error": "Bad Request",
    "response": {
        "message": [
            [
                "buttons[0] requires property \"type\""
            ]
        ]
    }
}

Esse erro é uma boa notícia disfarçada: ele diz exatamente o que fazer. Se você copiou um exemplo e recebeu isso, é tutorial antigo. Acrescente o type e troque buttonText por displayText e buttonId por id.

📋 Listas: o campo obrigatório que ninguém adivinha

A lista é o caminho para quando três botões não bastam. Ela chega como uma mensagem com um botão que, ao ser tocado, abre um menu com seções e itens.

{
    "number": "SEU_NUMERO_AQUI",
    "title": "Nosso menu",
    "description": "Toque para ver os itens",
    "footerText": "Loja Exemplo",
    "buttonText": "Ver opcoes",
    "sections": [
        {
            "title": "Doces",
            "rows": [
                { "title": "Bolo de cenoura", "description": "Fatia", "rowId": "bolo" },
                { "title": "Brigadeiro", "description": "Unidade", "rowId": "briga" }
            ]
        }
    ]
}

A estrutura tem três níveis, e é bom não confundir: a mensagem tem seções, cada seção tem um título e uma lista de itens, e cada item (row) tem título, descrição e o rowId, que é o equivalente ao id do botão: o código que volta para você.

E tem uma pegadinha de campo obrigatório que me custou algumas tentativas. Repare que nos botões o rodapé se chama footer e é opcional. Na lista ele se chama footerText e é obrigatório:

{
    "status": 400,
    "error": "Bad Request",
    "response": {
        "message": [
            [
                "instance requires property \"footerText\""
            ]
        ]
    }
}

Nome diferente e obrigatoriedade diferente, para a mesma coisa, em dois tipos vizinhos. É o tipo de detalhe que só se descobre batendo o nariz, e é o motivo de a validação da Evolution ser uma amiga: ela diz o nome exato da propriedade que falta. O rowId de cada item também é obrigatório, com o mesmo formato de erro.

⚠️ O status ERROR: quando o 200 mente

Chegamos na parte que me fez repensar como eu confiro envio. 😳

Todos os nove tipos foram aceitos pela API. Todos devolveram um JSON bonito, com id, com status: PENDING, sem nenhum campo de erro. Se eu tivesse parado ali, teria publicado que os nove funcionam.

Só que o PENDING estava falando sério. Fui olhar o log da Evolution e encontrei isto:

event: 'messages.update',
instance: 'tutorial',
data: {
  keyId: '3EB0BC5BF1AF31E63F2B4C',
  remoteJid: 'SEU_NUMERO_AQUI',
  fromMe: true,
  status: 'ERROR',
}

Aquele keyId é o id que a chamada da lista tinha me devolvido, com HTTP 200, minutos antes. A mensagem foi aceita pela Evolution, repassada ao WhatsApp, e o WhatsApp a recusou. Sem que nada na resposta original indicasse isso.

Repeti o envio dos nove tipos algumas vezes para ter certeza de que não era acaso, comparando cada id devolvido com os eventos que chegaram depois. O resultado foi idêntico em todas as rodadas: um único ERROR, sempre o da lista. Os outros oito tipos seguiram para DELIVERY_ACK, que é o WhatsApp confirmando a entrega.

Então é isso que está acontecendo com as listas hoje: o WhatsApp não está mais aceitando listMessage vindo dessa forma. A rota existe, a validação passa, o JSON de resposta é perfeito, e a mensagem não chega. Se você precisa de um menu com mais de três opções, o caminho que funciona é oferecer as opções em texto numerado e ler o número que a pessoa responder, ou dividir em botões de três em três.

Por que o 200 não é confirmação de entrega

Esta é a lição que vale para todos os tipos, e ela é estrutural, não um bug. Olhe o caminho que uma mensagem faz:

seu codigo  ──POST──>  Evolution  ──WhatsApp Web──>  WhatsApp
              (responde 200 aqui)              (aceita ou recusa aqui)

O 200 nasce no meio do caminho. Ele confirma que a Evolution entendeu o seu pedido, validou o corpo e repassou adiante. O que o WhatsApp vai fazer com a mensagem acontece depois, de forma assíncrona, e chega por outro canal: o evento messages.update.

Os estados que você vai ver nesse evento são estes:

PENDING        saiu daqui, aguardando o WhatsApp
SERVER_ACK     o servidor do WhatsApp recebeu
DELIVERY_ACK   entregou no aparelho de destino
READ           a pessoa leu
ERROR          o WhatsApp recusou a mensagem

Ou seja: quem confirma entrega é o DELIVERY_ACK, nunca o código HTTP. Para acompanhar isso num sistema de verdade, o caminho é configurar um webhook e escutar o messages.update, que é assunto para um artigo inteiro. Mas para testar durante o desenvolvimento, o log do contêiner resolve:

docker logs evolution-api --since 5m | grep -A 8 "messages.update"

O --since 5m mostra só os últimos cinco minutos, e o -A 8 mostra as oito linhas seguintes a cada ocorrência, que é onde o keyId e o status aparecem. Guarde o id que a API devolveu e procure por ele: é assim que você separa "a mensagem não chegou" de "a mensagem nunca foi aceita".

📊 Enquetes: de 2 a 10, e um zero que muda o tipo

Fecho com o tipo mais divertido. A enquete tem três campos:

{
    "number": "SEU_NUMERO_AQUI",
    "name": "Qual sabor voce prefere?",
    "selectableCount": 1,
    "values": ["Chocolate", "Morango", "Baunilha"]
}

O name é a pergunta, o values são as alternativas, e o selectableCount diz quantas cada pessoa pode marcar: 1 é voto único, 2 permite marcar duas, e assim por diante.

Os limites são cobrados na validação, com mensagens que dizem o número exato. Com uma alternativa só:

{
    "status": 400,
    "error": "Bad Request",
    "response": {
        "message": [
            [
                "values does not meet minimum length of 2"
            ]
        ]
    }
}

E com treze:

{
    "status": 400,
    "error": "Bad Request",
    "response": {
        "message": [
            [
                "values does not meet maximum length of 10"
            ]
        ]
    }
}

De 2 a 10 alternativas, portanto. Faz sentido: uma alternativa não é uma pergunta, e onze não cabem na tela do celular.

O zero que troca o tipo da mensagem sem avisar

Este achado eu gosto especialmente, porque não dá erro nenhum. Mandei selectableCount: 0 só para ver o que acontecia, esperando um 400. Não veio erro: veio uma mensagem de outro tipo.

Com selectableCount: 1, a resposta traz:

"messageType": "pollCreationMessageV3"

Com selectableCount: 0, a mesma rota devolve:

"messageType": "pollCreationMessage"

Repare no V3 que desapareceu. É a versão antiga do formato de enquete do WhatsApp, e ela sai calada, sem aviso nenhum. Um zero acidental num campo numérico, talvez uma variável que veio vazia do seu banco, e a sua enquete vira uma mensagem de formato antigo. Sempre mande 1 quando quiser voto único.

🧩 Um arquivo só, para os nove tipos

Depois de mapear tudo, juntei num arquivo único e linear. É Node puro, com fetch nativo, então não precisa instalar nada: nenhum npm install, nenhuma dependência. Salve como enviar.js e pronto.

Primeiro, a configuração e a função por onde todo envio passa:

const fs = require("fs");

const API = process.env.EVO_URL || "http://SEU_IP_AQUI:8080";
const CHAVE = process.env.EVO_KEY || "SUA_CHAVE_AQUI";
const INSTANCIA = process.env.EVO_INSTANCIA || "tutorial";
const DESTINO = process.env.EVO_DESTINO || "SEU_NUMERO_AQUI";

// Todo envio passa por aqui. A Evolution responde 200 mesmo quando o
// WhatsApp vai recusar depois, por isso imprimimos o id: e por ele que
// voce encontra a mensagem no log se ela nao chegar.
async function enviar(rota, corpo) {
    const resposta = await fetch(API + "/message/" + rota + "/" + INSTANCIA, {
        method: "POST",
        headers: {
            "apikey": CHAVE,
            "Content-Type": "application/json"
        },
        body: JSON.stringify(corpo)
    });

    const dados = await resposta.json();

    if (resposta.status !== 201 && resposta.status !== 200) {
        console.log("ERRO " + resposta.status + " em " + rota + ":");
        console.log(JSON.stringify(dados, null, 4));
        return null;
    }

    console.log("ok   " + rota + "  id=" + dados.key.id);
    return dados;
}

// O arquivo vai em base64 puro, SEM o prefixo "data:image/jpeg;base64,".
// Com o prefixo a Evolution responde 400.
function paraBase64(caminho) {
    return fs.readFileSync(caminho).toString("base64");
}

Repare que a chave e o IP saem de variável de ambiente, com um valor de reserva. Isso não é firula: é o que evita você commitar a sua apikey por distração, e ela dá acesso a mandar mensagem em nome do seu WhatsApp.

Agora as nove funções. Cada uma monta o corpo do seu tipo e entrega para a enviar(), com um comentário curto onde mora a armadilha:

function enviarTexto(texto) {
    return enviar("sendText", { number: DESTINO, text: texto });
}

function enviarImagem(caminho, legenda) {
    return enviar("sendMedia", {
        number: DESTINO,
        mediatype: "image",
        mimetype: "image/jpeg",
        caption: legenda,
        fileName: "imagem.jpg",
        media: paraBase64(caminho)
    });
}

// Audio de voz tem rota propria. O sendMedia com mediatype "audio"
// manda como arquivo anexado, nao como aquele balaozinho de gravacao.
function enviarAudio(caminho) {
    return enviar("sendWhatsAppAudio", {
        number: DESTINO,
        audio: paraBase64(caminho)
    });
}

function enviarVideo(caminho, legenda) {
    return enviar("sendMedia", {
        number: DESTINO,
        mediatype: "video",
        mimetype: "video/mp4",
        caption: legenda,
        fileName: "video.mp4",
        media: paraBase64(caminho)
    });
}

function enviarLocalizacao(nome, endereco, latitude, longitude) {
    return enviar("sendLocation", {
        number: DESTINO,
        name: nome,
        address: endereco,
        latitude: latitude,
        longitude: longitude
    });
}

function enviarContato(nome, numero, empresa) {
    return enviar("sendContact", {
        number: DESTINO,
        contact: [{
            fullName: nome,
            // wuid e o numero limpo, so digitos. E o que faz o WhatsApp
            // reconhecer o contato e mostrar o botao de conversar.
            wuid: numero,
            phoneNumber: numero,
            organization: empresa
        }]
    });
}

// No maximo 3 botoes. O quarto derruba a chamada com 400.
function enviarBotoes(titulo, texto, rodape, botoes) {
    return enviar("sendButtons", {
        number: DESTINO,
        title: titulo,
        description: texto,
        footer: rodape,
        buttons: botoes
    });
}

// footerText e obrigatorio aqui, apesar de ser opcional nos botoes.
function enviarLista(titulo, texto, rodape, textoBotao, secoes) {
    return enviar("sendList", {
        number: DESTINO,
        title: titulo,
        description: texto,
        footerText: rodape,
        buttonText: textoBotao,
        sections: secoes
    });
}

// De 2 a 10 opcoes. selectableCount = 1 e voto unico.
function enviarEnquete(pergunta, opcoes, quantosVotos) {
    return enviar("sendPoll", {
        number: DESTINO,
        name: pergunta,
        selectableCount: quantosVotos,
        values: opcoes
    });
}

E o main(), que escolhe o tipo pelo argumento da linha de comando. Um if atrás do outro, de propósito: dá para ler de cima a baixo sem parar para decifrar nada.

async function main() {
    const tipo = process.argv[2] || "texto";

    try {
        if (tipo === "texto") {
            await enviarTexto("Ola! Mensagem de texto simples.");
        } else if (tipo === "imagem") {
            await enviarImagem("imagem.jpg", "Uma imagem com legenda");
        } else if (tipo === "audio") {
            await enviarAudio("audio.ogg");
        } else if (tipo === "video") {
            await enviarVideo("video.mp4", "Um video com legenda");
        } else if (tipo === "localizacao") {
            await enviarLocalizacao("Praca Central", "Rua das Flores, 100",
                -26.3044, -48.8487);
        } else if (tipo === "contato") {
            await enviarContato("Suporte da Loja", "5547999990000",
                "Loja Exemplo");
        } else if (tipo === "botoes") {
            await enviarBotoes("Pedido #1234", "Confirma o seu pedido?",
                "Loja Exemplo", [
                    { type: "reply", displayText: "Confirmar", id: "sim" },
                    { type: "reply", displayText: "Cancelar", id: "nao" }
                ]);
        } else if (tipo === "enquete") {
            await enviarEnquete("Qual sabor voce prefere?",
                ["Chocolate", "Morango", "Baunilha"], 1);
        } else {
            console.log("Tipo desconhecido: " + tipo);
        }
    } catch (erro) {
        console.log("Falhou ao falar com a API: " + erro.message);
    }
}

main();

O try/catch ali cobre um caso específico e nada mais: o IP errado, o servidor fora do ar, a rede caindo. Nesses casos o fetch não devolve resposta, ele estoura, e sem o catch você veria um rastro de pilha em vez de uma frase. Erro de validação da API não passa por ali, porque ele é uma resposta: quem trata é o if do status dentro da enviar().

Para rodar, é uma linha por tipo. Ponha a sua chave e o seu IP nas variáveis de ambiente, e chame:

export EVO_URL="http://SEU_IP_AQUI:8080"
export EVO_KEY="SUA_CHAVE_AQUI"
export EVO_DESTINO="SEU_NUMERO_AQUI"

node enviar.js texto
node enviar.js imagem
node enviar.js audio
node enviar.js video
node enviar.js localizacao
node enviar.js contato
node enviar.js botoes
node enviar.js enquete

E a saída, com os oito tipos que o WhatsApp entrega:

ok   sendText  id=3EB01470519A97AD59A70A
ok   sendMedia  id=3EB020A780F38D4D4660DD
ok   sendWhatsAppAudio  id=3EB0CE6476C0AF26996F8A
ok   sendMedia  id=3EB0ECB86F4CE3BE18BB42
ok   sendLocation  id=3EB0E89CB95E555C96E247
ok   sendContact  id=3EB058C2117B2E832E63ED
ok   sendButtons  id=3EB0104E30EDCAC370A77C
ok   sendPoll  id=3EB09824169010FA6BDB13

Aquele id impresso em cada linha é o que fecha o ciclo do artigo: é ele que você procura no log quando uma mensagem não aparecer no celular. Sem guardá-lo, você fica sem meio de saber se o problema foi seu ou do WhatsApp.

🎒 Os campos que aparecem em todos os tipos

Uma última coisa que economiza tempo: há campos opcionais que funcionam em qualquer uma das nove rotas, porque são tratados antes do tipo da mensagem.

Campo             O que faz
───────────────   ─────────────────────────────────────────
delay             espera N milissegundos antes de enviar
quoted            responde citando outra mensagem
mentionsEveryOne  marca todos os participantes do grupo
mentioned         marca contatos especificos

O delay é o mais útil dos quatro no dia a dia. Ele é o jeito mais simples de não disparar dez mensagens no mesmo segundo, o que é justamente o padrão de robô que aumenta o risco do seu número. Um delay de alguns segundos entre envios custa nada e parece bem mais com uma pessoa digitando.

Em inglês, na documentação, os campos aparecem como delay, quoted, mentionsEveryOne e mentioned, dentro do mesmo objeto do corpo da requisição.

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

Qual a rota para enviar cada tipo de mensagem na Evolution API?
Cada tipo tem a sua: sendText para texto, sendMedia para imagem e vídeo (mudando o campo mediatype), sendWhatsAppAudio para áudio de voz, sendLocation para localização, sendContact para contato, sendButtons para botões, sendList para listas e sendPoll para enquetes. Todas são POST em /message/<rota>/<instancia>, com a apikey no cabeçalho.
Por que a Evolution API responde 200 e a mensagem não chega?
Porque o 200 confirma só que a Evolution aceitou o pedido e o repassou ao WhatsApp Web, não que o WhatsApp aceitou a mensagem. A recusa chega depois, de forma assíncrona, no evento messages.update com status: ERROR e o mesmo id que a resposta devolveu. Sem olhar esse evento (ou o log da Evolution), o envio parece ter dado certo.
Quantos botões a Evolution API aceita numa mensagem?
No máximo três botões de resposta rápida. O quarto derruba a chamada com 400 e a mensagem Maximum of 3 reply buttons allowed. Além do tipo reply, existem os botões url (abre um link), call (liga para um número) e copy (copia um código), que usam campos próprios em vez do id.
Quantas opções pode ter uma enquete no WhatsApp?
De 2 a 10. Com uma opção só a API responde values does not meet minimum length of 2; com onze ou mais, values does not meet maximum length of 10. O campo selectableCount define quantas alternativas cada pessoa pode marcar: 1 é voto único.
Qual a diferença entre sendMedia com áudio e sendWhatsAppAudio?
O sendWhatsAppAudio manda o áudio como mensagem de voz, aquele balão com a onda sonora e o botão de tocar. O sendMedia com mediatype: audio manda como arquivo anexado, que o WhatsApp mostra como documento. Para o áudio parecer gravado na hora, é o sendWhatsAppAudio.
Como mandar uma imagem sem hospedar o arquivo em algum lugar?
Coloque o arquivo em base64 puro no campo media, sem o prefixo data:image/jpeg;base64,. Vale lembrar que, quando você passa uma URL nesse campo, quem baixa o arquivo é o servidor da Evolution: se o site responder 403 para ele, a chamada devolve 500 e a mensagem se perde.

Leia também