InícioDocumentação › API de WhatsApp em Node.js

Como enviar mensagem de WhatsApp com Node.js

Do token ao fluxo de duas vias em Node 18 ou superior: um cliente com o fetch nativo, envio de texto, imagem e botões, verificação de número e um receptor de webhook em Express que responde 200 na primeira linha e processa depois.

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.

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ódigoSignificaO que fazer em Node
200Requisição aceitaGrave data.Id junto do seu registro e siga
400Corpo inválidoLoga o payload e corrige: campo obrigatório faltando ou Phone fora do formato
401Token ausente ou inválidoVerifique se process.env.ZAPON_TOKEN chegou preenchido — variável não carregada vira undefined no header
404Endpoint inexistenteConfira a rota; um /chat/send/txt cai aqui
500Erro ao processarRepita 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());
}
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.

Leia também

Escreva a integração hoje, conecte o número quando quiser.

14 dias grátis, sem cartão — a contagem só começa na primeira conexão. R$ 27 por mês por número, sem cobrança por mensagem.