Voltar para o blog
WhatsAppIntegraçõesBackendWebhooksObservabilidade

5 erros que fazem integrações WhatsApp falharem em produção

13 de maio de 202611 min de leiturapor Sávio Araújo

Quase toda integração com WhatsApp Business API funciona bem na primeira semana. O código passa na revisão, os testes passam, a demonstração para o cliente é bem-sucedida. O problema aparece com volume real: webhooks chegando em rajada, workers reiniciando após um deploy, a Meta retornando 503 num pico de tráfego.

O bug mais comum nesses casos não é um erro de implementação pontual. É uma suposição arquitetural feita cedo demais: que a API vai responder sempre dentro do timeout, que o worker nunca vai reiniciar no meio de um batch, que o webhook vai ser entregue exatamente uma vez.

A maioria das falhas em integrações de mensageria não acontece no envio. Acontece no que o sistema faz quando algo sai do caminho feliz. E na maior parte das vezes, você não sabe que falhou até o cliente ligar perguntando por que não recebeu a mensagem, ou por que recebeu duas vezes.

O que segue são os cinco padrões de falha que aparecem com mais frequência em integrações de WhatsApp em produção. Não são erros de iniciante. São omissões que fazem sentido no início e custam caro quando a escala aumenta.

Erro 1: Retries sem idempotência

Um sistema de cobranças recorrentes enviou a confirmação de pagamento duas vezes para uma parcela dos clientes. O motivo não era um bug óbvio: era um worker que reiniciou durante um deploy, no meio do processamento de um batch. Os eventos já processados foram recolocados na fila e, como não havia controle de duplicidade, a API do WhatsApp recebeu a mesma requisição duas vezes. O cliente recebeu duas confirmações e ligou para o suporte achando que havia sido cobrado em dobro.

A WhatsApp Business API, como qualquer API externa, retorna erros: timeouts, 5xx, conexões resetadas. A resposta natural é implementar retries. O problema é que retry sem idempotência transforma falhas transientes em bugs de dados.

Considere esse cenário:

async function sendMessage(to: string, text: string) {
  const response = await fetch(`${WHATSAPP_API_URL}/messages`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}` },
    body: JSON.stringify({
      messaging_product: "whatsapp",
      to,
      type: "text",
      text: { body: text },
    }),
  });
 
  if (!response.ok) {
    throw new Error(`Falha no envio: ${response.status}`);
  }
}

Se a API retornar 500, você faz retry. Até aqui, correto. O problema é que a API pode ter processado a mensagem e apenas a resposta se perdeu, ou o timeout aconteceu depois que a mensagem foi enfileirada no lado deles. Resultado: o usuário recebe a mensagem duas vezes.

Em notificações operacionais isso é incômodo. Em confirmações de pedido, cobranças ou mensagens de suporte, é um problema de negócio concreto que gera atrito com o cliente e demanda desnecessária para o time de suporte.

A solução é garantir que o controle de idempotência esteja no seu lado, antes de chamar qualquer API externa:

async function processMessageEvent(event: MessageEvent) {
  const alreadyProcessed = await db("message_events")
    .where({ event_id: event.id })
    .first();
 
  if (alreadyProcessed) {
    return; // evento duplicado, ignora
  }
 
  await sendMessage(event.to, event.text);
 
  await db("message_events").insert({
    event_id: event.id,
    sent_at: new Date(),
    status: "sent",
  });
}

Sem isso, qualquer retry no seu sistema (reinício de processo, reprocessamento de fila, bug no consumer) vai gerar duplicidade. E numa integração de mensageria, duplicidade não é um warning: é um incidente de produto.

Erro 2: Falhas silenciosas sem monitoramento

Uma campanha de recuperação de carrinho rodou por quatro horas sem entregar uma única mensagem. Ninguém percebeu até o gestor comercial perguntar por que as conversões não tinham subido. A fila estava travada desde o início da campanha: o worker havia parado de processar por um erro de conexão silencioso, e como não havia alertas configurados, o sistema aparentava estar saudável enquanto acumulava eventos sem processar.

Esse é o erro mais perigoso justamente porque não gera ruído. Não há exceção no log principal, não há erro visível no painel. A integração está aparentemente de pé enquanto falha em silêncio.

Webhooks têm janelas de entrega limitadas. A maioria dos provedores tenta por 24 a 72 horas com backoff exponencial. Se seu sistema não processar a tempo, ou não responder 200 na recepção, o webhook é descartado definitivamente. Não há replay automático. A documentação oficial da Meta detalha esse comportamento e os requisitos de resposta.

A separação entre recepção e processamento é o primeiro passo obrigatório:

app.post("/webhook/whatsapp", async (req, res) => {
  // Responde imediatamente. Nunca processar de forma síncrona aqui
  res.sendStatus(200);
 
  await queue.add("process-whatsapp-event", req.body, {
    jobId: req.body.entry?.[0]?.id, // idempotência na fila
    attempts: 5,
    backoff: { type: "exponential", delay: 2000 },
  });
});

E a fila precisa de um destino explícito para falhas permanentes. Sem uma Dead Letter Queue, eventos que falham repetidamente simplesmente desaparecem sem rastro. A documentação oficial do RabbitMQ sobre Dead Letter Exchanges detalha como estruturar esse fluxo:

const worker = new Worker("process-whatsapp-event", async (job) => {
  await processWebhookEvent(job.data);
});
 
worker.on("failed", (job, error) => {
  logger.error("Evento falhou após todas as tentativas", {
    jobId: job?.id,
    data: job?.data,
    error: error.message,
    attempts: job?.attemptsMade,
  });
 
  dlqQueue.add("dead-letter", {
    originalJob: job?.data,
    failedAt: new Date(),
    reason: error.message,
  });
});

Sem monitoramento ativo da fila (tamanho, taxa de erro, jobs na Dead Letter Queue) você está operando às cegas. Alerta no tamanho da fila e na DLQ não é opcional em integrações de mensageria crítica: é o que separa "eu sei que está funcionando" de "eu acho que está funcionando".

Erro 3: Rate limit mal implementado

No dia do disparo de uma campanha promocional para dezenas de milhares de contatos, a conta foi temporariamente bloqueada pela Meta. O motivo: os workers estavam enviando requisições em rajada, sem controle de throughput, e excederam o limite de mensagens por segundo da conta. A campanha atrasou horas, e o time de marketing perdeu a janela de engajamento que havia planejado com semanas de antecedência.

A WhatsApp Business API impõe limites por número de telefone, por tipo de template e por tier de conta. Esses limites variam conforme o volume histórico da conta e nem sempre são documentados de forma exaustiva.

O erro mais comum não é ignorar o rate limit: é implementá-lo de forma ingênua:

async function sendBulkMessages(contacts: Contact[]) {
  for (const contact of contacts) {
    try {
      await sendMessage(contact.phone, contact.message);
    } catch (error) {
      if (error.status === 429) {
        await sleep(1000);
        await sendMessage(contact.phone, contact.message); // retry imediato
      }
    }
  }
}

O problema com sleep(1000) é que ele não sabe quanto tempo esperar. O header Retry-After da resposta 429 diz exatamente isso, e ignorá-lo significa ou esperar tempo demais (lento) ou tentar cedo demais (novo 429).

Pior: em sistemas com múltiplos workers, cada um fazendo sua própria espera de forma independente, você vai ter rajadas simultâneas ao fim da janela, causando novos bloqueios em cascata. A solução para rate limit em ambiente distribuído não é reagir ao erro: é controlar o throughput na entrada. A documentação do NestJS sobre throttling cobre o módulo nativo para quem já usa o framework:

import Bottleneck from "bottleneck";
 
// Limiter centralizado, compartilhado entre todos os workers
const limiter = new Bottleneck({
  maxConcurrent: 1,
  minTime: 1000 / 80, // 80 mensagens/segundo, abaixo do limite da API
  reservoir: 1000,
  reservoirRefreshAmount: 1000,
  reservoirRefreshInterval: 60 * 1000,
});
 
const sendLimited = limiter.wrap(sendMessage);
 
async function sendBulkMessages(contacts: Contact[]) {
  await Promise.all(contacts.map((c) => sendLimited(c.phone, c.message)));
}

Para múltiplas instâncias, o controle precisa ser centralizado no Redis. Um limiter local não funciona em múltiplos pods: cada instância vai operar no limite máximo de forma independente, multiplicando o throughput real pelo número de réplicas e garantindo o bloqueio que você estava tentando evitar.

Erro 4: Integrações excessivamente síncronas

Durante uma promoção relâmpago, a latência do endpoint de checkout subiu para mais de dois segundos. A equipe olhou para o banco de dados, para a rede, para a infraestrutura. A causa estava num lugar menos óbvio: um timeout na API do WhatsApp sendo aguardado de forma síncrona antes de retornar a resposta ao usuário. O envio da confirmação de compra havia sido colocado no caminho crítico do checkout, e ninguém havia percebido até a latência virar um problema visível.

Esse padrão é comum em sistemas que cresceram de forma orgânica. O que começou como uma chamada simples e pontual se tornou um ponto de falha no caminho crítico da aplicação.

O fluxo problemático:

Request do usuário → API interna → chamada síncrona ao WhatsApp → resposta ao usuário

Se a WhatsApp Business API demorar 800ms (e em dias de instabilidade demora), seu endpoint demora 800ms. Se ela retornar 503, seu endpoint retorna erro. Se ela ficar fora por 5 minutos, seu serviço principal fica degradado junto. Você amarrou a disponibilidade do seu sistema à disponibilidade de uma API de terceiro que você não controla.

O acoplamento síncrono também cria problema de escalabilidade: em picos de tráfego, as conexões ficam bloqueadas esperando I/O externo, consumindo recursos sem processar trabalho útil.

A separação correta isola o envio de mensagens do caminho crítico da aplicação:

// Endpoint: valida e enfileira, resposta em milissegundos
app.post("/notifications/send", async (req, res) => {
  const { userId, message } = req.body;
 
  const [notificationId] = await db("notifications").insert({
    user_id: userId,
    message,
    status: "queued",
    created_at: new Date(),
  }).returning("id");
 
  await queue.add("send-whatsapp", { notificationId, userId, message });
 
  res.json({ notificationId, status: "queued" });
});
 
// Worker: processa de forma isolada, sem afetar o endpoint principal
const worker = new Worker("send-whatsapp", async (job) => {
  const { notificationId, userId, message } = job.data;
  const user = await db("users").where({ id: userId }).first();
 
  await sendMessage(user.phone, message);
 
  await db("notifications")
    .where({ id: notificationId })
    .update({ status: "sent", sent_at: new Date() });
});

Agora seu endpoint responde em milissegundos independente do estado da API externa. Se o WhatsApp ficar instável, as mensagens ficam na fila e são processadas quando a API voltar. O SLA do seu serviço deixa de depender do SLA de um terceiro que você não opera.

Erro 5: Falta de observabilidade

O cliente disse que não recebeu o link de acesso ao sistema. O suporte abriu o painel de envios e viu o status "enviado". Abriu os logs e encontrou entradas espalhadas em três serviços diferentes, sem nenhuma correlação entre elas. Foram 40 minutos de investigação para descobrir que a mensagem havia sido "enviada" para a fila, mas o worker tinha parado de processar 10 minutos antes. O status nunca foi atualizado. O cliente ficou sem acesso por quase uma hora e o suporte não conseguiu responder se a culpa era do sistema ou do número de destino.

Sem rastreabilidade estruturada, cada investigação numa integração de mensageria é exatamente isso: arqueologia. Logs sem correlação, sem timeline, sem estado intermediário registrado. A pergunta "essa mensagem foi enviada?" precisa ter uma resposta em segundos, não em horas.

A estrutura começa com um correlation ID propagado em todo o fluxo. Ele é o fio que conecta o evento de entrada ao log do worker ao registro no banco:

app.use((req, res, next) => {
  req.correlationId =
    (req.headers["x-correlation-id"] as string) ?? crypto.randomUUID();
  res.setHeader("x-correlation-id", req.correlationId);
  next();
});
 
await queue.add("send-whatsapp", {
  ...payload,
  correlationId: req.correlationId,
});
 
logger.info("Mensagem enfileirada", {
  correlationId: req.correlationId,
  userId,
  notificationId,
});

Com o correlation ID presente em cada log, você filtra todos os eventos de um fluxo específico com uma única query. Sem ele, você está juntando contexto manualmente entre serviços.

O segundo componente é o registro de estado em cada transição:

async function updateNotificationStatus(
  id: string,
  status: "queued" | "sending" | "sent" | "failed",
  meta?: Record<string, unknown>
) {
  await db("notification_events").insert({
    notification_id: id,
    status,
    metadata: JSON.stringify(meta ?? {}),
    occurred_at: new Date(),
  });
 
  await db("notifications").where({ id }).update({ status });
}

Com isso, a resposta para "em que estado está essa notificação, quando foi enviada, quantas tentativas foram feitas, qual foi o erro" está disponível numa query simples. O suporte consegue responder sem precisar acionar um engenheiro.

O terceiro componente são métricas operacionais. Sem elas, você reage a problemas depois que os usuários já sentiram. As métricas mínimas para uma integração de mensageria:

  • Taxa de sucesso de envio por janela de tempo
  • Tamanho da fila e tempo médio de processamento
  • Taxa de erro por tipo (timeout, rate limit, erro da API)
  • Número de mensagens na Dead Letter Queue

Com alertas nesses indicadores, você sabe antes do cliente. A fila crescendo acima de um threshold é um sinal de degradação antes de se tornar um incidente.

Conclusão

Integrações com WhatsApp Business API falham de formas que não aparecem em staging porque os problemas que importam são operacionais, não funcionais. O código envia a mensagem corretamente. O que falha é a suposição de que o ambiente vai sempre cooperar: que a API vai responder, que o worker vai completar, que o webhook vai chegar uma única vez.

Sistemas de mensageria confiáveis não são construídos em torno da ideia de que tudo vai dar certo. São construídos para quando as coisas derem errado: com idempotência para tolerar retries sem efeito colateral, com filas assíncronas para isolar falhas, com Dead Letter Queues para não perder eventos, com observabilidade para diagnosticar rapidamente.

Essa diferença tem consequência de negócio direta. Uma integração frágil significa campanhas que falham silenciosamente, clientes que não recebem notificações críticas, equipes de suporte investigando problemas que deveriam estar em dashboards. Uma integração robusta é infraestrutura invisível: ela simplesmente funciona, mesmo quando as condições são adversas.

Os cinco problemas descritos aqui não exigem refatoração completa. Cada um tem uma intervenção específica e bem delimitada. O ponto de partida mais impactante costuma ser a observabilidade: sem rastreabilidade, você não sabe quais dos outros quatro problemas você já tem em produção neste momento.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech