← Blog

Rate limit e fila: disparar WhatsApp em volume sem perder mensagem

Enviar uma mensagem por API é fácil. Enviar dez mil, na ordem certa, sem duplicar, sem perder e sem derrubar o número é um problema de engenharia — e é onde a maioria das integrações quebra na primeira campanha real.

O for que percorre a lista e chama a API a cada iteração funciona perfeitamente com 20 contatos e falha de forma espetacular com 2.000. Este guia mostra por que, e qual estrutura colocar no lugar.

Os dois limites diferentes que você precisa respeitar

Existe uma confusão que custa caro: limite de API e limite do WhatsApp não são a mesma coisa.

O limite da API é técnico e protege a infraestrutura. No Zapixo são 60 requisições por minuto por chave. Estourou, você recebe 429 e a requisição não é processada. É um limite conhecido, medido e recuperável: espere a janela reabrir e continue.

O limite do WhatsApp não é publicado, não é numérico e não avisa. É comportamental: volume incomum, mensagens idênticas em sequência, intervalo robótico entre envios, taxa de bloqueio pelos destinatários. A punição não é um 429 — é o número restrito ou banido, sem aviso e sem recurso rápido. As práticas para não chegar lá estão no guia sobre como evitar banimento.

A consequência prática é importante: respeitar 60 por minuto não significa que enviar 60 por minuto seja seguro. O limite técnico é o teto do que a API aceita; o ritmo saudável de disparo costuma ser bem mais baixo que isso.

Leia os cabeçalhos em vez de adivinhar

Toda resposta da API traz dois cabeçalhos, inclusive nas respostas de erro:

CabeçalhoConteúdo
X-RateLimit-RemainingQuantas requisições ainda cabem na janela atual
X-RateLimit-ResetQuando a janela reabre, em timestamp Unix (segundos)

Com eles, você não precisa descobrir o limite tomando 429. Basta desacelerar quando o saldo fica baixo:

async function enviarComControle(payload) {
  const res = await fetch("https://zapixo.com.br/api/v1/messages/text", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ZAPIXO_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });

  const restante = Number(res.headers.get("X-RateLimit-Remaining") ?? "60");
  const reset = Number(res.headers.get("X-RateLimit-Reset") ?? "0");

  if (res.status === 429) {
    const esperaMs = Math.max(reset * 1000 - Date.now(), 1000);
    return { status: "aguardar", esperaMs };
  }

  // margem de segurança: pausa antes de raspar o limite
  if (restante < 5) {
    const esperaMs = Math.max(reset * 1000 - Date.now(), 0);
    await new Promise((r) => setTimeout(r, esperaMs));
  }

  const corpo = await res.json();
  return { status: res.ok ? "ok" : "erro", http: res.status, corpo };
}

Repare que o 429 não é tratado como erro definitivo. A mensagem não foi enviada e precisa voltar para a fila, não ser descartada.

Por que o loop simples não serve

// não faça isso em produção
for (const contato of contatos) {
  await enviar(contato);
}

Quatro problemas, todos graves:

  1. Estoura o rate limit em segundos e o resto da lista vira 429.
  2. Perde tudo se o processo cair. Reinício no meio de 2.000 envios e você não sabe onde parou.
  3. Não diferencia falha temporária de definitiva. Instância caída e número inexistente recebem o mesmo tratamento: nenhum.
  4. Dispara em ritmo constante, o padrão robótico mais fácil de detectar.

A estrutura que funciona

Três componentes: tabela de fila, worker e política de retentativa.

A tabela

CREATE TABLE fila_mensagens (
  id            BIGSERIAL PRIMARY KEY,
  instancia     TEXT NOT NULL,
  numero        TEXT NOT NULL,
  texto         TEXT NOT NULL,
  chave_unica   TEXT UNIQUE,
  status        TEXT NOT NULL DEFAULT 'pendente',
  tentativas    INT  NOT NULL DEFAULT 0,
  proxima_em    TIMESTAMPTZ NOT NULL DEFAULT now(),
  message_id    TEXT,
  ultimo_erro   TEXT,
  criado_em     TIMESTAMPTZ NOT NULL DEFAULT now()
);

Dois campos merecem atenção.

chave_unica é a sua proteção contra mensagem duplicada. Monte com algo que identifique o evento de negócio, não o momento do envio: pedido-4821-entrega, fatura-9932-vencimento. Com índice único, o mesmo evento processado duas vezes insere uma linha só. Duplicar mensagem para cliente é pior do que atrasar.

proxima_em é o que permite adiar sem travar a fila. Uma mensagem que falhou volta com proxima_em no futuro e o worker simplesmente não a enxerga até lá.

O worker

O consumo é um laço que pega um lote pequeno, envia com pausa entre cada uma e atualiza o resultado:

async function processarLote() {
  const status = await consultarStatusInstancia("minha-loja");
  if (status !== "connected") {
    console.warn("instância fora do ar, adiando lote");
    return;
  }

  const lote = await buscarPendentes({ limite: 20 });

  for (const item of lote) {
    const resultado = await enviarComControle({
      instance: item.instancia,
      number: item.numero,
      text: item.texto,
    });

    if (resultado.status === "ok") {
      await marcarEnviado(item.id, resultado.corpo.messageId);
    } else if (resultado.status === "aguardar") {
      await adiar(item.id, resultado.esperaMs);
      break; // limite atingido: para o lote inteiro
    } else {
      await tratarFalha(item, resultado);
    }

    await dormir(intervaloHumanizado());
  }
}

Três detalhes que fazem diferença:

Checar o status antes do lote. Se a instância caiu, não adianta tentar: cada envio vira 502 e queima tentativa à toa. O que fazer quando isso acontece está em instância desconectou.

Parar o lote inteiro no 429. Continuar tentando os próximos só gera mais 429.

Intervalo variável entre envios:

function intervaloHumanizado() {
  // entre 3 e 8 segundos
  return 3000 + Math.floor(Math.random() * 5000);
}

Intervalo fixo de 2 segundos é assinatura de robô. Intervalo variável parece gente. Custa nada e reduz risco.

A política de retentativa

Nem toda falha merece nova tentativa. A distinção é o coração da fila:

CódigoNaturezaO que fazer
400Erro seu — JSON ou campo inválidoNão retentar. Marcar como falha e corrigir a origem
401Chave inválida ou revogadaNão retentar. Parar tudo e avisar a equipe
402Limite do período de teste atingidoNão retentar. Assinar um plano
404Instância inexistenteNão retentar. Nome errado na configuração
429Limite temporárioRetentar depois do X-RateLimit-Reset
502Falha no envio ao WhatsAppRetentar com espera crescente, até 3 vezes
Timeout de redeIndefinidoRetentar com cuidado — veja abaixo

Retentar 400 em laço infinito é o clássico: a mensagem nunca vai sair e você gasta requisição para sempre. Separe os erros em "meu problema" e "problema temporário" e trate cada grupo à sua maneira.

Para a espera crescente, o padrão de 1, 5 e 25 segundos funciona bem — é o mesmo que a plataforma usa para reentregar webhook ao seu servidor antes de registrar falha definitiva.

const ESPERAS_MS = [1000, 5000, 25000];

async function tratarFalha(item, resultado) {
  const temporario = resultado.http === 502 || resultado.http === undefined;

  if (!temporario || item.tentativas >= 3) {
    return marcarFalha(item.id, resultado.corpo);
  }

  return adiar(item.id, ESPERAS_MS[item.tentativas] ?? 25000);
}

O caso do timeout: quando você não sabe se enviou

Timeout de rede é o pior cenário, porque você não sabe se a mensagem saiu. Retentar pode duplicar; não retentar pode perder.

Duas defesas:

  1. Timeout generoso. 30 segundos para texto, mais para mídia. Timeout curto transforma envio lento em incerteza.
  2. Antes de retentar um timeout, confira. Se você registra os envios com a chave_unica e guarda o messageId, dá para verificar no seu próprio log se aquele evento já gerou envio bem-sucedido antes de tentar de novo.

Na dúvida entre duplicar e perder, para mensagem transacional geralmente é melhor perder e registrar o incidente — cliente que recebe a mesma cobrança duas vezes gera atrito muito maior.

Ordem: quando importa e quando não

Se você manda "seu pedido foi confirmado" e depois "seu pedido saiu para entrega", chegar invertido é ruim. Duas formas de garantir a ordem:

  • Uma fila por destinatário. Processe no máximo uma mensagem por número por vez, deixando o paralelismo entre números diferentes.
  • Sequência por conversa. Guarde um contador por telefone e só envie a de número n+1 depois de confirmar a n.

Para campanha, ordem não importa — e aí você pode paralelizar mais, sempre dentro do teto de requisições.

Monitore três números

Uma fila sem visibilidade é uma bomba silenciosa:

  1. Tamanho da fila pendente. Crescendo sem parar significa que a produção supera o consumo.
  2. Idade da mensagem mais antiga. Melhor indicador de atraso real que o tamanho da fila.
  3. Taxa de falha definitiva. Subiu de repente? Instância caída, chave revogada ou base de números ruim.

Um alerta simples — "mensagem mais antiga na fila tem mais de 15 minutos" — pega quase todo incidente antes do cliente.

Resumo

Volume no WhatsApp não é problema de velocidade, é de estrutura. A fila persiste antes de enviar, o worker respeita X-RateLimit-Remaining e o status da instância, a política separa erro seu de erro temporário, a chave_unica impede duplicata e o intervalo variável evita o padrão robótico.

Com isso no lugar, queda de instância vira atraso em vez de perda, e campanha grande vira uma questão de tempo em vez de risco. A referência dos endpoints e dos códigos de retorno está na documentação.