Webhook de WhatsApp e SMS com Node.js: mensagens e status de entrega

Capa do artigo Webhook de WhatsApp e SMS com Node.js: mensagens e status de entrega

Um webhook de WhatsApp é a forma mais barata de saber o que acontece depois do envio: se a mensagem foi entregue, se foi lida, se o cliente respondeu e se o dispositivo caiu. Em vez de o seu sistema perguntar à API a cada poucos segundos, a APIBrasil faz um POST na sua URL no momento em que o evento ocorre. O mesmo vale para SMS, com um formato de evento diferente.

Este guia monta um servidor Express em Node.js que recebe os dois tipos de evento, separa mensagem recebida de status de entrega e trata segurança (assinatura HMAC), idempotência (eventos duplicados) e fila. Os nomes de campos vêm da documentação de webhooks em doc.apibrasil.io; onde a doc deixa algo em aberto, o texto avisa.

Você precisa de Node.js 20 ou superior (pelo fetch nativo), uma conta na APIBrasil com um dispositivo WhatsApp e, para o SMS, créditos na API de SMS Avulso.

Por que usar webhooks em vez de polling

Polling é chamar a API repetidamente perguntando “mudou alguma coisa?”. Funciona para um protótipo, mas escala mal: a maior parte das requisições volta vazia, e o atraso entre o evento e a sua reação é o intervalo do loop. Webhook inverte a direção: quem tem a informação avisa quem precisa dela.

CritérioPollingWebhook
LatênciaIgual ao intervalo de consultaSegundos após o evento
RequisiçõesContínuas, mesmo sem novidadeUma por evento
ComplexidadeUm loop simplesServidor público, segurança e fila
RiscoPerder eventos entre consultasReceber duplicados ou fora de ordem

O preço do webhook é operacional: URL pública, resposta rápida e tolerância a repetição. Se os fundamentos ainda não estão claros, o artigo sobre webhooks na prática com Node.js do Programador Brasil explica o modelo de forma geral; aqui o foco é o formato específico da APIBrasil.

Configurando o webhook de WhatsApp e SMS na APIBrasil

WhatsApp e SMS registram a URL em lugares diferentes. No WhatsApp WPP, as URLs ficam no dispositivo e são informadas ao iniciar a sessão. No SMS, a URL vai no corpo de cada envio.

WhatsApp: quatro URLs no dispositivo

O POST /whatsapp/start aceita quatro campos de webhook, cada um para uma família de eventos: wh_message (mensagens, status de entrega, chamadas e eventos de chat), wh_status (estado da conexão), wh_connect (ciclo de vida da sessão) e wh_qrcode (QR Code e código de pareamento). Você pode apontar todos para o mesmo servidor em rotas distintas.

// iniciar-dispositivo.mjs — registra as URLs de webhook ao iniciar a sessão
const BASE = 'https://gateway.apibrasil.io/api/v2';
const PUBLIC_URL = process.env.PUBLIC_URL; // ex.: https://abcd-1234.ngrok-free.app

const resposta = await fetch(`${BASE}/whatsapp/start`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.APIBRASIL_TOKEN}`,
    DeviceToken: process.env.APIBRASIL_DEVICE_TOKEN,
  },
  body: JSON.stringify({
    session: 'atendimento',
    qrcode: true,
    wh_message: `${PUBLIC_URL}/webhooks/whatsapp`,
    wh_status: `${PUBLIC_URL}/webhooks/whatsapp/status`,
    wh_connect: `${PUBLIC_URL}/webhooks/whatsapp/conexao`,
    wh_qrcode: `${PUBLIC_URL}/webhooks/whatsapp/qrcode`,
  }),
});

const corpo = await resposta.json();
if (!resposta.ok || corpo.error) throw new Error(corpo.message); // erro pode vir com HTTP 200
console.log(corpo);

Além das URLs do dispositivo, o serviço tem um webhook global; quando os dois estão configurados, cada evento é postado nos dois destinos. Se você apontar ambos para a mesma rota, vai receber tudo em dobro, e é por isso que a deduplicação da seção de segurança não é opcional.

SMS: webhook_url em cada envio

A API de SMS Avulso recebe a URL no próprio POST /sms/send/credits, no campo webhook_url. A URL é repassada à operadora, que notifica o seu servidor a cada mudança de status e, se user_reply estiver ativo, quando o destinatário responder.

await fetch(`${BASE}/sms/send/credits`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.APIBRASIL_TOKEN}`,
  },
  body: JSON.stringify({
    number: '5531999999999',
    message: 'Seu código de acesso é 482913. Ele expira em 5 minutos.',
    user_reply: true,
    webhook_url: `${PUBLIC_URL}/webhooks/sms`,
  }),
});

Se o seu caso é verificação por SMS, o tutorial de verificação por SMS com OTP na APIBrasil cobre a parte de geração e expiração do código; o webhook daqui entra para saber se o SMS chegou à operadora e se houve resposta.

Servidor Express recebendo eventos

Três decisões moldam o servidor: guardar o corpo bruto da requisição (necessário para validar a assinatura HMAC), responder 200 antes de processar (a APIBrasil espera no máximo 10 segundos) e tratar o corpo como array, porque tanto o WhatsApp quanto o SMS enviam uma lista com um evento dentro.

// servidor.mjs
import express from 'express';
import crypto from 'node:crypto';
import { fila } from './fila.mjs';

const app = express();

// Guarda o corpo cru em req.rawBody para a verificação da assinatura
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

function assinaturaValida(req) {
  const recebida = req.get('x-webhook-signature'); // formato: sha256=<hmac>
  const chave = process.env.APIBRASIL_SESSION_KEY;
  if (!recebida) return !chave; // sem chave configurada, o header não é enviado
  const esperada = 'sha256=' + crypto.createHmac('sha256', chave).update(req.rawBody).digest('hex');
  return recebida.length === esperada.length &&
    crypto.timingSafeEqual(Buffer.from(recebida), Buffer.from(esperada));
}

app.post('/webhooks/whatsapp', (req, res) => {
  if (!assinaturaValida(req)) return res.sendStatus(401);
  const eventos = Array.isArray(req.body) ? req.body : [req.body];
  res.sendStatus(200); // responde primeiro, processa depois
  for (const ev of eventos) fila.adicionar({ canal: 'whatsapp', evento: ev });
});

app.post('/webhooks/whatsapp/:tipo', (req, res) => {
  // status, conexao e qrcode chegam aqui
  const eventos = Array.isArray(req.body) ? req.body : [req.body];
  res.sendStatus(200);
  for (const ev of eventos) fila.adicionar({ canal: 'whatsapp', evento: ev });
});

app.post('/webhooks/sms', (req, res) => {
  if (!Array.isArray(req.body) || req.body.length === 0) return res.sendStatus(400);
  res.sendStatus(200);
  for (const ev of req.body) fila.adicionar({ canal: 'sms', evento: ev });
});

app.listen(3000, () => console.log('Webhooks ouvindo na porta 3000'));

Nenhuma rota faz trabalho pesado dentro do handler: consultar banco, chamar outra API ou responder o cliente acontece no consumidor da fila. Se o handler demorar, a APIBrasil conta como falha e, dependendo da configuração, tenta de novo ou descarta o evento.

Infográfico com os eventos que chegam pelo webhook de WhatsApp e SMS da APIBrasil: mensagem recebida, status de entrega, falha de envio, conexão do dispositivo e reprocessamento seguro

Mensagem recebida x status de entrega

Os eventos do webhook de WhatsApp chegam identificados pelo campo wook, não por um campo event. Os dois que mais importam em um fluxo de atendimento ou notificação são RECEIVE_MESSAGE e MESSAGE_STATUS, e eles têm formatos parecidos o bastante para confundir.

Evento (wook)URLCampos principaisSignificado
RECEIVE_MESSAGEwh_messagefrom, name, content, type, id, isGroupMsg, datetimeAlguém mandou mensagem; status vem como RECEIVED
SEND_MESSAGEwh_messageto, content, id, fromMe: trueUma mensagem saiu do seu número
MESSAGE_STATUSwh_messageid, status, data.ack, dateTimeEtapa de entrega: SENT, RECEIVED, READ, PLAYED ou falha
STATUS_CONNECTIONwh_statusstate, statusCONNECTED, DISCONNECTED, TIMEOUT, CONFLICT
STATUS_CONNECT / SESSION_REMOVEDwh_connectstate, status, reasonCiclo de vida da sessão; SESSION_REMOVED é terminal
QRCODE / CODEwh_qrcodeqrcode (base64), codeDados para parear o celular

Dois detalhes que custam horas de debug: em RECEIVE_MESSAGE, o campo status indica a direção (RECEIVED ou SENT); em MESSAGE_STATUS, o mesmo nome indica a etapa de entrega. O texto da mensagem está em content, não em body. O id do MESSAGE_STATUS é o mesmo do SEND_MESSAGE, e é por ele que você cruza envio e confirmação.

// consumidor.mjs — trata cada evento já fora do ciclo da requisição
// salvarMensagem, atualizarStatus, alertarEquipe etc. são funções do seu sistema (banco, alertas)
export async function tratarWhatsApp(ev) {
  switch (ev.wook) {
    case 'RECEIVE_MESSAGE':
      if (ev.isGroupMsg || ev.fromMe) return;
      await salvarMensagem({ de: ev.from, nome: ev.name, texto: ev.content, idMsg: ev.id });
      break;
    case 'MESSAGE_STATUS':
      // status: CLOCK, SENT, RECEIVED, READ, PLAYED | FAILED, EXPIRED, INACTIVE...
      await atualizarStatus(ev.id, ev.status, ev.dateTime);
      break;
    case 'STATUS_CONNECTION':
      if (ev.status !== 'CONNECTED') await alertarEquipe(`Dispositivo ${ev.session}: ${ev.status}`);
      break;
    default:
      console.log('Evento ignorado:', ev.wook);
  }
}

export async function tratarSms(ev) {
  // status: inserted_for_processing, valid, sent_to_carrier, reply
  if (ev.status === 'reply') return salvarRespostaSms(ev.id, ev.number, ev.mensagem);
  return atualizarStatusSms(ev.id, ev.status);
}

No SMS não há campo de tipo de evento: tudo é distinguido por status, com quatro valores documentados (inserted_for_processing, valid, sent_to_carrier e reply). Só o evento reply traz o campo mensagem, com o texto que o destinatário respondeu. Os carimbos de tempo podem vir em fusos diferentes entre avisos do mesmo id, então use a ordem de chegada, não created_at, para montar a linha do tempo.

Um caso de uso direto é OTP: ao enviar um código de verificação pelo WhatsApp via API, o MESSAGE_STATUS diz se o código foi entregue e lido; se ficar em SENT por mais de um minuto, a tela de verificação pode oferecer o reenvio por SMS em vez de deixar o usuário esperando sem informação.

Testando com ngrok

Um webhook de WhatsApp só funciona com URL pública, e o seu localhost não é. O ngrok cria um túnel HTTPS temporário para a porta do Express e mostra cada requisição recebida em um inspetor local, onde você vê o payload real.

  1. Suba o servidor: node servidor.mjs (porta 3000).
  2. Em outro terminal, rode ngrok http 3000 e copie a URL https gerada.
  3. Exporte PUBLIC_URL com essa URL e rode node iniciar-dispositivo.mjs para registrar os webhooks.
  4. Mande uma mensagem do seu celular para o número conectado e observe o RECEIVE_MESSAGE chegar.
  5. Envie um texto pela API para o seu próprio número e acompanhe a sequência SEND_MESSAGE, MESSAGE_STATUS (SENT, RECEIVED, READ).
  6. Abra http://127.0.0.1:4040 para inspecionar headers e corpo de cada POST.

A URL do ngrok muda a cada reinício no plano gratuito, e os webhooks do WhatsApp ficam gravados no dispositivo: sempre que a URL mudar, rode o start de novo. Para o SMS, basta usar a nova URL no próximo envio.

Segurança, idempotência e fila

Um endpoint público que aceita qualquer POST atrai eventos forjados, reprocessamento em dobro e fila travada. Três mecanismos resolvem a maior parte.

Assinatura HMAC (WhatsApp) e URL secreta (SMS)

Quando a sessão do dispositivo tem uma chave (sessionkey), a APIBrasil envia o header x-webhook-signature com um HMAC-SHA256 do corpo cru, no formato sha256=<hash>. A verificação precisa usar os bytes originais, por isso o middleware guarda req.rawBody: se você re-serializar o JSON, a assinatura não bate. Confira na documentação do dispositivo onde a chave é definida e guarde-a em variável de ambiente, nunca no código.

O webhook de SMS não é assinado. A defesa é usar um caminho impossível de adivinhar (por exemplo, /webhooks/sms/um-token-longo-aleatorio), servir apenas HTTPS e só agir quando o id do evento corresponder a um SMS que você de fato enviou. O mesmo cuidado de tokens e segredos em produção está detalhado no guia de boas práticas de integração com a APIBrasil em produção.

Idempotência: o mesmo evento vai chegar duas vezes

Com retentativas, webhook global e falhas de rede, duplicatas são questão de tempo. A chave de deduplicação é id + wook no WhatsApp e id + status no SMS, porque cada etapa chega em um POST separado com o mesmo id. Guarde as chaves já processadas em Redis com expiração de alguns dias; um Set em memória serve para desenvolvimento.

Fila: responder em milissegundos, processar com calma

// fila.mjs — fila mínima em memória; em produção use BullMQ/Redis ou SQS
import { tratarWhatsApp, tratarSms } from './consumidor.mjs';

const vistos = new Set(); // em produção: Redis com TTL
const pendentes = [];
let rodando = false;

function chave({ canal, evento }) {
  return canal === 'sms' ? `sms:${evento.id}:${evento.status}` : `wa:${evento.id}:${evento.wook}`;
}

async function processar() {
  if (rodando) return;
  rodando = true;
  while (pendentes.length) {
    const item = pendentes.shift();
    try {
      item.canal === 'sms' ? await tratarSms(item.evento) : await tratarWhatsApp(item.evento);
    } catch (erro) {
      console.error('Falha ao processar evento', chave(item), erro); // registre e siga
    }
  }
  rodando = false;
}

export const fila = {
  adicionar(item) {
    const k = chave(item);
    if (vistos.has(k)) return; // duplicado
    vistos.add(k);
    pendentes.push(item);
    setImmediate(processar);
  },
};

A versão em memória perde eventos se o processo reiniciar. Em produção, troque por uma fila persistente (BullMQ com Redis é a escolha comum em Node) e mantenha o handler HTTP idêntico: validar, responder 200, enfileirar. A retentativa da APIBrasil, quando a fila de retry está ativa no serviço, é de até 3 tentativas a cada 5 segundos; sem ela, um evento que falhou é perdido em silêncio. E os eventos não têm ordem garantida entre si.

Perguntas frequentes

Preciso de um servidor diferente para WhatsApp e SMS?

Não. Um único Express com rotas separadas resolve, como no exemplo. O que muda é o parser: o WhatsApp identifica o tipo pelo campo wook e o SMS pelo campo status. Mantenha consumidores separados para não misturar as regras.

Como sei que uma mensagem foi lida?

Pelo evento MESSAGE_STATUS com status READ (ou PLAYED, no caso de áudio) no webhook wh_message. O campo id é o mesmo do SEND_MESSAGE, então grave esse id na hora do envio e atualize o registro quando o status chegar. Se o destinatário desativou confirmações de leitura, o status pode parar em RECEIVED.

O webhook parou de chegar. Por onde começo?

Verifique o evento STATUS_CONNECTION em wh_status: se o último foi DISCONNECTED ou CONFLICT, o dispositivo precisa ser reconectado. Depois confira se a URL registrada no start ainda é a atual (o ngrok muda a cada reinício) e se o seu servidor está respondendo 200 em menos de 10 segundos.

Posso usar o webhook para responder automaticamente?

Pode, e é a base de qualquer chatbot: ao receber RECEIVE_MESSAGE, o consumidor decide a resposta e chama POST /whatsapp/sendText. Faça isso fora do handler HTTP, na fila, e ignore mensagens com fromMe: true para não responder a si mesmo em loop.

O que acontece se meu servidor ficar fora do ar?

Com a fila de retentativa ativa no serviço, a APIBrasil tenta até 3 vezes com 5 segundos de intervalo; fora dessa janela, o evento é descartado. O histórico continua no aparelho, mas o seu sistema não será avisado. Monitore a disponibilidade do endpoint como faria com qualquer API crítica.

Próximos passos

Com o servidor recebendo eventos, o ciclo fecha: envio, confirmação de entrega e resposta do cliente no mesmo fluxo. Daqui, os caminhos naturais são um chatbot com menu e transbordo humano, ou um painel que cruza SEND_MESSAGE com MESSAGE_STATUS para medir entrega por campanha. Se ainda não tem um dispositivo, crie a conta em platform.apibrasil.com.br, conecte um número de teste e rode o roteiro do ngrok antes de apontar os webhooks para produção.

Loading

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *