Atualizado em 28/10/2026
Para criar um bot de WhatsApp com Node.js na API oficial, você precisa de um servidor Express com HTTPS e duas rotas: um GET que devolve o desafio de verificação da Meta e um POST que recebe as mensagens, valida a assinatura X-Hub-Signature-256 sobre o corpo bruto (com express.raw) e responde pelo endpoint /messages da Cloud API usando o fetch nativo. Sem SDK e sem dependências além do Express, tudo cabe em um arquivo de cerca de 150 linhas.
O bot final responde “oi” ou “menu” com três botões, trata o toque em cada botão, marca a mensagem como lida, ignora eventos repetidos e roda em produção com Docker e HTTPS automático. O código foi testado com Express 5.3 e Node.js 26, e funciona direto na Meta ou pela Vectalk, mudando só duas variáveis de ambiente. Se você prefere Python, o mesmo bot existe em versão FastAPI.
Como o bot funciona
- O cliente manda uma mensagem para o seu número.
- A Meta envia um
POSTcom o evento para a URL do seu webhook. - O servidor valida a assinatura, responde
200na hora e processa o evento depois. - O bot chama
POST /<PHONE_NUMBER_ID>/messagespara responder.
Como a resposta sai logo depois da mensagem do cliente, ela está dentro da janela de 24 horas e pode ser texto livre ou mensagem interativa, sem template. Para a visão geral de como acessar a API, veja o guia da API oficial do WhatsApp.
O que você precisa antes
| Variável | O que é | Onde encontrar |
|---|---|---|
PHONE_NUMBER_ID | ID do número na Cloud API | Painel de Apps, na seção do WhatsApp |
ACCESS_TOKEN | Token com permissão whatsapp_business_messaging | Token de usuário do sistema |
APP_SECRET | Chave secreta do app, usada na assinatura | Configurações do app > Básico |
VERIFY_TOKEN | Texto qualquer escolhido por você | Você define e repete no painel |
Use um token permanente de usuário do sistema, porque o token temporário do painel expira. O número também precisa estar registrado na Cloud API, com o PIN de duas etapas definido.
No ambiente, você precisa de Node.js 18 ou mais recente, que é a versão mínima exigida pelo Express 5 e já traz o fetch global, sem precisar de axios.
Quer testar enquanto lê? Crie sua conta na Vectalk e envie a primeira mensagem pela API oficial. Criar conta grátis →
1. Prepare o projeto
mkdir bot-whatsapp && cd bot-whatsapp
npm init -y
npm pkg set type=module
npm install express@5
O type=module permite usar import. Crie o arquivo server.js. Os blocos de código das seções 1 a 5 formam o arquivo completo: cole cada um em sequência.
import crypto from 'node:crypto';
import express from 'express';
const {
VERIFY_TOKEN,
ACCESS_TOKEN,
APP_SECRET = '',
WEBHOOK_KEY = '',
PHONE_NUMBER_ID = '',
PORT = 3000,
} = process.env;
const API_URL =
process.env.API_URL ?? `https://graph.facebook.com/v26.0/${PHONE_NUMBER_ID}/messages`;
const app = express();
const processed = new Set(); // em produção, use Redis ou o banco
API_URL usa a Graph API da Meta como padrão. Para usar a Vectalk, você troca essa variável e o token, como mostra a seção sobre a Vectalk.
2. Verificação do webhook (GET)
Quando você salva a URL de callback no painel, a Meta manda um GET com hub.mode=subscribe, hub.verify_token e hub.challenge. Se o token bater, responda 200 com o valor de hub.challenge. Qualquer outra resposta deixa o endpoint não verificado, e a Meta não envia eventos.
app.get('/webhook', (req, res) => {
const mode = req.query['hub.mode'];
const token = req.query['hub.verify_token'];
if (mode === 'subscribe' && token === VERIFY_TOKEN) {
return res.status(200).send(req.query['hub.challenge']);
}
res.sendStatus(403);
});
3. Validar a assinatura com express.raw
Todo POST da Meta traz X-Hub-Signature-256: sha256=<hash>, um HMAC-SHA256 do corpo calculado com a chave secreta do app. O hash precisa ser calculado sobre os bytes exatos que chegaram. Se o express.json() já transformou o corpo em objeto, JSON.stringify não devolve os mesmos bytes, e a assinatura nunca bate.
A solução é usar express.raw só na rota do webhook. Segundo a documentação do Express, esse middleware coloca o corpo em req.body como Buffer, mas apenas quando o Content-Type combina com a opção type, cujo padrão é application/octet-stream. Como a Meta envia application/json, é preciso informar type: 'application/json'.
function signatureOk(rawBody, header = '') {
if (!APP_SECRET) return true;
const expected =
'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
crypto.timingSafeEqual compara em tempo constante, mas lança erro se os buffers tiverem tamanhos diferentes. Por isso o código checa o tamanho antes.
Três erros comuns nessa etapa:
app.use(express.json())global antes da rota. O corpo já chega convertido e oBuffersome. Registre oexpress.json()depois da rota do webhook ou só nas rotas que precisam dele.typeerrado noexpress.raw. Com o padrão,req.bodyficaundefinedpara requisições JSON.- Chave errada. A assinatura usa a chave secreta do app, não o token de acesso nem o
VERIFY_TOKEN.
O artigo sobre validar a assinatura do webhook aprofunda o tema.
4. Receber o evento e responder rápido
A Meta espera 200 rápido. Se a entrega falha, ela tenta de novo com frequência cada vez menor por até 7 dias, então o mesmo evento pode chegar mais de uma vez. O código responde antes de processar e ignora IDs de mensagem já vistos.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (WEBHOOK_KEY && req.query.key !== WEBHOOK_KEY) return res.sendStatus(403);
if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
if (!signatureOk(req.body, req.get('x-hub-signature-256'))) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // responde já e processa depois
processPayload(payload).catch((err) => console.error('falha no processamento', err));
});
async function processPayload(payload) {
for (const entry of payload.entry ?? []) {
for (const change of entry.changes ?? []) {
for (const msg of change.value?.messages ?? []) {
if (processed.has(msg.id)) continue; // a Meta pode reenviar o mesmo evento
processed.add(msg.id);
try {
await handle(msg);
} catch (err) {
console.error(`falha ao responder ${msg.id}`, err.message);
}
}
}
}
}
O res.sendStatus(200) sai antes de processPayload, que roda sem bloquear a resposta. Eventos de status (sent, delivered, read, failed) chegam no mesmo webhook, em value.statuses, e este bot os ignora. Para tratá-los, veja status de mensagem via webhook.
5. Responder texto e botões interativos
A lógica fica em handle. Texto “oi” ou “menu” abre o menu; o toque em um botão chega como type: "interactive" com interactive.type: "button_reply" e o id definido no envio.
const ANSWERS = {
horario: 'Atendemos de segunda a sexta, das 9h às 18h.',
precos: 'Veja a tabela completa em https://example.com/precos',
humano: 'Certo! Um atendente vai continuar a conversa por aqui.',
};
async function handle(msg) {
const dest = recipientOf(msg);
await markRead(msg.id);
if (msg.type === 'text') {
const text = msg.text.body.trim().toLowerCase();
if (['oi', 'olá', 'ola', 'menu'].includes(text)) return sendMenu(dest);
return sendText(dest, 'Não entendi. Digite *menu* para ver as opções.');
}
if (msg.type === 'interactive' && msg.interactive.type === 'button_reply') {
const choice = msg.interactive.button_reply.id;
return sendText(dest, ANSWERS[choice] ?? 'Opção inválida.');
}
return sendText(dest, 'Por enquanto eu entendo só texto e botões.');
}
function recipientOf(msg) {
// Usuários com nome de usuário podem chegar sem "from", só com o BSUID
return msg.from ? { to: msg.from } : { recipient: msg.from_user_id };
}
A função recipientOf prepara o bot para os nomes de usuário do WhatsApp. Segundo a documentação da Meta, quando o cliente adota um nome de usuário, o campo from pode faltar no webhook, mas o from_user_id (BSUID) vem sempre, e desde julho de 2026 é possível enviar mensagens para ele no campo recipient. Detalhes em usernames e BSUID.
E as funções que chamam a API com o fetch nativo:
async function callApi(payload) {
const r = await fetch(API_URL, {
method: 'POST',
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),
});
const body = await r.json();
if (!r.ok) throw new Error(`API ${r.status}: ${JSON.stringify(body)}`);
return body;
}
async function markRead(messageId) {
try {
await callApi({ status: 'read', message_id: messageId, typing_indicator: { type: 'text' } });
} catch {
// não impede a resposta
}
}
function sendText(dest, text) {
return callApi({ recipient_type: 'individual', ...dest, type: 'text', text: { body: text } });
}
function sendMenu(dest) {
const buttons = [
['horario', 'Horário'],
['precos', 'Preços'],
['humano', 'Falar com atendente'],
];
return callApi({
recipient_type: 'individual',
...dest,
type: 'interactive',
interactive: {
type: 'button',
body: { text: 'Olá! Como posso ajudar?' },
action: {
buttons: buttons.map(([id, title]) => ({ type: 'reply', reply: { id, title } })),
},
},
});
}
app.listen(PORT, () => console.log(`Bot ouvindo na porta ${PORT}`));
markRead marca a mensagem como lida e mostra “digitando…” para o cliente; a Meta remove o indicador quando você responde ou depois de 25 segundos. Um detalhe do fetch: ele não lança erro em respostas 4xx e 5xx, por isso o código confere r.ok e lança o erro com o corpo da Meta, que traz o código para diagnóstico.
Os limites dos botões de resposta, segundo a documentação da Meta:
| Campo | Limite |
|---|---|
| Botões por mensagem | até 3 |
title do botão | 20 caracteres |
id do botão | 256 caracteres |
body.text | 1.024 caracteres |
footer.text | 60 caracteres |
O toque no botão chega assim no webhook (trecho de messages[0]):
{
"from": "5511999999999",
"id": "wamid.HBgL...",
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": { "id": "precos", "title": "Preços" }
}
}
Listas com até 10 opções e outros formatos estão em mensagens interativas.
6. Testar localmente
export VERIFY_TOKEN="um-texto-qualquer"
export ACCESS_TOKEN="EAAG..."
export APP_SECRET="sua-chave-secreta"
export PHONE_NUMBER_ID="123456789012345"
node server.js
Em outro terminal, teste a verificação e um evento assinado sem depender da Meta:
curl "http://127.0.0.1:3000/webhook?hub.mode=subscribe&hub.verify_token=um-texto-qualquer&hub.challenge=42"
BODY='{"entry":[{"changes":[{"value":{"messages":[{"from":"5511999999999","id":"wamid.teste1","type":"text","text":{"body":"menu"}}]}}]}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" | awk '{print $NF}')
curl -i -X POST http://127.0.0.1:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$SIG" \
--data-binary "$BODY"
O primeiro comando devolve 42. O segundo devolve 200 OK e faz o bot enviar o menu para o número em from (use o seu número de teste). Troque um caractere do BODY sem recalcular a assinatura e a resposta passa a ser 401.
Para eventos reais, a Meta exige URL pública com certificado TLS válido. Em desenvolvimento, use um túnel, como mostra o guia testar o webhook localmente com ngrok. Depois vá em Painel de Apps > WhatsApp > Configuração, preencha a URL de callback (https://.../webhook) e o token de verificação, salve e assine o campo messages.
7. Deploy simples com Docker e HTTPS
O npm install já criou o package-lock.json. Crie o Dockerfile:
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js .
ENV PORT=3000
CMD ["node", "server.js"]
Suba o container no servidor:
docker build -t bot-whatsapp .
docker run -d --restart unless-stopped -p 127.0.0.1:3000:3000 \
-e VERIFY_TOKEN -e ACCESS_TOKEN -e APP_SECRET -e PHONE_NUMBER_ID \
bot-whatsapp
Para o HTTPS, o Caddy obtém e renova o certificado sozinho quando o DNS do domínio aponta para o servidor e as portas 80 e 443 estão abertas. O Caddyfile:
bot.suaempresa.com.br
reverse_proxy 127.0.0.1:3000
Atualize a URL de callback no painel para https://bot.suaempresa.com.br/webhook.
Checklist antes de colocar em produção
- Deduplicação persistente. O
Setem memória some a cada reinício e não é compartilhado entre instâncias. Guarde os IDs no Redis ou no banco, com expiração. - Fila. Se a resposta depende de IA ou de sistemas lentos, mova o processamento para uma fila (BullMQ, RabbitMQ, SQS) com worker separado. Um processo que cai no meio do
processPayloadperde o evento, porque o200já foi enviado. - Uma instância por enquanto. Com estado em memória, não rode em cluster. Ao escalar, mova o estado para fora do processo.
- Erros da API. O código registra o corpo de erro da Meta; a tabela de erros da API do WhatsApp ajuda a interpretar.
- Segredos fora do código. Token e chave secreta só em variáveis de ambiente ou cofre de segredos.
- Mídia. Imagens, áudios e documentos chegam no webhook só com um ID; o download é uma chamada separada à API de mídia.
Como fazer com a Vectalk
A Vectalk é um proxy da WhatsApp Cloud API oficial: os payloads de envio e os eventos de webhook são os mesmos da Meta, então o server.js funciona sem mudar a lógica. Mudam três coisas:
- URL e token. Defina
API_URL=https://apis.vectalk.com.br/api/messagese use emACCESS_TOKENum token de instância da Vectalk. Com ele, ophone_number_idé resolvido automaticamente. - Cadastro do webhook por API. Registre a URL com
POST https://apis.vectalk.com.br/api/webhooks, cominstance_id,urlename. Você pode ter várias URLs por instância. - Proteção do endpoint. A documentação de webhooks da Vectalk não descreve um cabeçalho de assinatura. Deixe
APP_SECRETvazio, definaWEBHOOK_KEYcom um valor aleatório longo e cadastre a URL comohttps://bot.suaempresa.com.br/webhook?key=SEU_VALOR.
const r = await fetch('https://apis.vectalk.com.br/api/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VECTALK_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
instance_id: 'SUA_INSTANCIA',
url: 'https://bot.suaempresa.com.br/webhook?key=SEU_VALOR',
name: 'Bot Node.js',
}),
});
console.log(await r.json());
A Vectalk espera 200 em menos de 20 segundos e reenvia o evento se não receber, o que a deduplicação já cobre. Crie sua conta em apps.vectalk.com.br/register e veja os outros endpoints na documentação. Para colocar IA nas respostas, o tutorial ChatGPT na API oficial do WhatsApp parte de uma base parecida.
Perguntas frequentes
Preciso de uma biblioteca de WhatsApp para Node.js?
Não. A Cloud API é HTTP com JSON: Express para receber e fetch para enviar bastam. Bibliotecas que automatizam o WhatsApp Web, como Baileys, são outra coisa: não usam a API oficial da Meta.
Por que a assinatura nunca bate?
Quase sempre é o corpo: algum middleware já converteu o JSON antes do express.raw, ou o type não é application/json. Confira também se a chave é a chave secreta do app.
O bot pode iniciar a conversa?
Texto livre e botões só funcionam dentro da janela de 24 horas aberta pela última mensagem do cliente. Fora dela, é preciso enviar um template aprovado.
Quanto custa manter um bot respondendo?
Respostas dentro da janela de atendimento são mensagens de serviço. Desde 1º de outubro de 2026, cada número tem franquia de 1.000 mensagens de serviço por mês; a partir da 1.001ª, a tarifa para destinatários no Brasil é de R$ 0,0350 por mensagem.
Posso usar TypeScript ou Fastify?
Pode. A sequência não muda: responder ao GET de verificação, validar o HMAC sobre o corpo bruto, devolver 200 rápido e chamar o endpoint de mensagens. No Fastify, garanta acesso ao corpo bruto antes que o JSON seja convertido.
Fontes oficiais consultadas em 28/10/2026: Create a webhook endpoint (Meta), Webhooks overview (Meta), Interactive reply buttons messages (Meta), Interactive messages webhook (Meta), Typing indicators (Meta), Business-scoped user IDs (Meta), Preços (Meta), Express 5: express.raw(), Express 5.x API, Caddy: reverse proxy quick-start, Vectalk: webhooks, Vectalk: autenticação, Vectalk: botões de resposta.
Teste a API oficial do WhatsApp hoje
Crie sua conta, gere o token e envie a primeira mensagem seguindo o guia da documentação. Mesmos payloads e webhooks da Cloud API da Meta.
