Node é a linguagem em que mais gente escreve integração de WhatsApp, e por um motivo simples: desde o Node 18 o fetch vem embutido, então uma chamada à API do zapon não precisa de nenhum pacote instalado. O trabalho de verdade não está em montar a requisição — está em tratar o que volta, receber o webhook sem travar o event loop e não transformar um for de mil clientes numa rajada.
Este guia vai do token até o fluxo de duas vias: envio de texto, imagem e botões, verificação de número, um receptor de webhook em Express e o tratamento de erro por código HTTP. Os exemplos usam ESM (import) e rodam em Node 18 ou superior, sem transpilação.
O que você precisa antes da primeira linha
O que preciso para chamar a API do zapon em Node.js?
Node 18 ou superior, uma conta no zapon e um número conectado. Da conexão sai o token, que vai no header de toda chamada — um token por número. Não há SDK para instalar: a API é REST com JSON, e o fetch global do Node resolve. Para o webhook você precisa de um endereço público que aceite POST; em desenvolvimento, um túnel para a sua porta local serve.
- Runtime. Node 18 trouxe
fetch, FormData e AbortSignal.timeout como globais. Em Node 16 nada disso existe e você precisaria de um cliente HTTP externo — atualizar sai mais barato. - Conexão e token. No painel, crie a conexão e leia o QR Code no WhatsApp do número (Aparelhos conectados → Conectar aparelho). Copie o
token daquela conexão: é ele que autentica as chamadas daquele número, e só daquele. - Base da API.
https://api.zapon.dev, sempre por HTTPS.
Onde guardar o token num projeto Node?
Em variável de ambiente, lida no servidor — nunca em constante no código nem em bundle que chega ao navegador. Do Node 20.6 em diante dá para carregar um arquivo .env sem dependência alguma, com a flag --env-file:
carregando o token sem dependência
# .env (fora do git)
ZAPON_TOKEN=cole_aqui_o_token_da_conexao
# rodando
node --env-file=.env app.js
A autenticação é um header só, de nome token. Não é Authorization, não é Bearer e não há OAuth para negociar. Quem tem o token envia mensagens em nome daquele número, então trate-o como senha de banco de dados.
Primeiro envio de texto
Como enviar uma mensagem de WhatsApp com Node.js?
Um POST para https://api.zapon.dev/chat/send/text com o header token, Content-Type: application/json e um corpo com dois campos: Phone, o número só com dígitos incluindo país e DDD, e Body, o texto. A resposta traz data.Id, o identificador da mensagem no WhatsApp — guarde-o junto do registro que originou o envio.
Em vez de espalhar fetch pelo projeto, vale escrever um módulo de três funções. Este é o cliente completo, e todo o resto da página usa a função chamar:
zapon.js — cliente mínimo
// zapon.js — Node 18+, sem dependências
const BASE = "https://api.zapon.dev";
const TOKEN = process.env.ZAPON_TOKEN;
export class ErroZapon extends Error {
constructor(status, detalhe) {
super("zapon " + status + ": " + detalhe);
this.status = status; // 0 = nem chegou na API (rede ou timeout)
this.detalhe = detalhe;
}
}
export async function chamar(rota, payload) {
let resposta;
try {
resposta = await fetch(BASE + rota, {
method: "POST",
headers: { token: TOKEN, "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000) // sem isto a chamada pode ficar pendurada
});
} catch (e) {
throw new ErroZapon(0, e.name === "TimeoutError" ? "timeout" : String(e));
}
const bruto = await resposta.text(); // leia texto: erro de servidor pode não vir em JSON
let json = null;
try { json = JSON.parse(bruto); } catch { /* corpo não-JSON, segue com null */ }
if (!resposta.ok || json?.success !== true) {
throw new ErroZapon(resposta.status, json?.error ?? bruto.slice(0, 200));
}
return json.data;
}
export const enviarTexto = (Phone, Body) => chamar("/chat/send/text", { Phone, Body });
E o uso, num script que roda depois de um pedido ser confirmado:
app.js — primeiro envio
import { enviarTexto, ErroZapon } from "./zapon.js";
try {
const dados = await enviarTexto(
"5511999999999",
"Pedido 4821 confirmado. Assim que o pacote sair, mandamos o rastreio por aqui."
);
console.log("mensagem enviada:", dados.Id);
} catch (e) {
if (e instanceof ErroZapon) console.error("falhou:", e.status, e.detalhe);
else throw e;
}
A resposta de sucesso tem sempre este formato — code, success e um data com o identificador e o horário do envio:
resposta da API
{
"code": 200,
"success": true,
"data": {
"Details": "Sent",
"Id": "90B2F8B13FAC8A9CF6B06E99C7834DC5",
"Timestamp": "2026-09-03T09:12:08-03:00"
}
}
Por que o fetch não lança erro quando a API devolve 401?
Porque o fetch só rejeita a promessa quando a requisição não completa: DNS que não resolve, conexão recusada, timeout do AbortSignal. Uma resposta 401 é uma resposta bem-sucedida do ponto de vista da rede — chegou, com status 401. Quem esquece de olhar resposta.ok escreve um código que nunca percebe token errado e segue gravando "enviado" no banco. É o erro número um de quem vem de bibliotecas que lançam exceção em 4xx. Por isso o cliente acima checa resposta.ok e também json.success.
Preciso de axios ou de undici para isso?
Não. O fetch do Node é implementado sobre o undici, que já está dentro do runtime. Um cliente HTTP externo só se paga se você precisar de recursos que ele traz prontos — interceptors, repetição automática, pool configurável. Para chamadas ao zapon, o ganho é pequeno perto de uma dependência a mais para manter. Se o seu projeto já usa um, use o mesmo: o que não pode é misturar dois clientes HTTP no mesmo serviço.
Imagem e botões
Como enviar uma imagem pela API do zapon em Node?
POST /chat/send/image com Phone, Image e, opcionalmente, Caption. O campo Image é o arquivo em base64 no formato data URI — data:image/jpeg;base64,.... Em Node, isso é readFile seguido de buffer.toString("base64"), sem biblioteca nenhuma.
enviando uma imagem
import { readFile } from "node:fs/promises";
import { chamar } from "./zapon.js";
const bytes = await readFile("./etiqueta-4821.jpg");
const imagem = "data:image/jpeg;base64," + bytes.toString("base64");
await chamar("/chat/send/image", {
Phone: "5511999999999",
Image: imagem,
Caption: "Etiqueta do pedido 4821. Cole na caixa antes de postar."
});
Duas cautelas de memória. Base64 cresce o arquivo em cerca de um terço, e readFile traz tudo para a memória do processo: um lote de fotos grandes em paralelo derruba um contêiner pequeno. Redimensione antes de enviar e processe um arquivo por vez. Se a imagem já está numa URL sua, baixe com fetch e converta com Buffer.from(await r.arrayBuffer()) — o campo espera o conteúdo, não o endereço.
Como mandar botões de resposta rápida?
O POST /chat/send/buttons envia botões. Cada um leva um id definido por você e um text curto — o WhatsApp trunca rótulo longo, então fique em até 20 caracteres. O id é a peça mais valiosa do fluxo: ele volta inteiro no webhook, e é onde você carimba a chave do seu registro. Além da resposta rápida, o mesmo endpoint monta botão que abre um link ("type": "cta_url"), botão que abre o discador ("type": "cta_call") e botão que copia um código ("type": "copy") — e os tipos convivem na mesma mensagem. O toque em resposta rápida volta no webhook; os outros três agem no aparelho de quem recebe.
botões com o pedido carimbado no id
await chamar("/chat/send/buttons", {
Phone: "5511999999999",
Body: "Pedido 4821 sai para entrega hoje. Tem alguém para receber à tarde?",
Footer: "Loja Exemplo",
Buttons: [
{ id: "ped4821|hoje", text: "Pode entregar" }, // 13 caracteres
{ id: "ped4821|amanha", text: "Prefiro amanhã" },
{ id: "ped4821|humano", text: "Falar com alguém" }
]
});
Sem esse carimbo, a resposta chega só com o telefone e você precisa adivinhar de qual pedido ela fala — problema garantido no cliente que tem dois pedidos abertos. Com o id, o webhook faz split("|") e vai direto ao registro. Precisa de mais de três opções? Aí o caminho é o /chat/send/list, que abre um menu.
Verificar o número antes de enviar
Como saber se um número tem WhatsApp antes de disparar?
POST /user/check recebe um array em Phone e devolve, para cada número, o campo IsInWhatsapp. Rodar isso antes de um lote evita queimar chamadas com telefone fixo e cadastro velho — e uma sequência de envios para números inexistentes é justamente o padrão que aumenta o risco do seu número.
filtrando a lista antes do disparo
import { chamar } from "./zapon.js";
const candidatos = ["5511999999999", "5511888888888", "551133334444"];
const { Users } = await chamar("/user/check", { Phone: candidatos });
const validos = Users.filter((u) => u.IsInWhatsapp).map((u) => u.Query);
const fora = Users.filter((u) => !u.IsInWhatsapp).map((u) => u.Query);
console.log(validos); // ["5511999999999"]
await marcarCadastroSemWhatsapp(fora); // corrija a origem, não só o disparo de hoje
Grave o resultado no seu cadastro em vez de reconsultar a cada campanha. E trate fora como tarefa de dados: número sem WhatsApp quase sempre é cadastro com dígito faltando, e isso se corrige uma vez.
Receber mensagens: o webhook em Express
Como receber no Node as respostas que chegam no WhatsApp?
Cadastre no painel, por conexão, uma URL pública e escolha os eventos. Cada evento vira um POST para essa URL com a mensagem inteira no corpo — não há segunda chamada para buscar conteúdo. No Node, o handler deve responder 200 na primeira linha e empurrar o processamento para fora do ciclo da requisição.
O receptor precisa aceitar duas formas de corpo: JSON puro e formulário codificado, com o evento dentro do campo jsonData. Aceitar as duas é uma linha de código e evita o sintoma clássico de "o webhook não chega" — ele chega, o parser é que devolve um objeto vazio.
servidor de webhook — Express
import express from "express";
import { enviarTexto } from "./zapon.js";
const app = express();
const vistos = new Set(); // em produção: Redis, ou uma tabela com o Id da mensagem
app.post("/whatsapp/eventos",
express.json({ limit: "10mb" }), // eventos de mídia e sincronização são grandes
express.urlencoded({ extended: true }), // o corpo também pode vir como formulário
(req, res) => {
res.sendStatus(200); // responda ANTES de qualquer await
setImmediate(() => tratar(corpo(req)).catch((e) => console.error("webhook:", e)));
}
);
function corpo(req) {
const b = req.body ?? {};
if (typeof b.jsonData === "string") return JSON.parse(b.jsonData);
return b;
}
async function tratar(evento) {
if (evento?.type !== "Message") return; // ignore o que o seu fluxo não usa
const info = evento.event?.Info ?? {};
if (info.IsFromMe || info.IsGroup) return; // o que você mesmo enviou volta como evento
if (vistos.has(info.ID)) return;
vistos.add(info.ID);
const telefone = String(info.Chat || "").split("@")[0];
const msg = evento.event?.Message ?? {};
const botao = msg.buttonsResponseMessage?.selectedButtonID ?? "";
const texto = (msg.conversation ?? msg.extendedTextMessage?.text ?? "").trim();
if (botao) {
const [pedido, acao] = botao.split("|");
await registrarEscolha(pedido, acao);
return enviarTexto(telefone, acao === "hoje"
? "Combinado. A entrega sai hoje à tarde."
: "Certo, remarcamos para amanhã.");
}
await filaDeAtendimento(telefone, texto); // o que a regra não entende é assunto de gente
}
app.listen(3000, () => console.log("ouvindo em :3000"));
Qual é o formato do evento de mensagem recebida?
O corpo traz o tipo do evento e a mensagem completa. Assim chega quem respondeu por escrito:
payload do evento Message
{
"type": "Message",
"event": {
"Info": {
"ID": "3EB0C767D26A1B5F7C83",
"Chat": "[email protected]",
"Sender": "[email protected]",
"IsFromMe": false,
"IsGroup": false,
"PushName": "Ana Souza",
"Timestamp": "2026-09-03T18:04:22-03:00"
},
"Message": { "conversation": "pode entregar sim" }
}
}
// quando ele toca num botão, muda só o bloco Message:
"Message": { "buttonsResponseMessage": {
"selectedButtonID": "ped4821|hoje",
"Response": { "SelectedDisplayText": "Pode entregar" }
} }
Por que responder 200 antes de processar?
Porque a entrega do evento tem janela curta: um handler que consulta banco, chama outra API e só então devolve status transforma cada evento numa espera. Em Node isso é fácil de acertar — res.sendStatus(200) e, na linha seguinte, setImmediate para o trabalho. Se o volume crescer, troque o setImmediate por uma fila de verdade, mas mantenha a regra: responder e devolver o controle. Vale ainda registrar quem enviou o POST; as entregas chegam com o cabeçalho User-Agent: zapon-webhook/1, o que ajuda a separar o tráfego real de varredura na sua porta.
Tratamento de erro por código HTTP
O que fazer em cada erro da API?
400 é corpo inválido e você conserta no código, não repetindo. 401 é token errado ou de outra conexão. 404 é caminho digitado errado. 500 costuma ser conexão fora do ar, e é o único caso em que repetir faz sentido — com espera crescente. Repetir um 400 ou um 401 só gasta chamada.
| Código | Significa | O que fazer em Node |
|---|
200 | Requisição aceita | Grave data.Id junto do seu registro e siga |
400 | Corpo inválido | Loga o payload e corrige: campo obrigatório faltando ou Phone fora do formato |
401 | Token ausente ou inválido | Verifique se process.env.ZAPON_TOKEN chegou preenchido — variável não carregada vira undefined no header |
404 | Endpoint inexistente | Confira a rota; um /chat/send/txt cai aqui |
500 | Erro ao processar | Repita com espera crescente e, se persistir, veja o estado da conexão no painel |
Em código, isso vira uma função de decisão em cima do ErroZapon — repetir só o que vale a pena repetir:
repetição seletiva com espera crescente
import { chamar, ErroZapon } from "./zapon.js";
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
export async function chamarComRetentativa(rota, payload, tentativas = 3) {
for (let i = 1; ; i++) {
try {
return await chamar(rota, payload);
} catch (e) {
const temporario = e instanceof ErroZapon && (e.status === 0 || e.status >= 500);
if (!temporario || i >= tentativas) throw e; // 400 e 401 não melhoram com insistência
await dormir(1000 * 2 ** (i - 1)); // 1s, 2s, 4s
}
}
}
Um detalhe que economiza plantão: se o processo é um worker, deixe a falha definitiva virar registro visível — uma linha numa tabela de pendências — em vez de um console.error que ninguém lê. Mensagem que não saiu precisa virar tarefa.
Ritmo de envio e preservação do número
Node facilita demais o disparo em paralelo, e é aí que mora o risco. Um Promise.all sobre mil clientes manda mil mensagens em segundos — padrão que não se parece com nenhum uso humano. Prefira serializar com intervalo, e aceite que o lote demore:
um por vez, com intervalo
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = () => 3000 + Math.floor(Math.random() * 4000); // 3s a 7s
for (const cliente of await pendentesDeHoje()) {
if (cliente.optOut) continue; // quem pediu para sair, sai de todas as rotinas
try {
const { Id } = await enviarTexto(cliente.telefone, textoPara(cliente));
await registrarEnvio(cliente.id, Id); // guardar o Id evita mandar duas vezes
} catch (e) {
await registrarFalha(cliente.id, String(e));
}
await dormir(jitter());
}
- Fale com quem espera. Mensagem sobre um pedido que a pessoa fez é esperada; lista comprada é denúncia esperando acontecer, e denúncia é o que pesa.
- Intervalo com variação. Um
setTimeout de exatos 2.000 ms mil vezes é tão artificial quanto o disparo em rajada. O jitter acima custa nada. - Aqueça número novo. Comece com poucos envios por dia e aumente ao longo de semanas. Número recém-criado que estreia em volume chama atenção.
- Descadastro que funciona. Guarde o
optOut no cadastro e faça toda rotina consultá-lo antes de enviar. O pedido chega em qualquer palavra, então alguém precisa olhar a fila de exceções.
Sobre bloqueio de número, sem promessa mágica. Nenhuma API — nem a oficial da Meta — impede que o WhatsApp aja contra um número denunciado. O risco não se elimina, se administra: falar com quem espera, manter volume e ritmo compatíveis com o seu negócio, responder quem responde e parar de enviar para quem pediu para sair. Quem promete que o número nunca será bloqueado não está sendo honesto.
Sobre custo, o cálculo em Node é o mesmo de qualquer linguagem: R$ 27 por mês por número conectado, sem cobrança por mensagem, dentro da cota de 300 mensagens por dia por número — cerca de 9.000 por mês. O teste é de 14 dias sem cartão e só começa a contar na primeira conexão — dá para escrever a integração inteira, com testes, antes de gastar o primeiro dia.
Perguntas frequentes
Preciso de algum pacote npm para usar a API do zapon?
Não. Do Node 18 em diante o fetch é global e resolve todas as chamadas. Você só instala algo se quiser um servidor HTTP para o webhook — e mesmo aí o módulo node:http nativo dá conta; o Express aparece nos exemplos por ser o que a maioria dos projetos já tem.
Funciona em TypeScript?
Funciona sem adaptação. Vale declarar um tipo para a resposta ({ code: number; success: boolean; data: { Id: string; Timestamp: string } }) e outro para o evento do webhook, lembrando que o bloco Message muda de forma conforme o tipo da mensagem recebida — modele-o com campos opcionais em vez de um formato único.
Dá para rodar em serverless, como funções em edge?
Dá, e é um bom encaixe para o receptor do webhook. Só atenção a dois pontos: leia o corpo como texto e faça o parse você mesmo, porque nem todo runtime traz um parser de formulário, e responda 200 antes de iniciar o trabalho pesado — em runtimes com waitUntil, é ele quem segura o processamento depois da resposta.
O fetch do Node reaproveita conexão entre as chamadas?
Sim, o agente padrão mantém conexões vivas, o que é ótimo para um laço de envios. O que não existe é repetição automática: qualquer nova tentativa é código seu, como a função de espera crescente mostrada acima.
Como testo o webhook sem publicar o serviço?
Suba o servidor local, exponha a porta com um túnel HTTP e cadastre a URL do túnel na conexão. Para testar só a sua regra, sem depender de mensagem real, faça um POST no seu próprio endpoint com o payload de exemplo desta página — é o mesmo formato que chega em produção.
Uma conexão só atende vários números?
Não: um token é um número. Para operar vários, guarde um token por conexão e escolha qual usar na hora da chamada — o cliente do exemplo aceita isso trocando a constante por um parâmetro. Cada número conectado custa R$ 27 por mês, sem cobrança por mensagem.
Preciso da API oficial da Meta ou de aprovação de template?
Não. O número é conectado por QR Code ou código de pareamento, sem fila de aprovação e sem cadastro prévio de mensagem. Em troca, a conduta do número é responsabilidade de quem integra — por isso a seção de ritmo desta página não é decoração.