Em andamento Pessoal NodeJS React TypeScript Prisma ORM BullMQ ExpressJS Redis Socket.IO NextJS Docker

ZapBot: bastidores de um SaaS multi-tenant de automação no WhatsApp

Um backend e um frontend construídos do zero para uma plataforma de atendimento no WhatsApp com editor visual de fluxos, atendimento humano por setor e isolamento físico entre clientes — este post é o resumo do processo de decisão, não só do resultado fin

ZapBot: bastidores de um SaaS multi-tenant de automação no WhatsApp

Node.js 20TypeScript estritoExpress 5PostgreSQLPrismaRedisBullMQSocket.ioZodNext.jsReactTanStack Query@xyflow/reactEvolution API

Nos últimos meses venho construindo, sozinho, um SaaS de automação de atendimento no WhatsApp. A ideia é simples de explicar: cada cliente desenha, num editor visual parecido com uma URA, um fluxo de atendimento para o próprio número. Mensagem, captura de resposta, condição, integração com um ERP ou CRM, e quando precisa, transferência para um setor humano. Um motor no backend interpreta esse fluxo toda vez que alguém manda uma mensagem.

Esse post não é sobre o produto em si. É sobre o processo de construir ele: as decisões que mais me fizeram parar pra pensar, os trade-offs que assumi de olhos abertos e alguns bugs reais que apareceram pelo caminho, junto com o raciocínio pra resolver cada um.

Vale contar de onde parti na escolha de tecnologia, porque isso explica boa parte do resto. Uma das motivações do projeto sempre foi aprender fazendo, sem depender de serviços gerenciados caros. Por isso o backend roda inteiro num único VPS: Node com Express, PostgreSQL e Redis autohospedados, filas com BullMQ em cima do próprio Redis (em vez de contratar uma fila gerenciada à parte), tudo orquestrado com Docker Compose e PM2. Nenhuma dessas escolhas é a mais "na moda" do mercado, mas cada uma resolve o problema real sem exigir uma conta de nuvem nova pra funcionar. O mesmo raciocínio guiou usar a Evolution API (uma implementação não oficial do protocolo do WhatsApp) em vez da Cloud API da própria Meta, que exige verificação de negócio antes de sequer testar o primeiro fluxo.

Isolamento entre clientes como base, não como detalhe

A primeira decisão do projeto não foi sobre o motor de fluxo. Foi sobre como garantir que um cliente nunca visse um dado do outro. A resposta que escolhi foi um banco PostgreSQL físico por tenant, em vez da abordagem mais comum de uma coluna tenant_id numa tabela compartilhada. Custa mais caro de operar (tem que gerenciar pool de conexões e rodar migration em N bancos separados), mas um vazamento entre clientes de um produto que guarda conversa de cliente final não tem correção parcial depois que acontece.

A conexão do tenant certo nunca vem de nada que o cliente manda na requisição, nem subdomínio nem header. Ela é resolvida a partir do e-mail autenticado no JWT, cruzado com um banco central de autenticação. O mesmo princípio aparece de novo numa peça de infraestrutura compartilhada: Redis e as filas do BullMQ são únicos pra todos os tenants, mas todo job carrega o id do tenant dentro dele, e isso é garantido em tempo de compilação por um generic do TypeScript, não só por convenção de nome que alguém pode esquecer de seguir.

O maior desafio: chamar uma API externa sem quebrar um motor síncrono

Desde o início o motor de fluxo é uma função pura e síncrona. Recebe um estado e uma mensagem, devolve o próximo estado e uma lista de saídas, sem tocar em rede. É o que permite testar o motor inteiro sem precisar simular chamada de rede nenhuma.

Quando o produto precisou de um bloco que consulta uma API externa do tenant (um ERP, um CRM), a saída mais óbvia teria sido deixar o motor chamar fetch no meio da execução. Só que isso quebraria justamente a parte que fazia o motor confiável. Resolvi de outro jeito: o motor pausa e nunca chama rede sozinho. Uma camada de orquestração fora dele faz a chamada de verdade e devolve o resultado pra ele continuar de onde parou.

mensagem → captura → condição → integração HTTP
                                   │
                    motor pausa aqui (aguardandoIntegracao)
                                   │
              orquestrador chama a API externa de verdade
                                   │
                    motor reentra com sucesso ou falha

Essa mesma decisão trouxe uma pergunta de segurança que não dava pra ignorar. Deixar um tenant configurar qual URL chamar é uma porta clássica de SSRF. A defesa acabou em três camadas: a URL precisa ser válida e usar HTTPS, precisa começar pela base autorizada na credencial daquele tenant, e o host precisa resolver por DNS pra um IP público. Isso bloqueia, por exemplo, uma tentativa de apontar pro metadata da nuvem.

Segurança de sessão: onde o token realmente fica guardado

Uma pergunta que costuma passar batido: onde o JWT de acesso fica guardado no navegador? Aqui, a resposta é em lugar nenhum persistente. Ele vive só numa variável em memória do frontend. Se algum dia um XSS conseguir injetar script na página, não vai existir uma cópia parada em localStorage esperando pra ser lida. O token só existe enquanto a página está de fato aberta.

Isso significa que um F5 apaga o token, e a sessão sobrevive assim mesmo porque quem realmente persiste é o refresh token, guardado num cookie HttpOnly que o próprio JavaScript do frontend não consegue ler. No boot do app, uma chamada silenciosa pro endpoint de refresh (o cookie vai junto sozinho) devolve um access token novo, que só então volta pra memória.

Do lado do servidor, o refresh token é rotacionado a cada uso. Cada login gera uma família, um identificador único, e cada renovação cria um token novo dentro da mesma família, marcando o anterior como já usado. Se um token que já foi trocado reaparecer numa tentativa de renovação, é sinal clássico de token roubado. Nesse caso a família inteira é revogada de uma vez, não só aquele token isolado. É detecção de reuso, um padrão conhecido de rotação de refresh token, guardado como hash no banco, nunca em texto puro.

O JWT de acesso nunca é persistido em lugar nenhum, nem no cliente nem no servidor. Ele é stateless e reconstruído a cada boot do app. Só o refresh token vira registro, e mesmo assim como hash.

Trade-offs que assumi conscientemente

  • Banco físico por tenant: vazamento entre clientes fica praticamente impossível, ao custo de mais complexidade de conexão e migration em vários bancos.
  • Evolution API em vez da Cloud API oficial da Meta: fricção zero pra estudar o domínio, mas sem garantia nenhuma de estabilidade. Aconteceu de verdade durante o projeto, uma instância caiu com device_removed, exatamente o risco que eu já esperava.
  • Parser de condição restrito a igual e diferente: elimina o uso de eval como categoria de risco inteira, ao custo de não conseguir expressar lógica combinada ainda.
  • Redis compartilhado entre tenants: um serviço só, sem custo de instância extra por cliente, com isolamento lógico em vez de físico como no banco.

O que ficou para depois

Nó de inteligência artificial usando LangChain, mapeamento de status de entrega do WhatsApp e validação estática de variáveis do fluxo são as pendências mais visíveis hoje. Todas já estavam registradas no backlog do projeto antes desta apresentação, não foram achadas de última hora. Como o objetivo aqui é estudo e experimentação, não virar produto comercial, não tenho pressa nenhuma pra fechar essas lacunas. Prefiro ir com calma e aprender bem cada peça nova antes de adicionar a próxima.

Quer ver mais?

Este post é só um resumo. Documentei o projeto inteiro com bem mais detalhe num blueprint, com arquitetura completa, modelo de dados e todos os trade-offs explicados um a um. Deixo os links abaixo:

  • Blueprint completo do projeto
  • Repositório do backend (link a preencher)
  • Repositório do frontend (link a preencher)
  • Vídeo com o código explicado e o projeto rodando (link a preencher)