Blog

Arquitetura multi-tenant para SaaS com WhatsApp

Construir um SaaS sobre a WhatsApp Cloud API significa servir muitos clientes a partir da mesma base de código e da mesma infraestrutura. O desafio não é enviar mensagens: é garantir que o tenant A nunca veja, toque ou afete os dados do tenant B, que um cliente barulhento não degrade a experiência dos demais e que cada configuração (templates, credenciais, feature flags) seja isolada e governável. Este guia trata dos padrões de arquitetura multi-tenant que sustentam isso: modelos de isolamento, roteamento de webhook por phone_number_id, limites e quotas por tenant e governança de configuração.

2026-01-19 / SaaS Architecture / 12 min

01

Modelos de isolamento: silo, pool e bridge

A primeira decisão de um SaaS multi-tenant é o grau de isolamento entre clientes. Três modelos dominam o debate. No modelo silo, cada tenant recebe sua própria stack (banco, filas, às vezes até compute) dedicada. No modelo pool, todos os tenants compartilham a mesma infraestrutura e o isolamento é lógico, garantido por uma coluna tenant_id em cada tabela. O modelo bridge é o meio termo: compartilha compute e aplicação, mas isola o dado sensível (um banco ou schema por tenant) onde o custo de vazamento é mais alto.

No nível do banco, isso se materializa em três estratégias: banco por tenant (isolamento físico máximo, custo operacional alto), schema por tenant (isolamento lógico forte dentro do mesmo cluster) e row-level com tenant_id (uma única tabela com a coluna discriminadora). A escolha define seu custo, seu blast radius e a complexidade de onboarding de cada novo cliente.

ModeloIsolamentoCusto / tenantOnboardingBlast radiusQuando usar
Banco por tenant (silo)Físico, máximoAltoLento (provisiona stack)Mínimo: falha fica contidaEnterprise, compliance rígido, dado regulado
Schema por tenant (bridge)Lógico forteMédioMédio (cria schema + migra)Médio: cluster compartilhadoMix de clientes médios e grandes
Row-level tenant_id (pool)Lógico via aplicaçãoBaixoRápido (insere linha)Alto: erro de filtro vaza tudoSMB em escala, alto volume de tenants

Na prática, SaaS maduros raramente escolhem um único modelo. Adotam um pool para a maioria (SMB) e oferecem silo como plano premium para clientes enterprise que exigem isolamento físico. Esse híbrido é o bridge na sua forma mais comercial: a mesma aplicação roteia para o storage certo conforme o plano do tenant.

02

Resolvendo o tenant a partir do webhook

No WhatsApp SaaS, cada tenant tem um ou mais números, e cada número tem um phone_number_id estável atribuído pela Meta. Quando uma mensagem chega, o webhook é o mesmo para todos os clientes: o que muda é o phone_number_id dentro do payload. Esse identificador é a chave de roteamento. Antes de qualquer processamento, você resolve o tenant a partir dele e injeta o contexto do tenant em todo o fluxo subsequente.

O payload entrega o phone_number_id em entry[].changes[].value.metadata.phone_number_id. A partir dele, uma busca (idealmente cacheada) devolve o tenant correspondente. Se não houver tenant mapeado, o evento é rejeitado: nunca processe um webhook sem tenant resolvido, pois isso é justamente o vetor de vazamento entre clientes.

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

// Cache de phone_number_id -> tenant para evitar hit no banco a cada evento.
const TENANT_CACHE_TTL = 60 * 10; // 10 min

async function resolveTenant(phoneNumberId) {
  const cacheKey = `wa:pnid:${phoneNumberId}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // tenant_numbers mapeia cada phone_number_id ao seu tenant.
  const tenant = await db.query(
    'SELECT tenant_id, status FROM tenant_numbers WHERE phone_number_id = $1',
    [phoneNumberId],
  ).then((r) => r.rows[0]);

  if (!tenant) return null; // numero nao registrado: rejeite o evento.
  await redis.set(cacheKey, JSON.stringify(tenant), 'EX', TENANT_CACHE_TTL);
  return tenant;
}

async function handleWebhook(req, res) {
  // Responda 200 rapido; o roteamento e o trabalho pesado ficam fora do request.
  res.sendStatus(200);

  const change = req.body?.entry?.[0]?.changes?.[0]?.value;
  const phoneNumberId = change?.metadata?.phone_number_id;
  if (!phoneNumberId) return;

  const tenant = await resolveTenant(phoneNumberId);
  if (!tenant || tenant.status !== 'active') {
    console.warn('Webhook sem tenant valido', { phoneNumberId });
    return; // nunca processe sem tenant resolvido.
  }

  // Enfileira na fila ISOLADA do tenant, carregando o contexto adiante.
  await enqueueForTenant(tenant.tenant_id, change);
}
Meta (WhatsApp)
      |
      |  POST /webhook  (payload com phone_number_id)
      v
+----------------------------+
|     Webhook endpoint       |
|  1. responde 200           |
|  2. extrai phone_number_id |
|  3. resolveTenant()        |  --> cache Redis / tabela tenant_numbers
|  4. valida tenant ativo    |
+----------------------------+
      |
      |  contexto { tenant_id }
      v
+----------------------------+      +----------------------------+
|  Fila do tenant A          |      |  Fila do tenant B          |
|  (queue:wa:tenantA)        |      |  (queue:wa:tenantB)        |
+----------------------------+      +----------------------------+
      |                                    |
      v                                    v
  Workers (sempre filtram por tenant_id no banco)

O contexto do tenant resolvido aqui deve viajar com o evento por todo o pipeline. Workers, queries e chamadas de envio sempre recebem o tenant_id explicitamente. Resolver o tenant cedo e propagá-lo é o que impede que lógica posterior precise adivinhar a quem o evento pertence.

03

Limites e quotas por tenant

Em infraestrutura compartilhada, o maior risco operacional é o noisy neighbor: um tenant que dispara uma campanha massiva ou entra em loop e consome toda a capacidade, degradando os demais. A defesa é impor limites por tenant em várias camadas, de modo que nenhum cliente possa monopolizar recursos compartilhados.

  • Rate limit por tenant: limite de requisições por segundo isolado por tenant_id, com token bucket no Redis, para que o pico de um não consuma a cota global da Meta.
  • Cota de mensagens: teto diário ou mensal por plano, contabilizado por tenant, com bloqueio ou degradação graciosa ao atingir o limite.
  • Fila isolada por tenant: filas separadas (ou prioridade ponderada) para que o backlog de um tenant não atrase o processamento dos outros.
  • Limite de concorrência de workers: número máximo de jobs simultâneos por tenant, evitando que um cliente domine o pool de workers.
  • Quota de armazenamento e mídia: teto de uploads e retenção por tenant para conter custo e abuso.
  • Circuit breaker por tenant: ao detectar erros repetidos (ex.: template inválido em massa), pausar o tenant específico sem afetar os demais.

A chave conceitual é que toda quota carrega o tenant_id como dimensão. Um rate limit global protege a Meta de bloquear sua conta, mas não protege os tenants entre si. Só um limite por tenant garante isolamento de desempenho, que é tão importante quanto o isolamento de dados em um SaaS.

04

Governança de configuração por tenant

Cada tenant traz sua própria configuração: números, templates aprovados, credenciais da Meta, integrações e comportamento de produto. Governar isso significa armazenar, versionar e resolver essas configurações por tenant de forma segura, sem hardcode e sem que a config de um cliente vaze para outro.

  • Templates por tenant: cada cliente tem seu catálogo de templates aprovados, com nome, idioma e status, isolados por tenant_id; nunca presuma que um template existe para todos.
  • Credenciais isoladas: o access token e o WABA de cada tenant ficam cifrados em um cofre (KMS / Secrets Manager), referenciados por tenant, nunca em variáveis de ambiente compartilhadas.
  • Feature flags por tenant: habilite recursos (IA, novo fluxo, beta) por cliente ou plano, permitindo rollout gradual e planos diferenciados sem branches de código.
  • Webhooks de saída e integrações: cada tenant configura seus próprios destinos e segredos de assinatura, resolvidos a partir do contexto do tenant.
  • Branding e copy: saudações, menus e textos específicos por tenant, carregados pela mesma camada de resolução de config.

Trate a configuração como dado de primeira classe, com a mesma disciplina de isolamento do resto. Uma camada de resolução de configuração recebe o tenant_id e devolve apenas a config daquele tenant, com cache invalidável. Credenciais nunca trafegam em claro nem aparecem em logs.

05

Segurança e prevenção de vazamento entre tenants

No modelo pool, o vazamento entre tenants é a falha mais grave possível: um filtro tenant_id esquecido em uma query é o suficiente para um cliente ler dados de outro. A defesa não pode depender da disciplina manual de cada desenvolvedor; precisa ser estrutural, com camadas que tornem o vazamento difícil de introduzir e fácil de detectar.

  1. Sempre filtrar por tenant_id: toda query de leitura e escrita inclui tenant_id na cláusula WHERE; nunca confie em filtro implícito.
  2. Centralizar o acesso a dados: um repositório ou data layer que injeta tenant_id automaticamente a partir do contexto, removendo a chance de esquecer o filtro na mão.
  3. Aplicar Row-Level Security (RLS) no banco: políticas no Postgres que forçam o tenant_id no nível do SGBD, como última barreira mesmo se a aplicação falhar.
  4. Propagar o contexto do tenant: o tenant_id resolvido no webhook viaja por toda a request, fila e worker; nenhuma camada deduz o tenant por conta própria.
  5. Cifrar e isolar credenciais: tokens por tenant em cofre, nunca compartilhados; o vazamento de um segredo não deve dar acesso a outro tenant.
  6. Testes automatizados de isolamento: suites que, com dois tenants populados, tentam ler dados cruzados e falham o build se qualquer vazamento ocorrer.
  7. Auditoria e logs com tenant_id: toda operação registra o tenant, permitindo rastrear e detectar acessos anômalos entre clientes.

O teste de isolamento merece destaque: criar dois tenants, popular dados em ambos e afirmar que nenhuma operação de um enxerga o outro deve ser parte do pipeline de CI. Esse teste é o que transforma isolamento de uma promessa em uma garantia verificada a cada commit.

06

Onboarding e ciclo de vida do tenant

Um SaaS multi-tenant precisa de um processo claro para criar, suspender e remover tenants. Onboarding e offboarding malfeitos são fontes silenciosas de vazamento e de custo: dados órfãos, números mal mapeados e credenciais que sobrevivem ao fim do contrato.

  1. Provisionar o tenant: criar o registro, o storage conforme o plano (row-level, schema ou banco) e o namespace de filas.
  2. Registrar números: mapear cada phone_number_id ao tenant na tabela de roteamento e invalidar o cache.
  3. Configurar credenciais e templates: armazenar tokens cifrados e sincronizar o catálogo de templates aprovados da Meta.
  4. Aplicar limites do plano: definir rate limit, cotas e flags conforme o tier contratado.
  5. Suspender com segurança: ao inadimplir ou pausar, marcar o tenant como inativo para que o webhook rejeite eventos sem apagar dados.
  6. Offboarding: exportar dados, revogar credenciais e remover storage de forma auditável ao encerrar o contrato.

Tratar o ciclo de vida como um fluxo explícito e versionado evita o acúmulo de tenants zumbis. Cada estado (ativo, suspenso, encerrado) tem comportamento definido no roteamento e nos limites, e a transição entre eles é auditável.

FAQ

Perguntas frequentes

Devo começar com banco por tenant ou row-level com tenant_id?

Para a maioria dos SaaS em estágio inicial, comece com row-level usando tenant_id: o custo por tenant é baixo e o onboarding é instantâneo, o que importa quando você ainda está validando o produto. Reserve banco ou schema por tenant para clientes enterprise com exigências de compliance ou isolamento físico, oferecendo isso como plano premium. O importante é desenhar a aplicação para tolerar ambos os modelos desde o início, com o tenant_id propagado em todo o código, para que migrar um cliente específico para um silo não exija reescrita.

Como o webhook sabe a qual tenant pertence cada mensagem?

Pelo phone_number_id presente no payload, em entry[].changes[].value.metadata.phone_number_id. Esse identificador é estável e único por número. Você mantém uma tabela que mapeia cada phone_number_id ao seu tenant e resolve o tenant logo no início do processamento, idealmente com cache no Redis para evitar um hit no banco a cada evento. Se o phone_number_id não estiver mapeado a nenhum tenant ativo, o evento deve ser rejeitado: nunca processe um webhook sem tenant resolvido.

Como evito que um tenant degrade o desempenho dos outros?

Aplicando limites por tenant em várias camadas: rate limit por tenant_id com token bucket, cota de mensagens por plano, filas isoladas (ou com prioridade ponderada) e limite de concorrência de workers por tenant. A ideia central é que toda quota carregue o tenant_id como dimensão. Um limite global protege sua conta na Meta, mas não protege os clientes entre si; somente limites por tenant garantem isolamento de desempenho e evitam o problema do noisy neighbor.

Multi-tenant é isolamento desenhado, não improvisado

Escolher o modelo de isolamento certo, rotear webhooks por phone_number_id, impor limites por tenant e testar isolamento a cada commit formam a espinha dorsal de um SaaS de WhatsApp confiável e escalável. Se você está projetando ou escalando uma plataforma multi-tenant, posso ajudar a desenhar essa arquitetura.