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çalho | Conteúdo |
|---|---|
X-RateLimit-Remaining | Quantas requisições ainda cabem na janela atual |
X-RateLimit-Reset | Quando 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:
- Estoura o rate limit em segundos e o resto da lista vira
429. - Perde tudo se o processo cair. Reinício no meio de 2.000 envios e você não sabe onde parou.
- Não diferencia falha temporária de definitiva. Instância caída e número inexistente recebem o mesmo tratamento: nenhum.
- 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ódigo | Natureza | O que fazer |
|---|---|---|
400 | Erro seu — JSON ou campo inválido | Não retentar. Marcar como falha e corrigir a origem |
401 | Chave inválida ou revogada | Não retentar. Parar tudo e avisar a equipe |
402 | Limite do período de teste atingido | Não retentar. Assinar um plano |
404 | Instância inexistente | Não retentar. Nome errado na configuração |
429 | Limite temporário | Retentar depois do X-RateLimit-Reset |
502 | Falha no envio ao WhatsApp | Retentar com espera crescente, até 3 vezes |
| Timeout de rede | Indefinido | Retentar 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:
- Timeout generoso. 30 segundos para texto, mais para mídia. Timeout curto transforma envio lento em incerteza.
- Antes de retentar um timeout, confira. Se você registra os envios com a
chave_unicae guarda omessageId, 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+1depois de confirmar an.
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:
- Tamanho da fila pendente. Crescendo sem parar significa que a produção supera o consumo.
- Idade da mensagem mais antiga. Melhor indicador de atraso real que o tamanho da fila.
- 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.