Disparo em massa no WhatsApp: fila, rate limit e retry (a engenharia que o for-loop não resolve)
Um laço for com 5.000 contatos falha na metade e você não sabe em quais. Como montar a fila, respeitar o rate limit da API, aplicar retry com backoff só nos erros que valem e retomar uma campanha interrompida sem enviar nada duas vezes.
O código que todo mundo escreve primeiro:
for (const contato of contatos) {
await enviar(contato.numero, mensagem);
}
Funciona com 50 contatos. Com 5.000, falha na metade — e você não sabe em qual.
Este artigo é sobre a engenharia do disparo. A lista e as boas práticas de conteúdo são pré-requisito: nenhuma fila salva uma base ruim.
Os quatro problemas do laço
1. Não tem memória. Processo caiu na mensagem 2.300? Reexecutar manda tudo de novo. Quem já recebeu, recebe duas vezes.
2. Não tem ritmo. As primeiras saem em milissegundos, você bate o limite, e o resto falha em sequência.
3. Não distingue erro. "Número não tem WhatsApp" e "servidor ocupado, tente de novo" caem no mesmo catch.
4. Não é observável. Enquanto roda, ninguém sabe quantas saíram, quantas falharam nem quanto falta.
A estrutura: produtor, fila, worker
import { Queue, Worker } from 'bullmq';
const fila = new Queue('campanha', { connection: redis });
// PRODUTOR — só enfileira. Não envia nada. Termina em segundos.
async function agendarCampanha(campanhaId, contatos, template) {
for (const c of contatos) {
await fila.add('envio', {
campanhaId,
to: c.numero,
params: [c.nome, c.pedido],
template,
}, {
// Determinístico: reenfileirar a campanha inteira não duplica nada.
jobId: `${campanhaId}:${c.numero}`,
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
});
}
}
O jobId é a peça central. Com ele, "rodar a campanha de novo" vira uma operação segura: a fila descarta o que já processou e executa só o que faltava. Sem ele, cada reexecução é um novo disparo completo.
O worker: um de cada vez, no ritmo
new Worker('campanha', async (job) => {
const { to, params, template, campanhaId } = job.data;
// Opt-out AQUI, não na montagem da campanha. Uma campanha grande
// leva horas para escoar, e quem pede para sair no meio precisa
// parar de receber no meio.
if (await estaEmOptOut(to)) {
await registrar(campanhaId, to, 'optout');
return;
}
try {
const r = await enviarTemplate(to, template, params);
await registrar(campanhaId, to, 'enviado', r.messageId);
} catch (e) {
if (definitivo(e)) {
await registrar(campanhaId, to, 'falha_definitiva', null, e.code);
return; // não repete: não adianta
}
throw e; // temporário: deixa a fila repetir
}
}, {
connection: redis,
concurrency: 1, // um por vez: ritmo é o objetivo
limiter: { max: 20, duration: 60_000 }, // no máximo 20 por minuto
});
Duas configurações fazem o trabalho:
concurrency: 1 — paralelizar disparo é contraproducente. O gargalo é o limite da plataforma, não a sua CPU.
limiter — o teto duro. Mesmo que a fila tenha 50 mil itens, saem 20 por minuto.
Distinguir erro temporário de definitivo
É o que separa uma fila que escoa de uma fila que se arrasta:
const DEFINITIVOS = new Set([
'numero_invalido',
'sem_whatsapp',
'template_nao_aprovado',
'contato_bloqueou',
]);
function definitivo(e) {
if (DEFINITIVOS.has(e.code)) return true;
const s = e.status;
if (!s) return false; // rede: temporário
if (s === 429) return false; // limite: temporário
if (s >= 500) return false; // servidor: temporário
return s >= 400 && s < 500; // demais 4xx: definitivo
}
Repetir cinco vezes um "número não tem WhatsApp" gasta 5× a cota e atrasa quem está atrás na fila, sem nenhuma chance de sucesso.
O que cada código significa e o tratamento certo para cada um estão em erros da API e como tratar.
Backoff com jitter
Retry sincronizado é pior que retry nenhum: se 200 mensagens falham juntas por um 429 e todas repetem em 2 segundos, elas voltam juntas e tomam 429 de novo.
backoff: {
type: 'custom',
// 2s, 4s, 8s, 16s… com até 30% de variação aleatória,
// para as tentativas não voltarem todas no mesmo instante.
strategy: (tentativa) => {
const base = Math.min(2000 * 2 ** (tentativa - 1), 5 * 60_000);
return base * (1 + Math.random() * 0.3);
},
}
O teto de 5 minutos evita que a quinta tentativa caia daqui a horas.
Ritmo humano na API não oficial
Na API não oficial não há limite documentado — há o comportamento do número, e cadência de robô é um dos sinais que derrubam conta:
async function pausaHumana() {
// 3 a 10 segundos, variável. Intervalo fixo é padrão detectável.
const ms = 3000 + Math.random() * 7000;
await new Promise((r) => setTimeout(r, ms));
}
Some a isso pausas maiores a cada bloco e respeito ao horário comercial — mandar às 3 da manhã gera bloqueio mesmo com opt-in impecável:
function dentroDoHorario() {
const agora = new Date();
const h = agora.getHours();
const dia = agora.getDay();
if (dia === 0) return false; // domingo não
if (dia === 6) return h >= 9 && h < 13; // sábado de manhã
return h >= 8 && h < 20;
}
No worker, se estiver fora do horário, adie em vez de enviar:
if (!dentroDoHorario()) {
await job.moveToDelayed(Date.now() + 30 * 60_000);
return;
}
Na API oficial o limite é maior e documentado, mas o princípio continua: o que protege o número é o comportamento, não a tecnologia.
Acompanhar enquanto roda
async function progresso(campanhaId) {
const [aguardando, ativos, falhos] = await Promise.all([
fila.getWaitingCount(),
fila.getActiveCount(),
fila.getFailedCount(),
]);
const enviados = await contarPorStatus(campanhaId, 'enviado');
return {
enviados,
aguardando,
ativos,
falhos,
// Com limiter de 20/min, dá para estimar o fim de verdade
terminaEm: `${Math.ceil(aguardando / 20)} min`,
};
}
Campanha sem barra de progresso é campanha que ninguém confia — e alguém acaba rodando de novo "por garantia", que é exatamente o cenário que o jobId previne.
Sem Redis
O mesmo desenho cabe numa tabela:
CREATE TABLE envios (
campanha_id BIGINT,
numero VARCHAR(20),
status VARCHAR(20) DEFAULT 'pendente',
tentativas INT DEFAULT 0,
proxima_em TIMESTAMPTZ DEFAULT NOW(),
message_id VARCHAR(80),
erro VARCHAR(50),
PRIMARY KEY (campanha_id, numero)
);
A chave primária composta dá a idempotência que o jobId daria. Um worker busca os pendentes com proxima_em <= NOW(), envia, atualiza. É mais código, e funciona.
O que não funciona é guardar o estado só na memória do processo: ele se perde exatamente no momento em que você mais precisa dele.
Conclusão
Disparo em massa é problema de fila, não de laço. As quatro peças — idempotência por jobId, limitador de ritmo, retry só no que é temporário e progresso visível — são meia tarde de trabalho e transformam "rodei e não sei o que aconteceu" em uma operação que você retoma sem medo.
Depois que a campanha escoa, o trabalho continua: medir entrega, leitura e resposta é o que diz se valeu a pena.
Pronto para automatizar seu WhatsApp?
Crie sua conta gratuita e comece a enviar mensagens pela API em minutos.
Começar grátisPerguntas frequentes
Por que não devo usar um for-loop para disparar em massa?+
Porque o laço não tem memória. Se o processo cair na mensagem 2.300 de 5.000, você não sabe quais já foram, e reexecutar manda tudo de novo para quem já recebeu. Além disso, um laço sem controle de ritmo atinge o rate limit e passa a receber erro para a maioria das chamadas restantes.
Qual o intervalo certo entre mensagens?+
Depende do canal e do histórico do número. Na API não oficial, algo entre 3 e 10 segundos com variação aleatória é o intervalo prudente. Na oficial o limite é mais alto e documentado, mas continua existindo. O certo é tratar o intervalo como configuração ajustável, não como número fixo no código.
Quais erros valem retry e quais não?+
Vale repetir erro temporário: 429 de limite, 5xx do servidor, timeout e falha de rede. Não vale repetir erro definitivo: número inválido, sem WhatsApp, template não aprovado ou contato em opt-out. Repetir erro definitivo gasta cota e atrasa a fila sem chance de sucesso.
Como retomar uma campanha que parou no meio?+
Com um jobId determinístico por destinatário e campanha. Ao reenfileirar tudo, a fila descarta os jobs já processados pelo id repetido, e só o que faltava roda. Isso torna o reenvio da campanha inteira uma operação segura.
Preciso de Redis para fazer isso?+
Não obrigatoriamente. Redis com BullMQ é o caminho mais curto, mas uma tabela no banco com status por destinatário e um worker lendo os pendentes resolve o mesmo problema. O que não funciona é manter o estado só na memória do processo, porque ele se perde exatamente quando você mais precisa dele.
Continue lendo
Como criar um chatbot de IA com a API da OpenAI para responder no WhatsApp
Um webhook, uma chamada à API da OpenAI e uma resposta pela WAME API: o código completo de um chatbot de IA que atende no WhatsApp, Instagram e Messenger. Com memória por contato, controle de custo e o que fazer quando a IA não deve responder.
Cobrança por Pix dentro do WhatsApp pela API: como enviar e o que muda na conversão
Mandar o código Pix no WhatsApp resolve o pior ponto da cobrança digital: o cliente não precisa sair do app. Como enviar a cobrança pela API, tratar a confirmação e evitar os erros que transformam a facilidade em suporte.
Erros da API do WhatsApp: o que cada um significa e como tratar
A mensagem não saiu e o log diz apenas 'erro ao enviar'. Os erros que você vai encontrar de verdade — janela fechada, número inválido, template não aprovado, limite atingido, instância caída — e o tratamento certo para cada um.