Blog

Webhook WhatsApp em produção: idempotência, filas e retry

Em produção, o webhook do WhatsApp não é um endpoint qualquer: a Meta entrega eventos com garantia at-least-once, o que significa que o mesmo evento pode chegar mais de uma vez. Se o seu handler processa tudo inline e não trata duplicatas, você envia mensagens repetidas, cobra o cliente duas vezes ou dispara fluxos em duplicidade. Este guia mostra os padrões que sustentam um webhook estável em alto volume: idempotência por message id, resposta rápida com enfileiramento e retry exponencial com DLQ.

2026-02-27 / Arquitetura Backend / 10 min

01

Por que o WhatsApp reenvia o mesmo webhook

A infraestrutura de webhooks da Meta opera com entrega at-least-once. Ela garante que o evento será entregue pelo menos uma vez, mas não garante exatamente uma vez. Sempre que a Meta não recebe uma resposta HTTP 200 dentro da janela esperada, ela assume falha e reenvia o mesmo evento. O resultado é duplicação natural do tráfego.

  • Timeout: seu servidor demorou para responder 200 e a Meta considerou a entrega falha.
  • Erro de rede: a resposta 200 se perdeu no caminho mesmo com o processamento já concluído.
  • Status 5xx ou conexão recusada: deploy, reinício ou pico de carga derrubaram o handler.
  • Replays internos da Meta: retentativas agendadas que repetem eventos antigos.

A consequência prática é direta: você não pode confiar que cada POST representa um evento novo. O mesmo message id pode aparecer duas, três ou mais vezes. O desenho do sistema precisa assumir duplicatas como normais, não como exceção.

02

Idempotência por message id com Redis SET NX

A defesa central contra duplicatas é a idempotência. Cada mensagem do WhatsApp carrega um id único e estável. A ideia é registrar esse id na primeira vez que ele chega e descartar qualquer reaparição. O Redis resolve isso com uma operação atômica: SET com a flag NX (set if not exists) e um TTL, que evita crescimento infinito da memória.

O SET NX é atômico: se duas entregas do mesmo id chegam ao mesmo tempo, apenas uma vence a corrida e marca o id. A outra recebe null e é ignorada. Não há janela de race condition entre um GET e um SET separados.

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

// TTL de 24h: cobre a janela de retries da Meta sem reter ids para sempre.
const IDEMPOTENCY_TTL_SECONDS = 60 * 60 * 24;

/**
 * Retorna true se o evento e novo (deve ser processado).
 * Retorna false se ja foi visto (duplicata, deve ser descartado).
 */
async function claimMessage(messageId) {
  const key = `wa:idempotency:${messageId}`;
  // SET key value NX EX ttl => grava apenas se a chave nao existir.
  const result = await redis.set(key, '1', 'EX', IDEMPOTENCY_TTL_SECONDS, 'NX');
  return result === 'OK';
}

async function handleWebhookEvent(event) {
  const messages = event?.entry?.[0]?.changes?.[0]?.value?.messages || [];

  for (const message of messages) {
    const isNew = await claimMessage(message.id);
    if (!isNew) {
      // Duplicata: a Meta reenviou. Ignore com seguranca.
      console.log('Evento duplicado ignorado', { messageId: message.id });
      continue;
    }
    // Primeira ocorrencia: enfileire para processamento.
    await enqueueForProcessing(message);
  }
}

Escolha um TTL maior do que a janela de retries da Meta (24h é uma margem segura). Se o TTL for curto demais, um retry tardio pode passar como evento novo e gerar duplicação.

03

Responda 200 rapido e enfileire o processamento

O erro mais comum em produção é processar o evento inline antes de responder a Meta. Chamadas a banco, APIs externas, modelos de IA ou envio de mensagens podem levar segundos. Se a resposta 200 não sai a tempo, a Meta considera falha e reenvia. Isso cria um ciclo vicioso: quanto mais lento, mais retries, mais carga, mais lento ainda.

A regra de ouro: o webhook valida, registra a idempotência, coloca o evento numa fila e responde 200 em milissegundos. Todo o trabalho pesado acontece em workers assíncronos, fora do ciclo de request.

Meta (WhatsApp)
      |
      |  POST webhook (at-least-once)
      v
+----------------------+
|   Webhook endpoint   |
|  1. valida assinatura|
|  2. SET NX (Redis)   |
|  3. enfileira evento |
|  4. responde 200     |  <-- em milissegundos
+----------------------+
      |
      |  push
      v
+----------------------+      +----------------------+
|        Fila          | ---> |       Workers        |
|   (Redis / SQS /     |      |  processamento real: |
|    RabbitMQ)         |      |  DB, IA, envio msg   |
+----------------------+      +----------------------+
                                       |
                                  falha N vezes
                                       v
                                +-------------+
                                |     DLQ     |
                                +-------------+

Com esse desenho, picos de tráfego não derrubam o endpoint. A fila absorve a rajada e os workers consomem no ritmo que conseguem, sem provocar timeouts e retries na origem.

04

Retry exponencial e Dead Letter Queue

Enfileirar não basta: o processamento em si pode falhar (API instável, timeout de banco, erro transitório). A estratégia correta é retry com backoff exponencial e, após esgotar as tentativas, mover o evento para uma Dead Letter Queue (DLQ) para inspeção manual em vez de descartar.

O backoff exponencial espalha as tentativas no tempo (1s, 2s, 4s, 8s...) para não martelar um serviço que já está sofrendo. A DLQ garante que nenhum evento se perca silenciosamente.

const { Queue, Worker } = require('bullmq');
const connection = { url: process.env.REDIS_URL };

const eventsQueue = new Queue('wa-events', { connection });
const deadLetterQueue = new Queue('wa-events-dlq', { connection });

async function enqueueForProcessing(message) {
  await eventsQueue.add('process-message', message, {
    attempts: 5,                       // 1 inicial + 4 retries
    backoff: { type: 'exponential', delay: 1000 }, // 1s, 2s, 4s, 8s...
    removeOnComplete: true,
    removeOnFail: false,
  });
}

const worker = new Worker(
  'wa-events',
  async (job) => {
    // Processamento real do evento. Lance erro para acionar o retry.
    await processMessage(job.data);
  },
  { connection },
);

// Apos esgotar todas as tentativas, envie para a DLQ.
worker.on('failed', async (job, err) => {
  if (job && job.attemptsMade >= (job.opts.attempts || 1)) {
    await deadLetterQueue.add('dead-letter', {
      payload: job.data,
      error: err.message,
      failedAt: new Date().toISOString(),
    });
    console.error('Evento movido para a DLQ', { jobId: job.id, error: err.message });
  }
});

Monitore o tamanho da DLQ: ela deve ficar vazia em condições normais. Crescimento da DLQ é o sinal mais claro de que algo a jusante está quebrado e precisa de atenção humana.

05

Falhas comuns e como mitigar

FalhaCausaMitigação
Mensagens duplicadas para o usuárioProcessamento sem idempotência; a Meta reenviou o eventoSET NX por message id no Redis com TTL antes de qualquer efeito colateral
Webhook desativado pela MetaRespostas 200 lentas ou erros 5xx repetidosResponder 200 em milissegundos; processar fora do request via fila
Tempestade de retries da MetaEndpoint lento sob pico de carga gera mais retriesEnfileirar e desacoplar; a fila absorve a rajada, workers consomem no ritmo
Eventos perdidos silenciosamenteErro transitório sem retry; exceção engolida no handlerRetry exponencial com tentativas limitadas e DLQ para inspeção
Memória do Redis crescendo sem fimChaves de idempotência sem expiraçãoSempre definir TTL no SET NX (ex.: 24h) cobrindo a janela de retries

06

Checklist de produção

  1. Validar a assinatura do webhook (X-Hub-Signature-256) antes de processar.
  2. Aplicar SET NX no Redis por message id com TTL antes de qualquer efeito colateral.
  3. Responder 200 imediatamente, antes de qualquer trabalho pesado.
  4. Enfileirar o evento e processar em workers assíncronos.
  5. Configurar retry exponencial com número limitado de tentativas.
  6. Encaminhar falhas definitivas para a DLQ e alertar sobre o crescimento dela.
  7. Monitorar latência do endpoint, profundidade da fila e taxa de duplicatas.

FAQ

Perguntas frequentes

Por que preciso responder 200 em poucos segundos?

Porque a entrega da Meta é at-least-once com timeout. Se a resposta 200 não chega dentro da janela esperada, a Meta considera a entrega falha e reenvia o mesmo evento. Respostas lentas geram retries, que aumentam a carga e deixam o endpoint ainda mais lento. Em casos extremos, a Meta pode desativar o webhook. Por isso, responda 200 em milissegundos e processe o trabalho pesado fora do ciclo de request.

O message id do WhatsApp é confiável para idempotência?

Sim. Cada mensagem carrega um id único e estável que se mantém idêntico entre os reenvios da Meta. Usar esse id como chave de idempotência (SET NX no Redis com TTL) é a forma mais direta de descartar duplicatas com segurança, desde que você aplique a verificação antes de qualquer efeito colateral.

Qual TTL usar nas chaves de idempotência?

Use um TTL maior do que a janela de retries da Meta. 24 horas é uma margem segura e prática. Um TTL curto demais arrisca deixar um retry tardio passar como evento novo, gerando duplicação. Um TTL muito longo apenas consome mais memória sem ganho real. O importante é nunca deixar a chave sem expiração.

Webhook estável é questão de arquitetura, não de sorte

Idempotência por message id, resposta 200 imediata com enfileiramento e retry exponencial com DLQ formam o trio que mantém seu webhook do WhatsApp confiável sob alto volume. Se você está lidando com duplicatas ou instabilidade em produção, posso ajudar a desenhar essa arquitetura.