Whatsapp

Escopo: JavaScript/Node.js. Guia baseado em Baileys v7 e Meta Cloud API v21.0 com Node.js 22. Conceitos transversais de bots (webhook, polling, command routing, rate limit): shared/platform/bots.md. Diferença entre API oficial e cliente não-oficial, Template Messages, verificação de webhook: shared/platform/bots-advanced.md.

Automatizar WhatsApp começa com uma escolha, e ela muda tudo que vem depois. O Baileys finge ser o WhatsApp Web: você lê um QR code com o celular, a sessão abre e o bot funciona hoje, sem pedir licença a ninguém. Como a Meta não autoriza esse caminho, o número pode ser bloqueado. A Meta Cloud API é a via oficial, com número homologado, mensagens pré-aprovadas e regra sobre quando o bot pode falar; custa aprovação e burocracia, e em troca não desaparece. Baileys serve a protótipo e uso interno; a Cloud API, a produto que precisa durar. Uma pegadinha vale registrar de saída: a Meta arquivou o SDK Node.js oficial, então o caminho dela aqui é fetch puro do Node.js 22.

Conceitos fundamentais

ConceitoO que é
Baileys (cliente WhatsApp Web não-oficial)Biblioteca que simula o WhatsApp Web via WebSocket; sem aprovação da Meta
Meta Cloud API (Interface de Programação Meta na Nuvem)API oficial da Meta para WhatsApp Business; exige aprovação e número homologado
WhatsApp Business Account (Conta WhatsApp Business)Entidade que agrupa números e templates aprovados pela Meta
Template Message (mensagem por template)Mensagem com formato pré-aprovado pela Meta; obrigatória fora da janela de 24h
24-hour window (janela de 24 horas)Período após mensagem do usuário em que o bot pode responder com texto livre
webhook verification (verificação de webhook)Handshake hub.challenge que a Meta exige para confirmar o dono do endpoint
QR pairing (pareamento por QR)Fluxo do Baileys que registra a sessão lendo um QR code com o app do celular
multi-file auth state (estado de autenticação em múltiplos arquivos)Persistência de credenciais Baileys em arquivos separados (useMultiFileAuthState)

Instalação

# Baileys v7 (cliente não-oficial, ESM-only)
npm install @whiskeysockets/baileys

# Meta Cloud API: sem pacote; usa fetch nativo do Node.js 22

Baileys v7

O que importar do pacote

A versão 7 do Baileys é ESM-only (publicada só como módulo ES), e require deixou de funcionar. O detalhe que costuma custar meia hora: makeWASocket é o export padrão, enquanto os utilitários são exports nomeados. Trocar os dois de lugar produz um undefined na hora de criar o socket.

❌ Ruim: CommonJS quebrado no v7; makeWASocket como named import
const { makeWASocket, useMultiFileAuthState } = require('@whiskeysockets/baileys');
✅ Bom: ESM; makeWASocket como default import; named imports separados
import makeWASocket, { useMultiFileAuthState, DisconnectReason } from '@whiskeysockets/baileys';

Abrir a sessão e sobreviver às quedas

A conexão do Baileys cai: a rede oscila, o WhatsApp encerra a sessão, o processo reinicia. O que separa um bot utilizável de um que exige babá é o tratamento de connection.update. Quando a conexão fecha, você olha o motivo: se foi logout de verdade, reconectar não adianta, porque a credencial morreu e o QR precisa ser lido de novo; qualquer outro motivo é queda passageira e pede um startBot() novo. useMultiFileAuthState é o que faz o reinício ser barato, guardando a credencial em disco para que o QR não volte a cada restart.

❌ Ruim: sem reconnect; sem tratamento de logout; printQRInTerminal ausente
import makeWASocket, { useMultiFileAuthState } from '@whiskeysockets/baileys';

const { state, saveCreds } = await useMultiFileAuthState('./auth');
const socket = makeWASocket({ auth: state });
socket.ev.on('creds.update', saveCreds);
✅ Bom: reconnect automático; guard para logout; printQRInTerminal ativo
import makeWASocket, { useMultiFileAuthState, DisconnectReason } from '@whiskeysockets/baileys';

async function startBot() {
  const { state, saveCreds } = await useMultiFileAuthState('./auth');

  const socket = makeWASocket({
    auth: state,
    printQRInTerminal: true,
  });

  socket.ev.on('creds.update', saveCreds);

  socket.ev.on('connection.update', (update) => {
    const { connection, lastDisconnect } = update;

    if (connection !== 'close') return;

    const statusCode = lastDisconnect?.error?.output?.statusCode;
    const shouldReconnect = statusCode !== DisconnectReason.loggedOut;

    if (shouldReconnect) startBot();
  });

  socket.ev.on('messages.upsert', ({ messages }) => {
    for (const message of messages) {
      processIncomingMessage(socket, message);
    }
  });
}

startBot();

Ler a mensagem recebida e despachar

Duas armadilhas moram aqui. A primeira: o evento messages.upsert também entrega o que o próprio bot enviou, então o guard de message.key.fromMe evita que ele converse consigo mesmo. A segunda: o texto da mensagem não vem sempre no mesmo campo. Mensagem simples chega em conversation; mensagem que cita outra, ou que traz link, chega em extendedTextMessage.text. Por isso a extração vira uma função própria, e o handler fica com três passos legíveis: descartar o que é seu, pegar o texto, despachar pelo mapa de comandos.

❌ Ruim: sem guard para fromMe; routing inline com texto; lógica misturada
socket.ev.on('messages.upsert', async ({ messages }) => {
  const msg = messages[0];
  const text = msg.message?.conversation;
  if (text === '/pedido') {
    await socket.sendMessage(msg.key.remoteJid, { text: 'Qual o ID?' });
  }
});
✅ Bom: guard fromMe; extração e routing separados; Strategy Map
function processIncomingMessage(socket, message) {
  if (message.key.fromMe) return;

  const text = extractMessageText(message);
  const chatId = message.key.remoteJid;

  if (!text) return;

  routeCommand(socket, chatId, text);
}

function extractMessageText(message) {
  const text =
    message.message?.conversation ||
    message.message?.extendedTextMessage?.text ||
    null;
  return text;
}

const COMMAND_MAP = {
  '/order': orderCommand,
  '/status': statusCommand,
  '/help': helpCommand,
};

async function routeCommand(socket, chatId, text) {
  const commandKey = text.split(' ')[0].toLowerCase();
  const command = COMMAND_MAP[commandKey];
  if (!command) return;

  await command(socket, chatId, text);
}

O remoteJid que vira chatId é o identificador da conversa no WhatsApp, e é para ele que a resposta volta.

Responder ao contato

O comando recebe o socket, a conversa e o texto, e devolve a resposta pelo mesmo caminho. O sendMessage sempre leva um objeto, nunca uma string solta, porque a mesma chamada serve para texto, imagem e documento; o formato do objeto é que decide.

async function orderCommand(socket, chatId, messageText) {
  const orderId = messageText.split(' ')[1];

  if (!orderId) {
    const errorPayload = { text: 'Informe o ID. Exemplo: /order 12345' };
    await socket.sendMessage(chatId, errorPayload);
    return;
  }

  const order = await fetchOrder(orderId);
  const summary = buildOrderSummary(order);

  const messagePayload = { text: summary };
  await socket.sendMessage(chatId, messagePayload);
}

Meta Cloud API

Provar à Meta que o endpoint é seu

Antes de mandar qualquer mensagem para o seu webhook, a Meta faz uma visita de conferência: chama a URL com três parâmetros e espera receber de volta o valor de hub.challenge. Devolver o challenge sem olhar o resto é o erro comum, e ele entrega o endpoint a qualquer um que descubra a URL. A verificação real confere duas coisas antes: que hub.mode é subscribe e que hub.verify_token bate com o segredo que você cadastrou no painel da Meta.

❌ Ruim: sem verificação de hub.mode; responde 200 sem checar token
app.get('/webhook', (req, res) => {
  res.status(200).send(req.query['hub.challenge']);
});
✅ Bom: verifica mode e token antes de responder com o challenge
app.get('/webhook', (request, response) => {
  const mode = request.query['hub.mode'];
  const token = request.query['hub.verify_token'];
  const challenge = request.query['hub.challenge'];

  const isVerificationRequest = mode === 'subscribe' && token === process.env.WEBHOOK_VERIFICATION_TOKEN;

  if (!isVerificationRequest) {
    response.sendStatus(403);
    return;
  }

  response.status(200).send(challenge);
});

Aceitar rápido e processar depois

Responda 200 OK na primeira linha, antes de processar. A Meta trata demora como falha de entrega: ela cancela, tenta de novo e, se o padrão se repetir, desativa o webhook. Buscar pedido no banco antes de responder coloca o tempo do seu banco dentro do orçamento dela. A inversão resolve: confirma o recebimento, e o processamento continua depois que a resposta já saiu.

❌ Ruim: req/res abreviados; processamento síncrono; sem checar body.object
app.post('/webhook', async (req, res) => {
  const message = extractMessage(req.body);
  await processMessage(message);
  res.sendStatus(200);
});
✅ Bom: request/response sem abreviação; 200 imediato; check de body.object; async após resposta
import express from 'express';

const app = express();

app.use(express.json());

app.post('/webhook', (request, response) => {
  response.sendStatus(200);

  const isWhatsAppEvent = request.body?.object === 'whatsapp_business_account';
  if (!isWhatsAppEvent) return;

  const message = extractMessage(request.body);
  if (message) processMessage(message);
});

function extractMessage(body) {
  const message = body?.entry?.[0]?.changes?.[0]?.value?.messages?.[0] ?? null;
  return message;
}

async function processMessage(message) {
  const chatId = message.from;
  const text = message.text?.body ?? '';
  await routeCommand(chatId, text);
}

O caminho até a mensagem dentro do corpo é longo e cheio de arrays, e nem todo evento traz mensagem: notificação de entrega e de leitura chegam pelo mesmo endpoint. Daí a cadeia de acesso opcional em extractMessage, terminando em null quando não há nada para tratar.

Chamar a API para responder

Sem SDK, a resposta é um POST montado à mão. Vale reparar em três detalhes que a versão ruim erra. A versão vai no caminho (/v21.0/), porque endpoint sem versão segue a versão padrão da conta e muda debaixo do seu código. O Content-Type: application/json precisa estar presente, senão a Meta não lê o corpo. E o recipient_type explícito evita ambiguidade quando o destino não é uma pessoa.

❌ Ruim: endpoint sem versão; env vars fora do padrão Meta; sem recipient_type; sem Content-Type
async function sendText(chatId, text) {
  await fetch(`https://graph.facebook.com/${process.env.PHONE_ID}/messages`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.TOKEN}` },
    body: JSON.stringify({ messaging_product: 'whatsapp', to: chatId, text: { body: text } }),
  });
}
✅ Bom: endpoint v21.0; env vars padrão Meta; recipient_type explícito; headers completos
async function sendTextMessage(chatId, text) {
  const endpoint = `https://graph.facebook.com/v21.0/${process.env.WA_PHONE_NUMBER_ID}/messages`;
  const headers = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.WA_ACCESS_TOKEN}`,
  };

  const payload = {
    messaging_product: 'whatsapp',
    recipient_type: 'individual',
    to: chatId,
    type: 'text',
    text: { body: text },
  };

  const body = JSON.stringify(payload);
  const response = await fetch(endpoint, { method: 'POST', headers, body });
  return response;
}

Mensagem pré-aprovada fora da janela de 24 horas

Texto livre só é permitido dentro da janela de 24 horas, contada a partir da última mensagem que o usuário mandou. Passado esse prazo, ou quando é o bot quem inicia a conversa, a Meta só aceita uma Template Message: um formato que você cadastrou antes e que ela aprovou, com os trechos variáveis marcados. No envio, você não manda o texto pronto, manda o nome do template, o idioma e os valores que preenchem as lacunas, na ordem em que foram declaradas.

async function sendOrderTemplate(chatId, orderId, orderStatus) {
  const endpoint = `https://graph.facebook.com/v21.0/${process.env.WA_PHONE_NUMBER_ID}/messages`;
  const headers = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.WA_ACCESS_TOKEN}`,
  };

  const payload = {
    messaging_product: 'whatsapp',
    to: chatId,
    type: 'template',
    template: {
      name: 'order_status_update',
      language: { code: 'pt_BR' },
      components: [
        {
          type: 'body',
          parameters: [
            { type: 'text', text: orderId },
            { type: 'text', text: orderStatus },
          ],
        },
      ],
    },
  };

  const body = JSON.stringify(payload);
  const response = await fetch(endpoint, { method: 'POST', headers, body });
  return response;
}

Veja também

DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.