Number Pool — Balanceamento de Envios por API Key
Status: Proposta
Depende de: Plano: Múltiplos Provedores + Múltiplas Instâncias por Número
Estimativa: 3-5 dias após conclusão do plano principal
1. Objetivo
Permitir que uma API Key envie mensagens por múltiplos números WhatsApp com:
- Round-robin sequencial — cada nova mensagem usa o próximo número do pool
- Afinidade de destinatário — se um destinatário já recebeu de um número, tenta usar o mesmo número
- Fallback automático — se o número com afinidade está sem conexão ativa, usa outro número do pool
2. Conceito
API Key
├── defaultNumber: "5511999990001" (fallback, usada quando pool está desativado)
├── numberSelectionMode: "pool" ("default" | "pool")
└── numberPool: [ (N números vinculados)
{ number: "5511999990001", priority: 0 }
{ number: "5511999990002", priority: 1 }
]
Envio para +55 11 88888-0001:
1. Já enviei para esse destinatário antes? → Usa o mesmo número (afinidade)
2. Número com afinidade está conectado? → Usa ele
3. Número com afinidade desconectado? → Usa próximo do round-robin
4. Nenhum número conectado? → Erro
3. O que o plano principal já resolve
O plano multiple-instances-per-number.plan.md implementa mudanças que são pré-requisitos diretos para o pool:
3.1 defaultNumber no ApiKey (Seção 4.6 do plano)
model ApiKey {
whatsappInstanceId String? @map("whatsapp_instance_id") // DEPRECATED
defaultNumber String? @map("default_number") // NOVO
}
Hoje a API key aponta para uma instância. Com defaultNumber, aponta para um número (WhatsAppNumber), que pode ter N instâncias. Essa mudança de granularidade (instância → número) é o primeiro passo para suportar múltiplos números.
3.2 sourceNumber na Message (Seção 4.7 do plano)
model Message {
sourceNumber String? @map("source_number") // NOVO
}
Permite saber "de qual número foi enviado". Essa é a base da afinidade de destinatário — a query busca qual sourceNumber foi usado na última mensagem enviada para aquele destinationNumber.
3.3 Fallback entre instâncias (Seções 5.3 e 11 do plano)
O worker já terá lógica de: dado um número, buscar instâncias conectadas e tentar em ordem de priority. O pool expande essa lógica de "instâncias de um número" para "números de uma API key".
4. Schema — Mudanças Novas
4.1 Campo no ApiKey
model ApiKey {
// ... campos existentes (incluindo defaultNumber do plano) ...
/// Modo de seleção de número para envio:
/// "default" — usa apenas o defaultNumber
/// "pool" — round-robin entre os números do pool
numberSelectionMode String @default("default") @map("number_selection_mode")
}
4.2 Tabela de relacionamento N-N
/// Relação entre API Key e números WhatsApp no pool.
model ApiKeyNumberPool {
id String @id @default(cuid())
apiKeyId String @map("api_key_id")
whatsappNumberId String @map("whatsapp_number_id")
/// Prioridade dentro do pool (menor = tentado primeiro no round-robin)
priority Int @default(0) @map("priority")
createdAt DateTime @default(now()) @map("created_at")
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
whatsappNumber WhatsAppNumber @relation(fields: [whatsappNumberId], references: [id], onDelete: Cascade)
@@unique([apiKeyId, whatsappNumberId])
@@index([apiKeyId, priority])
@@map("api_key_number_pool")
}
4.3 Relações atualizadas
model ApiKey {
// ... existing ...
numberPool ApiKeyNumberPool[]
}
model WhatsAppNumber {
// ... existing ...
apiKeyPools ApiKeyNumberPool[]
}
5. Worker — Lógica de Resolução
5.1 Fluxo de decisão
processMessage(job)
│
├── apiKey.numberSelectionMode == "default" || pool vazio?
│ └── Usa defaultNumber (fluxo atual do plano)
│
└── apiKey.numberSelectionMode == "pool"
│
├── 1. Affinity: destinationNumber já recebeu de algum número do pool?
│ ├── Sim, e o número está conectado → Usa esse número
│ └── Sim, mas número desconectado → Fallback para round-robin
│
├── 2. Round-robin: próximo número do pool (Redis counter)
│ └── Tenta cada número em ordem, pulando desconectados
│
└── 3. Nenhum número conectado → Erro
5.2 Código
async function resolveSourceNumberForSend(params: {
apiKey: {
id: string;
defaultNumber: string | null;
numberSelectionMode: string;
};
destinationNumber: string;
tenantId: string;
}): Promise<{
whatsappNumber: WhatsAppNumber;
instance?: WhatsAppInstance;
}> {
const { apiKey, destinationNumber, tenantId } = params;
// ── Default mode (sem pool) ──
if (
apiKey.numberSelectionMode !== "pool" ||
!apiKey.defaultNumber
) {
return resolveByDefaultNumber(apiKey.defaultNumber, tenantId);
}
// ── Pool mode: busca números vinculados ──
const pool = await prisma.apiKeyNumberPool.findMany({
where: { apiKeyId: apiKey.id },
include: {
whatsappNumber: {
include: {
instances: {
where: { state: "CONNECTED" },
include: { providerConfig: true },
orderBy: [
{ providerConfig: { priority: "asc" } },
{ priority: "asc" },
],
},
},
},
},
orderBy: { priority: "asc" },
});
if (pool.length === 0) {
return resolveByDefaultNumber(apiKey.defaultNumber, tenantId);
}
// ── 1. Affinity: destinatário já recebeu de algum número do pool? ──
const lastMessage = await prisma.message.findFirst({
where: {
tenantId,
destinationNumber,
sourceNumber: { not: null },
status: { in: ["SENT", "DELIVERED", "READ"] },
},
orderBy: { sentAt: "desc" },
select: { sourceNumber: true },
});
if (lastMessage?.sourceNumber) {
const affinityEntry = pool.find(
(p) => p.whatsappNumber.number === lastMessage.sourceNumber
);
if (
affinityEntry &&
affinityEntry.whatsappNumber.instances.length > 0
) {
return {
whatsappNumber: affinityEntry.whatsappNumber,
instance: affinityEntry.whatsappNumber.instances[0],
};
}
// Affinity number disconnected → fallback to round-robin
}
// ── 2. Round-robin: próximo número do pool ──
const rrIndex = await getAndIncrementRoundRobin(apiKey.id, pool.length);
for (let i = 0; i < pool.length; i++) {
const idx = (rrIndex + i) % pool.length;
const entry = pool[idx];
// Meta numbers (sem instâncias web)
if (entry.whatsappNumber.metaPhoneId) {
return { whatsappNumber: entry.whatsappNumber, instance: undefined };
}
// Web-based: precisa de instância conectada
if (entry.whatsappNumber.instances.length > 0) {
return {
whatsappNumber: entry.whatsappNumber,
instance: entry.whatsappNumber.instances[0],
};
}
}
throw new Error("No number available in pool");
}
5.3 Round-robin com Redis
async function getAndIncrementRoundRobin(
apiKeyId: string,
poolSize: number,
): Promise<number> {
const redis = getWorkerRedis();
const key = `pool:round-robin:${apiKeyId}`;
const index = await redis.incr(key);
// TTL para auto-cleanup (chaves de APIs inativas somem)
if (index === 1) {
await redis.expire(key, 86400); // 24h
}
return (index - 1) % poolSize;
}
5.4 Interação com o fluxo de fallback do plano
O plano principal define que, para cada número, o worker faz fallback entre instâncias (Seção 5.3). O pool adiciona uma camada acima dessa lógica:
Pool (round-robin entre números)
└── Número A
└── Fallback entre instâncias (plano principal, Seção 5.3)
├── Instância A1 (EVO GO, priority 0)
└── Instância A2 (EVO V2, priority 1)
└── Número B
└── Fallback entre instâncias
└── Instância B1 (EVO GO, priority 0)
O pool escolhe o número, e o mecanismo de instâncias escolhe a conexão dentro desse número.
6. Backend API
6.1 Endpoints
| Método | Endpoint | Descrição |
|---|---|---|
| PUT | /api/api-keys/:id/selection-mode | Atualiza numberSelectionMode ("default" / "pool") |
| GET | /api/api-keys/:id/pool | Lista números do pool da API key |
| POST | /api/api-keys/:id/pool | Adiciona número ao pool |
| DELETE | /api/api-keys/:id/pool/:numberId | Remove número do pool |
| PUT | /api/api-keys/:id/pool/reorder | Reordena prioridade dos números |
6.2 Exemplo: Adicionar número ao pool
// POST /api/api-keys/clx.../pool
{
"whatsappNumberId": "clx-number-abc"
}
// Response
{
"id": "clx-pool-xyz",
"apiKeyId": "clx...",
"whatsappNumberId": "clx-number-abc",
"priority": 2,
"whatsappNumber": {
"id": "clx-number-abc",
"number": "5511999990002",
"displayName": "Vendas"
}
}
7. UI — ApiKeys.tsx
7.1 Componentes novos
Na tela de edição da API key, adicionar:
| Componente | Descrição |
|---|---|
| Toggle "Balanceamento de envios" | Liga/desliga o modo pool (numberSelectionMode) |
| Multi-select de números | Lista números conectados do tenant para adicionar ao pool |
| Lista ordenada do pool | Drag-and-drop para reordenar prioridade |
| Badge por número | Status: conectado / desconectado |
| Botão "Remover do pool" | Remove número do pool |
7.2 Wireframe
┌──────────────────────────────────────────────────────┐
│ Editar API Key: "Produção" │
│ │
│ Nome: Produção │
│ Ambiente: LIVE │
│ Número padrão: +55 11 99999-0001 │
│ │
│ ┌── Balanceamento de Envios ──────────────────────┐ │
│ │ │ │
│ │ [Toggle] Ativar balanceamento de envios │ │
│ │ │ │
│ │ Números no pool: │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ ☰ 1. +55 11 99999-0001 (Vendas) 🟢 │ × │ │
│ │ │ ☰ 2. +55 11 99999-0002 (Suporte) 🟢 │ × │ │
│ │ │ ☰ 3. +55 11 99999-0003 (Demo) 🔴 │ × │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ [+ Adicionar número ao pool] │ │
│ │ │ │
│ │ ℹ️ Mensagens são enviadas em sequência │ │
│ │ entre os números. Destinatários que já │ │
│ │ receberam de um número continuarão │ │
│ │ recebendo do mesmo quando possível. │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
8. Testes
| Camada | Cenário | Descrição |
|---|---|---|
| Unit | resolveSourceNumberForSend — default mode | Usa defaultNumber, ignora pool |
| Unit | resolveSourceNumberForSend — pool round-robin | Alterna entre números em sequência |
| Unit | resolveSourceNumberForSend — affinity | Destinatário já recebeu do número A → usa A |
| Unit | resolveSourceNumberForSend — affinity fallback | Número A desconectado → usa próximo do pool |
| Unit | resolveSourceNumberForSend — pool vazio | Fallback para defaultNumber |
| Unit | resolveSourceNumberForSend — todos desconectados | Erro: "No number available in pool" |
| Unit | getAndIncrementRoundRobin | Contador atômico wraps corretamente |
| Integration | API pool CRUD | Adicionar, remover, reordenar números no pool |
| Integration | Envio com pool | Mensagem é enviada pelo número correto do pool |
| Fullstack | Affinity end-to-end | 2 mensagens para mesmo destinatário → mesmo número |
| Fullstack | Fallback end-to-end | Número primário cai → envia pelo secundário |
9. Considerações
9.1 Limits
Definir um limite máximo de números por pool (ex: 10) para evitar queries pesadas. Validar no backend.
9.2 Métricas
Adicionar tracing no messageTrace para registrar qual número do pool foi usado e se houve fallback de afinidade:
await messageTrace.appendEvent({
messageId,
tenantId,
type: MessageTraceEventType.POOL_RESOLVED,
stage: "pool.number.resolved",
data: {
selectionMode: "pool",
affinityNumber: lastMessage?.sourceNumber ?? null,
resolvedNumber: entry.whatsappNumber.number,
roundRobinIndex: rrIndex,
poolSize: pool.length,
},
});
9.3 Webhook
O webhook de message.sent (e message.failed) já retorna o messageId do provedor. Adicionar o sourceNumber no payload do webhook para que o cliente saiba qual número foi usado:
{
"event": "message.sent",
"data": {
"messageId": "...",
"sourceNumber": "5511999990001",
"destinationNumber": "5511988880001",
"status": "SENT",
"sentAt": "..."
}
}
9.4 Rollout
O campo numberSelectionMode tem default "default", então a funcionalidade é opt-in. Não quebra nada existente. Rollout pode ser gradual por tenant.