"Estamos enviando 4 mil mensagens por dia e o número foi banido". Essa frase aparece, em variações, toda semana em fóruns de marketing digital. A causa é quase sempre a mesma: tudo concentrado em uma única instância, sem warmup gradual, sem distribuição inteligente, sem fallback. Quando o WhatsApp detecta o padrão (volume súbito, taxa de bloqueio acima da média), o número é desligado — junto com toda a operação.
A solução não é mágica nem proibida: distribua. Empresas que mandam 200 mil mensagens/dia operam pools de 80-150 instâncias com roteamento inteligente, isolamento por contexto e warmup automático de números novos. Esse artigo mostra a arquitetura completa.
O limite seguro de uma instância
Não existe número oficial publicado pelo WhatsApp. Mas observação empírica de produção (milhões de mensagens analisadas em 2024-2026):
- Número novo (semana 1): 50-100 mensagens/dia
- Número aquecido (1-2 meses): 500-800 mensagens/dia
- Número maduro (6+ meses): 1.500-2.500 mensagens/dia
- Número estabelecido com baixa rejeição (1+ ano): até 5.000 mensagens/dia
Acima desses limites, taxa de banimento sobe não-linearmente. A 3.000/dia em número novo, 78% banem em 14 dias.
Arquitetura do pool
Conceito básico
Em vez de uma instância recebendo todo o volume, você tem N instâncias categorizadas. Um router decide qual instância manda cada mensagem baseado em:
- Maturidade do número (novo/aquecido/maduro)
- Volume diário acumulado (não estourar limite)
- Contexto da mensagem (suporte vs marketing vs transacional)
- Cliente final (instância dedicada por cliente vs compartilhada)
- Saúde da instância (disconnected_since, error rate, latência)
Categorias de instância
- HOT: >6 meses, baixa rejeição, vai 1500+/dia. Para campanhas críticas e clientes premium.
- WARM: 1-6 meses, confiável até 800/dia. Para uso geral.
- COLD: <30 dias, em warmup, max 200/dia. Para tráfego misto.
- QUARANTINE: instância com problema recente — não recebe envios novos por 24h.
Implementação do router em Node
import axios from "axios";
import Redis from "ioredis";
const ZAP = axios.create({
baseURL: "https://api.zap-api.tech/v1",
headers: { Authorization: `Bearer ${process.env.ZAP_TOKEN}` },
});
const redis = new Redis(process.env.REDIS_URL);
// Pool em memória, atualizado a cada 5min via cron
let pool = [];
async function refreshPool() {
const { data } = await ZAP.get("/instances");
pool = data.instances.map((i) => ({
id: i.id,
name: i.name,
category: i.metadata?.category || "WARM",
dailyLimit: { HOT: 1500, WARM: 800, COLD: 200, QUARANTINE: 0 }[i.metadata?.category || "WARM"],
status: i.waStatus,
healthScore: i.healthScore,
}));
}
async function getDailyCount(instanceId) {
const key = `pool:count:${instanceId}:${new Date().toISOString().slice(0, 10)}`;
const v = await redis.get(key);
return Number(v || 0);
}
async function incDailyCount(instanceId) {
const key = `pool:count:${instanceId}:${new Date().toISOString().slice(0, 10)}`;
await redis.incr(key);
await redis.expire(key, 60 * 60 * 26);
}
async function pickInstance({ priority = "normal" } = {}) {
// 1. Filtra apenas conectadas e fora de quarentena
const candidatas = pool.filter(
(p) => p.status === "CONNECTED" && p.category !== "QUARANTINE"
);
// 2. Filtra que ainda têm capacidade hoje
const comCapacidade = [];
for (const c of candidatas) {
const used = await getDailyCount(c.id);
if (used < c.dailyLimit) {
comCapacidade.push({ ...c, used, free: c.dailyLimit - used });
}
}
if (comCapacidade.length === 0) {
throw new Error("POOL_EXHAUSTED — todas instâncias atingiram limite diário");
}
// 3. Para prioridade alta, escolhe HOT primeiro
const orderByPriority = priority === "high"
? ["HOT", "WARM", "COLD"]
: ["WARM", "COLD", "HOT"]; // poupa HOT pra crítico
for (const cat of orderByPriority) {
const dessaCat = comCapacidade.filter((c) => c.category === cat);
if (dessaCat.length === 0) continue;
// Round-robin ponderado por capacidade livre
return dessaCat.sort((a, b) => b.free - a.free)[0];
}
}
export async function sendMessage({ to, type, content, priority = "normal" }) {
const inst = await pickInstance({ priority });
const result = await ZAP.post(`/instances/${inst.id}/messages`, {
to,
type,
[type]: content,
});
await incDailyCount(inst.id);
return { messageId: result.data.id, instanceUsed: inst.id };
}
Health check do pool
// Cron a cada 5 minutos: marca quarentena automática
import cron from "node-cron";
cron.schedule("*/5 * * * *", async () => {
await refreshPool();
for (const inst of pool) {
const { data: health } = await ZAP.get(`/instances/${inst.id}/health`);
// Critério de quarentena: 3+ desconexões na última hora ou error rate >5%
if (health.disconnectsLastHour >= 3 || health.errorRate24h > 0.05) {
console.warn(`Quarentenando ${inst.id} (disconnects=${health.disconnectsLastHour})`);
await ZAP.patch(`/instances/${inst.id}`, {
metadata: { category: "QUARANTINE", quarantinedSince: new Date().toISOString() },
});
}
// Sai da quarentena depois de 24h se voltar ao normal
if (
inst.category === "QUARANTINE" &&
health.disconnectsLastHour === 0 &&
Date.now() - new Date(inst.metadata.quarantinedSince).getTime() > 24 * 3600 * 1000
) {
await ZAP.patch(`/instances/${inst.id}`, {
metadata: { category: "WARM" }, // volta como WARM, precisa re-aquecer pra HOT
});
}
}
});
Warmup de instância nova
Número novo NÃO entra no pool com limite cheio. Ele passa por warmup gradual:
// Programa de warmup: dia 1-30
const warmupSchedule = {
1: 30, // dia 1: max 30 msgs
2: 50,
3: 80,
4: 120,
5: 180,
7: 250, // semana 1 fim
10: 350,
14: 500, // semana 2 fim
21: 700, // semana 3 fim
30: 800, // graduado pra WARM completo
};
async function calcularLimiteWarmup(instanceId) {
const inst = pool.find((p) => p.id === instanceId);
const idade = Math.floor(
(Date.now() - new Date(inst.createdAt).getTime()) / (24 * 3600 * 1000)
);
const dias = Object.keys(warmupSchedule).map(Number).sort((a, b) => a - b);
const aplicavel = dias.filter((d) => d <= idade).pop() || 1;
return warmupSchedule[aplicavel] || 30;
}
// Substitui dailyLimit fixo no pool
async function refreshPool() {
const { data } = await ZAP.get("/instances");
pool = await Promise.all(
data.instances.map(async (i) => ({
id: i.id,
category: i.metadata?.category || "COLD",
dailyLimit: i.metadata?.category === "COLD"
? await calcularLimiteWarmup(i.id)
: { HOT: 2000, WARM: 1000, QUARANTINE: 0 }[i.metadata?.category || "WARM"],
// ...
}))
);
}
Isolamento por contexto
Mistura de tipos de tráfego degrada qualidade. Separe pools:
- Pool transacional (confirmação, recibo, OTP): número estável, alta entrega, jamais usado pra marketing
- Pool suporte (atendimento bidirecional): rotativo, com identificador "suporte" no nome
- Pool marketing (campanha em massa): assume mais risco, números mais novos, com warmup mais longo
async function sendMessage({ to, type, content, contexto }) {
// contexto: "transacional" | "suporte" | "marketing"
const poolFiltrado = pool.filter((p) => p.tags?.includes(contexto));
const inst = await pickInstanceFrom(poolFiltrado);
// ...
}
Casos práticos
E-commerce médio: 8 instâncias, 12 mil msg/dia
2 HOT (transacional: pedido confirmado, código rastreio) + 4 WARM (suporte) + 2 COLD em warmup. Roteamento por contexto. Banimento mensal: 0 nos últimos 9 meses.
Agência de marketing: 30 instâncias, 50 mil msg/dia
Pool dividido por cliente final (cada cliente tem 2-4 instâncias dedicadas). Quando cliente cresce, agência aprovisiona instância nova e roda warmup automático. Operação roda 24/7 sem intervenção humana.
SaaS B2B: 12 instâncias, isolamento por feature
3 instâncias só pra OTP (alta criticidade) — número da operadora oficial. 6 pra notificações de produto. 3 pra suporte bidirecional. Falha em 1 instância de OTP escala automaticamente pra fila de SMS como fallback.
FAQ
Quantas instâncias eu preciso?
Regra: divida volume diário esperado por 800 (limite seguro de WARM). Some 30% de margem. Para 10k msgs/dia: 10000/800 × 1.3 = 17 instâncias. Para 50k: 80 instâncias.
Posso usar o mesmo número em várias instâncias?
Não. Um número WhatsApp é único — duas instâncias conectadas no mesmo número geram conflito imediato (uma desconecta a outra). Cada instância = um número diferente.
Como sticky session: cliente sempre conversa com mesma instância?
Hash do telefone do cliente final — mesmo cliente sempre vai pra mesma instância. Implementação: instances[hash(phone) % instances.length]. Útil pra suporte (cliente vê mesmo "atendente"), mas reduz flexibilidade do pool.
Custo de 50 instâncias na ZAP API?
2 primeiras: 2 × R$49 = R$98. 48 seguintes: 48 × R$29 = R$1.392. Total: R$1.490/mês. Por mensagem: R$1.490/50.000 = R$0,0298. Significativamente mais barato que SMS (R$0,15-0,25/msg) e que enterprise (R$0,06-0,12/msg).
O que evita falha em cascata?
Quarentena automática + circuit breaker por instância + dailyLimit por categoria. Quando uma instância falha, ela sai do pool, requests vão pra outras. Quando 30%+ do pool falha simultaneamente (caso raro de bug do gateway), router serve erro explícito pra cliente upstream em vez de tentar mensagens fantasmas.
Pronto para escalar sem banir? A ZAP API atende empresas que precisam de 5 a 200+ instâncias com pricing volumétrico e provisionamento via API. Criar conta grátis e ative reseller pra escalar.