Você conecta o número de WhatsApp que a sua operação já usa — o mesmo que está impresso no cartão e no site — e passa a enviar e receber mensagens por API, sem pedir aprovação de template à Meta e sem pagar por mensagem.
- Operações de atendimento e cobrança que já trabalham no WhatsApp em celular e querem integrar o número existente ao sistema
- Fintechs e financeiras que precisam de histórico de conversa auditável dentro da própria plataforma, não em um app de terceiro
- Plataformas B2B que revendem atendimento para dezenas de clientes e precisam de um número por cliente com dados isolados
- Assinatura de gateway de WhatsApp não-oficial por instância (Z-API, Zapster e similares)
- Servidor próprio rodando Evolution API, WPPConnect ou Baileys puro, com a operação junto
- Celular corporativo com atendente digitando mensagem à mão
- A API oficial da Meta — isso é o building block wpp-business, e é o caminho recomendado para marketing, alto volume e operação regulada
- Uma ferramenta de disparo em massa; envio frio em volume é o caminho mais rápido para o número ser banido
- Um canal com SLA de entrega contratual da Meta, porque a Meta não é parte deste contrato
01Resumo executivo
O WPP conecta um número de WhatsApp comum ao seu sistema. É o mesmo pareamento que você faz quando abre o WhatsApp Web no computador: lê um QR code ou digita um código de oito caracteres, e a partir daí a plataforma envia e recebe mensagens daquele número por API. O celular do atendente continua funcionando normalmente, no mesmo número, ao lado.
Na prática isso resolve o caso em que a empresa já atende no WhatsApp há anos, tem o número impresso em contrato, boleto e fachada, e não pode perdê-lo. Migrar esse número para a API oficial da Meta o tira do aplicativo comum e obriga a submeter cada mensagem proativa a um template aprovado. Com o WPP, a mensagem que o sistema envia é indistinguível da que o atendente digitaria — e o histórico dela fica no seu banco, não no celular de alguém.
Está em produção desde maio de 2026, roda em três processos separados (API, worker de sessão e stream de eventos) e usa a biblioteca Baileys como único driver real.
Aviso que precede qualquer decisão de compra. Este building block usa um cliente não-oficial do WhatsApp. Os Termos de Serviço da Meta proíbem acessar o serviço por meios automatizados ou não autorizados e enviar mensagens em massa, e reservam à Meta o direito de suspender ou encerrar o acesso a qualquer momento (WhatsApp Terms of Service). O número pode ser banido. O risco é real, não é hipotético, e não existe configuração que o elimine. Leia a §15 antes de fechar contrato. Se a sua operação não tolera perder o número, o caminho é o wpp-business, que fala a API oficial.
| Atributo | Valor |
|---|---|
| Identificador | wpp |
| Categoria | Comunicação |
| Escopo | Tenant (exige organizationId no token) |
| Porta (standalone) | 3023 — o processo de SSE sobe separado, na 3024 do compose |
| Path alias | @wpp |
| Prefixo HTTP | /wpp |
| Status | Produção desde 2026-05 |
| Depende de | PostgreSQL (schema wpp), Redis (Streams + registro de posse), S3 via File Storage, IAM, Webhooks Engine |
| Driver real | baileys (há um mock usado apenas em teste) |
02O problemanegócio
O cenário. Uma operação brasileira atende no WhatsApp. Não é uma escolha de canal — é onde o cliente está. O WhatsApp tem cerca de 147 milhões de usuários no Brasil, o segundo maior mercado do mundo, e a pesquisa Mobile Time/Opinion Box de junho de 2025 aponta que 97% dos usuários abrem o aplicativo ao menos uma vez por dia (Opinion Box, Pesquisa WhatsApp no Brasil 2025). A empresa já tem um número, já tem histórico nele, e ele está impresso em lugares que ela não controla mais.
O que trava hoje.
- O atendimento vive fora do sistema. As conversas estão no celular do atendente. Quando ele sai da empresa, o histórico sai junto. Não há como auditar o que foi prometido a um cliente.
- Migrar para a API oficial custa o número. Um número registrado na WhatsApp Business Platform sai do aplicativo comum. Não dá para o atendente continuar respondendo pelo celular ao mesmo tempo, e toda mensagem proativa passa a exigir template aprovado pela Meta.
- Cobrança por mensagem não fecha em atendimento. Desde 1º de julho de 2025 a Meta cobra por mensagem entregue, com preço por categoria e por país (Meta — updates to pricing). Para uma esteira de cobrança que troca dezenas de mensagens com o mesmo devedor, a conta é sensível ao volume de conversa, que é exatamente o que a operação quer aumentar.
- A alternativa caseira vira um problema de infraestrutura. Subir Baileys ou Evolution API num servidor funciona na demonstração. Em produção aparecem os problemas de verdade: onde guardar a credencial da sessão, o que acontece quando o processo reinicia, como impedir que duas réplicas abram dois sockets com a mesma credencial, como separar o dado de um cliente do outro.
- Gateway pronto é caixa-preta. Assinar um serviço não-oficial de terceiros resolve a operação e cria outro problema: a conversa do seu cliente trafega e fica hospedada fora, e a permissão de quem pode enviar mensagem em nome da empresa não conversa com o seu controle de acesso.
O custo de não resolver. O custo direto é o atendimento que não escala: cada atendente é limitado pela velocidade com que digita, e nada do que ele digita alimenta o sistema. O custo indireto é maior e chega depois — uma disputa em que a empresa não consegue provar o que foi combinado, porque a prova está num aparelho que já foi formatado.
03Proposta de valornegócio
| Antes | Depois |
|---|---|
| Conversa mora no celular do atendente | Cada mensagem persiste no schema wpp, com chat, contato e grupo relacionados |
| Integrar exige migrar o número para a Meta e aprovar template | Você pareia o número em minutos, por QR ou código, e o celular continua funcionando |
| Rodar Baileys em produção é um projeto de infraestrutura | Sessão restaurada no boot, dono único por número, reconexão com backoff e teto de tentativas |
| Credencial da sessão em arquivo no disco do servidor | Credencial cifrada em AES-256-GCM no Postgres, com chave-mestra fora do banco |
| Receber evento exige poller ou socket próprio | Webhook assinado em RSA-SHA256 pelo Webhooks Engine, com log de entrega e reenvio |
O número sobrevive à integração. O pareamento é o mesmo do WhatsApp Web: o aparelho continua sendo o principal, o sistema entra como dispositivo companheiro. Não há migração, não há janela de indisponibilidade e não há template para aprovar.
Multi-tenant desde a linha do banco. Toda tabela do schema wpp carrega organizationId, e todo router aplica requireOrganization antes de chegar na regra de negócio. Uma plataforma que opera um número por cliente não depende de lembrar de filtrar.
Uma sessão viva por número, garantido em Redis. Duas réplicas abrindo socket com a mesma credencial fazem o WhatsApp responder com conflito 440 em pingue-pongue até invalidar a sessão. O registro de posse (wpp:device-owner:*, TTL de 45s, reivindicado por script Lua atômico) existe para que isso não aconteça — e ele nasceu de um incidente real, não de um diagrama.
Freio de mão contra o próprio integrador. Existe um orçamento de 30 tentativas de conexão por número por hora, compartilhado entre todas as réplicas. Um consumidor com defeito não consegue martelar o login do WhatsApp até o número ser bloqueado temporariamente, porque o serviço recusa antes.
Mídia recebida vira arquivo endereçável. Foto, áudio e documento que chegam são baixados, descriptografados e materializados no File Storage, com URL assinada — em vez de ficarem como um blob criptografado que só o socket original conseguia abrir.
04Casos de uso reaisnegócio
Caso 1 — Uma financeira integra o número do call center sem perder o histórico Cenário ilustrativo
Contexto. Financeira de crédito com 40 atendentes e um único número de WhatsApp divulgado em contrato, boleto e site. O número existe há seis anos.
A dor. O atendimento acontecia em três celulares corporativos passados de mão em mão. Nada do que era combinado chegava ao sistema de propostas. Migrar para a API oficial foi descartado na primeira reunião: tirava o número do aplicativo comum, e o time de atendimento não aceitava perder o app que já sabia usar.
A solução com o BB. O número vira um WppDevice. O pareamento é feito uma vez por POST /wpp/api/v1/devices/:deviceId/connect seguido de GET /wpp/api/v1/devices/:deviceId/qr. A partir daí toda mensagem recebida persiste em wpp_messages e dispara wpp.v1.message.received.text para um dispatcher que entrega no CRM. As respostas automáticas saem por POST /wpp/api/v1/messages/send. O celular continua no bolso do supervisor, no mesmo número.
O resultado. O histórico passa a existir dentro da plataforma e fica consultável por GET /wpp/api/v1/messages?filter[remoteJid]=.... A operação não trocou de ferramenta e não trocou de número.
Caso 2 — Uma régua de cobrança que respeita a regra anti-spam da Meta Cenário ilustrativo
Contexto. Operação de recuperação de crédito com uma carteira de dezenas de milhares de contratos em atraso.
A dor. A tentação óbvia — disparar a régua inteira pelo WhatsApp — é também o caminho mais curto para perder o número. O WhatsApp bloqueia mensagens de alcance a quem nunca te escreveu, respondendo com o erro 463, e o bloqueio acumula na conta remetente: mandar vários contatos frios seguidos trava o número inteiro, inclusive para quem antes recebia normalmente (ver `docs/wpp-send-463-lid-learnings.md`).
A solução com o BB. A régua é invertida. O primeiro contato sai por canal oficial — SMS, e-mail ou o wpp-business com template aprovado — convidando o devedor a responder. Quando ele responde, o wpp.v1.message.received.text chega ao sistema, a janela de conversa abre e todo o resto da negociação corre pelo WPP, sem template e sem custo por mensagem. Um dispatcher com condição JSONPath sobre senderPhone roteia a conversa para a fila certa.
O resultado. O volume de mensagem que realmente importa — a negociação, que é longa — sai pelo canal barato, e o disparo frio, que é o que arrisca o número, nunca acontece pelo canal não-oficial.
Caso 3 — Uma plataforma de logística coordena entrega por grupo Cenário ilustrativo
Contexto. Empresa que faz última milha com transportadores autônomos, organizados por rota.
A dor. A coordenação acontecia em grupos de WhatsApp criados à mão pelo supervisor, um por rota, todo dia. Ninguém sabia quem estava em qual grupo, e o supervisor virava gargalo.
A solução com o BB. A rota do dia dispara POST /wpp/api/v1/groups com a lista de participantes; o serviço devolve 202 com um correlationId, e o resultado — inclusive o JID do grupo criado — sai em GET /wpp/api/v1/operations/:correlationId. Ao fim do dia, POST /wpp/api/v1/groups/:groupJid/leave encerra a participação do número da empresa. Os eventos wpp.v1.group.updated e as mensagens do grupo alimentam o painel.
O resultado. O grupo deixa de ser trabalho manual e vira efeito colateral do roteirizador. A limitação honesta: adicionar participante a grupo depende da configuração de privacidade de cada pessoa, e o WhatsApp pode recusar — por isso a resposta de updateParticipants vem por participante, com sucesso e erro individuais.
Caso 4 — O mercado escolheu o caminho não-oficial de olhos abertos Referência de mercado
Contexto. Existe um ecossistema grande e ativo de clientes não-oficiais de WhatsApp: o Baileys é a biblioteca base, e projetos como Evolution API (Apache 2.0) e WPPConnect (MIT) o empacotam em serviço. No Brasil, gateways comerciais como a Z-API cobram assinatura por instância conectada.
A dor do mercado. Nenhum desses projetos esconde o risco, e o Baileys é explícito: não é afiliado nem endossado pelo WhatsApp, os mantenedores não condenam nem se responsabilizam pelo uso, e a licença MIT afasta qualquer garantia. As issues do repositório registram relatos recorrentes de banimento — por exemplo #1869 "High number of bans on WhatsApp" e #2309. Quem adota assume o risco; quem finge que ele não existe perde o número e a confiança do cliente junto.
Como a Catalisa endereça. Não com uma promessa de que não vai acontecer, porque essa promessa não é honesta. Com três coisas concretas: orçamento de conexão por número que impede o martelo de login, posse única de socket que impede a sessão duplicada — a causa raiz documentada de invalidação de credencial e bloqueio temporário nos nossos próprios incidentes — e o wpp-business como caminho oficial, no mesmo catálogo, para o cliente que decidir que não tolera o risco.
O resultado. A decisão de canal vira uma escolha informada de trade-off — custo e liberdade de mensagem contra risco de perder o número — em vez de uma surpresa seis meses depois.
05Mercado e diferenciaisnegócio
Panorama. O mercado de WhatsApp para empresas está partido em dois, e os dois lados resolvem problemas diferentes. De um lado, a via oficial: a Cloud API da Meta e os BSPs que revendem por cima dela, como a Twilio. É o caminho com contrato, sem risco de banimento por ferramenta, e com botões, catálogo e fluxos — mas o número migra, cada mensagem proativa exige template aprovado e, desde julho de 2025, a cobrança é por mensagem entregue. Do outro lado, a via não-oficial, construída sobre engenharia reversa do protocolo do WhatsApp Web: Baileys como biblioteca, Evolution API e WPPConnect como serviços open source, Z-API e similares como gateways pagos. Aqui o número não migra, não há template e o custo não acompanha o volume de mensagem — em troca de operar fora dos Termos de Serviço da Meta.
O WPP está do lado não-oficial. A aposta dele não é ser mais um cliente Baileys: é ser o único em que a sessão, o isolamento entre clientes, a permissão e o webhook já vêm resolvidos e ligados ao resto de um catálogo de 32 building blocks.
| Critério | Catalisa WPP | Evolution API | WPPConnect | Z-API | Meta Cloud API |
|---|---|---|---|---|---|
| Precisa migrar o número | Não | Não | Não | Não | Sim |
| Template aprovado pela Meta | Não exige | Não exige | Não exige | Não exige | Exige para proativas |
| Custo por mensagem | Não | Não | Não | Não (assinatura por instância) | Sim, por mensagem entregue |
| Risco de banimento do número | Sim | Sim | Sim | Sim | Não, por esta causa |
| Quem opera | Já vem operado | Você | Você | O fornecedor | A Meta |
| Isolamento multi-tenant | organizationId em toda linha e em todo router | Por instância; separação é sua | Por sessão; separação é sua | Por instância contratada | Por WABA |
| Autorização integrada ao seu IAM | Sim, permissões WPP_* | Não | Não | Não | Não |
| Onde o dado de conversa fica | No seu banco | No seu banco | No seu banco | No fornecedor | Na Meta |
| Sessão distribuída com dono único | Sim, registro em Redis com TTL de 45s | Não nativo | Não nativo | Não exposto | Não se aplica |
| Webhook assinado com log e reenvio | Sim, RSA-SHA256 via Webhooks Engine | Webhook simples | Webhook simples | Webhook simples | Sim |
| Botões, catálogo, pagamento | Não | Parcial (via Cloud API) | Não | Parcial | Sim |
| Licença/custo de software | Incluso na plataforma | Apache 2.0 | MIT | Assinatura mensal | Por mensagem |
Comparativo levantado em 2026-08-16 a partir da documentação pública de cada projeto. Verifique na data da sua análise: este mercado muda rápido, especialmente do lado não-oficial.
Nossos diferenciais
- A posse do socket é um problema resolvido, e resolvido em Lua. Reivindicar dono para um número usando
GETseguido deSETtem uma janela em que duas réplicas leem vazio e ambas se declaram donas. Isso abre dois sockets com a mesma credencial e o WhatsApp responde invalidando a sessão. OOwnerRegistryfaz a reivindicação num únicoEVAL, o que fecha a janela. É o tipo de detalhe que só entra no código depois de um incidente — e por isso é difícil de copiar de um README. - O tenant é a linha, não a instância. A concorrência não-oficial separa cliente por instância: um processo, um contêiner ou uma assinatura por número. Isso é caro e não isola relatório, permissão nem auditoria. Aqui
organizationIdestá em cada uma das 14 tabelas e o token do IAM é a única fonte dele. - O freio existe contra o próprio cliente. Orçamento de 30 conexões por número por hora, recusa explícita de conectar número em estado terminal sem reparo declarado, e circuito que abre depois de N tentativas de reconexão. São defesas contra o modo de falha que mais mata número: a tentativa automática em laço.
- O caminho de saída para o canal oficial está no mesmo catálogo. Quando a operação amadurece e o risco deixa de ser aceitável, o wpp-business fala a Cloud API com o mesmo IAM, o mesmo Webhooks Engine e o mesmo formato de evento
wpp.v1.*. Trocar de canal não é trocar de fornecedor.
Quando escolher o concorrente. Com franqueza, porque essa decisão costuma ser tomada errado.
Se o seu caso é marketing, disparo proativo em volume ou operação regulada que não pode perder o canal, escolha a via oficial — o wpp-business, a Cloud API direto ou a Twilio. O WPP não é o produto certo, e nenhuma engenharia nossa muda isso: a Meta pode banir o número, e o único caminho sem essa exposição é o oficial. Se você precisa de botões, catálogo de produtos, WhatsApp Flows ou verificação de conta, também é oficial: o WPP não implementa nenhum deles.
Se você é um time técnico com um único número, sem exigência de multi-tenancy, auditoria ou alta disponibilidade, a Evolution API ou o WPPConnect resolvem o seu problema sem custo de licença, e resolvem bem. A infraestrutura que justifica o WPP — três processos, Redis Streams, registro de posse, worker de sessão — é excesso para quem tem um número só.
Se você quer contratar em vez de operar, e não se incomoda que a conversa passe por terceiro, um gateway brasileiro como a Z-API entrega em minutos, cobra em real e emite nota. O WPP vence quando as conversas precisam ficar no seu banco, sob as suas permissões, ao lado do resto da sua plataforma.
06Modelo de cobrança e ROInegócio
Precificação em definição. Não há tabela publicada para o WPP na data desta documentação. O que está definido é a unidade: o número de WhatsApp conectado por mês. É a unidade justa aqui porque o custo real do serviço acompanha os sockets vivos — cada número conectado ocupa uma sessão persistente em uma réplica do worker, com memória e conexão dedicadas —, e não o volume de mensagem, que não custa mais para nós.
O que dispara custo.
| Driver | Por quê |
|---|---|
| Números conectados simultaneamente | Cada um mantém um socket vivo e uma fatia de memória no worker de sessão |
| Volume de mensagens processadas | Persistência no Postgres, avaliação de dispatchers e publicação de eventos |
| Armazenamento de mídia recebida | Materialização no File Storage — foto, áudio e documento ficam em S3 |
| Entregas de webhook | Cada evento casado com um dispatcher vira uma ou mais entregas pelo Webhooks Engine |
Comparação de custo — cenário: operação de atendimento com 5 números conectados e 200 mil mensagens por mês, das quais 30 mil são proativas (a empresa iniciando a conversa).
| Catalisa WPP | Z-API | Meta Cloud API / BSP | |
|---|---|---|---|
| Base de cálculo | Por número conectado | Por instância conectada | Por mensagem entregue, por categoria |
| Custo das 170 mil mensagens em janela de atendimento | Sem custo por mensagem | Sem custo por mensagem | Mensagens de serviço em janela de 24h são gratuitas |
| Custo das 30 mil proativas | Sem custo por mensagem | Sem custo por mensagem | Cobradas como utility ou marketing, com preço por país |
| Ordem de grandeza mensal do software | Precificação em definição | 5 instâncias na faixa de R$ 99,99 cada | Depende integralmente do mix de categoria e do país |
| Migração do número | Não | Não | Sim |
| Risco de perder o número | Existe | Existe | Não, por esta causa |
Preços de terceiros consultados em 2026-08-16: o valor de referência da Z-API veio da página pública (z-api.io, plano Ultimate, 1 instância), e o modelo por mensagem da Meta, da documentação oficial (Meta — WhatsApp pricing). A Meta publica as tarifas por país em arquivo à parte e elas mudam; não reproduzimos valor por mensagem aqui de propósito. Nenhum destes números é proposta comercial.
ROI. A conta de guardanapo não é sobre licença — é sobre o que a empresa deixa de gastar e de arriscar.
O ganho direto está no atendimento em janela aberta: em operações de negociação e suporte, a maior parte das mensagens é reativa e longa, e o modelo por mensagem penaliza exatamente isso. O ganho indireto, geralmente maior, é o que não acontece: nenhuma migração de número, nenhuma fila de aprovação de template, nenhum projeto de infraestrutura de duas a quatro semanas para colocar Baileys de pé com sessão persistente, dono único e reconexão.
Do outro lado da conta há um custo real que precisa entrar na planilha: a probabilidade de perder o número. Não temos base estatística própria para atribuir um número a ela, e não vamos inventar um. A recomendação prática, que a §11 detalha, é operar com número dedicado e nunca com o número que a empresa não pode perder — mesmo que a tentação de fazer o contrário seja grande.
07Arquitetura
HTTP (Bearer JWT do IAM)
│
┌───────────────────────────────────┴───────────────────────────────────────┐
│ PROCESSO 1 — API src/wpp/main.ts porta 3023 Hono basePath('/wpp') │
│ │
│ /api/v1/devices deviceRouter 13 rotas │
│ /api/v1/messages messageRouter 9 rotas │
│ /api/v1/chats chatRouter 2 rotas │
│ /api/v1/groups groupRouter 9 rotas │
│ /api/v1/config configRouter 2 rotas │
│ /api/v1/operations operationRouter 2 rotas │
│ /api/v1/dispatchers dispatcherRouter 8 rotas │
│ /health │
│ │
│ A API NUNCA fala com o WhatsApp. Ela grava uma operation, enfileira um │
│ job e devolve 202 + correlationId. Leitura vem do Postgres. │
└───────────────────────────────────┬───────────────────────────────────────┘
│ Redis Streams
┌────────────────────────┴────────────────────────┐
│ WppQueueService.enqueue │
│ │
│ job device-bound? ── não ──▶ stream global │
│ │ sim (connect, │
│ ▼ session-restore)│
│ quem é o dono? ── ninguém ──▶ wpp:pending:… + │
│ │ dono X session-restore │
│ ▼ │
│ wpp:inbox:<X> (a réplica que tem o socket) │
└────────────────────────┬────────────────────────┘
│
┌───────────────────────────────────┴───────────────────────────────────────┐
│ PROCESSO 2 — SESSION WORKER src/wpp/main-session-worker.ts sem HTTP │
│ │
│ WppConsumer grupo 'wpp-consumers', XREADGROUP + XAUTOCLAIM a cada 15s │
│ │ │
│ ▼ │
│ SessionManager ──▶ DriverFactory ──▶ BaileysDriver ══════▶ WhatsApp │
│ │ ▲ ║ │
│ │ posse │ ║ eventos │
│ ▼ │ ▼ │
│ OwnerRegistry wpp:device-owner:<org>:<dev> persistência + eventos │
│ claim/refresh por EVAL Lua, TTL 45s (mensagem, chat, contato, │
│ heartbeat a cada 30s grupo, reação) │
└───────────────────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
┌───────────┐ ┌────────────────────┐ ┌──────────────────┐
│ PostgreSQL│ │ Redis pub/sub │ │ EventPublisher │
│ schema wpp│ │ wpp-events:<org> │ │ wpp.v1.* │
│ 14 tabelas│ └─────────┬──────────┘ └────────┬─────────┘
└───────────┘ │ │
▼ ▼
┌────────────────────────────────┐ ┌───────────────────────┐
│ PROCESSO 3 — SSE │ │ DispatcherService │
│ src/wpp/main-sse.ts │ │ pré-filtro por device │
│ GET /api/v1/events/:orgId │ │ e JSONPath │
└────────────────────────────────┘ └───────────┬───────────┘
▼
┌───────────────────────┐
│ Webhooks Engine │
│ RSA-SHA256, retry, │
│ log de entrega │
└───────────────────────┘
Decisões não óbvias.
A API não fala com o WhatsApp. Nunca. Todo verbo que toca o socket — enviar, conectar, criar grupo — grava uma linha em
wpp_operation_results, enfileira um job e devolve202comcorrelationId. O motivo é que o socket vive em outro processo, possivelmente em outra máquina, e uma requisição HTTP não pode esperar por um handshake com o WhatsApp que às vezes leva dezenas de segundos. O preço é que o integrador precisa fazer polling ou assinar webhook; não existe caminho síncrono de envio.Três processos, e não um. O worker de sessão mantém sockets abertos por horas e não pode ser reiniciado por um deploy de API. O SSE segura conexões longas de browser e escala por número de espectador, não por número de dispositivo. Separar deixa cada um escalar e reiniciar pelo seu próprio motivo. O worker sobe com
bun run dist/wpp/main-session-worker.mjse o SSE comdist/wpp/main-sse.mjs.Roteamento por afinidade de dispositivo. Um job de envio precisa cair exatamente na réplica que tem o socket daquele número — em qualquer outra, ele falha com "No active session". Por isso
DEVICE_BOUND_QUEUESclassifica os jobs: envio, grupo, ping, pairing-code, reconnect, reset, disconnect e download de mídia vão para o inbox do dono (wpp:inbox:<consumer>);connectesession-restoresão cluster-wide, porque criam socket e devem se distribuir. Sem dono vivo, o job é estacionado emwpp:pending:<org>:<device>e umsession-restoreé enfileirado para acordar o número.A identidade da réplica vem do hostname, não do PID. Cada contêiner tem seu próprio namespace de PID, e o mesmo arranjo
dumb-init → bunproduz o mesmo PID em todas as réplicas. Usar PID colapsaria todas elas no mesmo inbox e traria de volta o bug de "No active session".defaultConsumerName()usaHOSTNAME, que o Docker Swarm garante único por tarefa.Reivindicar posse é uma operação atômica em Lua.
GETseguido deSETtem uma janela em que duas réplicas leem vazio simultaneamente — é o que acontece num restart em massa, e a assinatura já foi observada: doisXADDde hand-off para o mesmo device no mesmo milissegundo, vindos de réplicas diferentes. O scriptCLAIM_IF_FREE_OR_MINEroda inteiro numa execução do Redis. A liberação usaRELEASE_IF_MINE, porque umDELincondicional no shutdown apagaria a posse de quem já assumiu o número.Falha aberta em cima do Redis, nas duas defesas. Tanto o pré-voo de posse quanto o orçamento de conexão tratam erro de Redis como permissão para seguir. É um trade-off deliberado: um soluço de Redis não pode derrubar todos os
connectdo cluster. O risco aceito é que, com Redis fora, a proteção contra socket duplicado não vale.A credencial da sessão é cifrada com chave que não está no banco. As linhas de
wpp_authguardamBytescifrado em AES-256-GCM, no formatobase64(iv ‖ authTag ‖ ciphertext), com a chave-mestra emWPP_BAILEYS_MASTER_KEY(64 caracteres hex). Um dump do Postgres, sozinho, não sequestra nenhum WhatsApp.Orçamento de conexão no único ponto de estrangulamento. O contador fica em
getOrCreateSession, que é por onde passam oconnectexterno, a reconexão interna e o restore. Isso é proposital: proteger só a rota HTTP deixaria o laço de reconexão livre para martelar o WhatsApp, que é justamente o modo de falha que leva a bloqueio temporário do número.Estado terminal recusa conexão simples.
logged_outebannedsignificam credencial morta. Umconnectcomum ali só produz 401 em laço contra o WhatsApp — o caminho mais rápido para agravar o bloqueio. A rota devolve409 requires_repaire exigeforce: true, que apaga o estado de autenticação antes de tentar um registro limpo.
Monolito vs. standalone. Em monolito, a API, o worker e o SSE convivem no mesmo processo e o roteamento por afinidade é trivial, porque só existe uma réplica. Em standalone — o modo de produção — são três serviços distintos, e é aí que posse, inbox e estacionamento de job importam. WPP_SESSION_WORKER_ENABLED e WPP_SSE_ENABLED controlam quem liga o quê.
08Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
| Device | Um número de WhatsApp conectado. É a unidade de sessão: um device, um socket, um dono. O deviceId é uma string escolhida por você, única dentro da organização. |
| JID | Endereço do WhatsApp. 5511999999999@s.whatsapp.net para pessoa, ...@g.us para grupo, ...@broadcast para lista, ...@lid para identidade opaca. |
| LID | Linked ID. Identidade opaca que o WhatsApp usa no lugar do telefone em alguns contextos. Não carrega número; precisa ser resolvida para o PN. |
| PN | Phone Number. O JID no formato telefone, <dígitos>@s.whatsapp.net. É sempre o destino correto de um envio. |
| Pareamento | Ligar o número à plataforma, por QR code ou por código de oito caracteres digitado no celular. |
| Operation | Registro de uma operação assíncrona, endereçado por correlationId. É como você descobre o resultado de tudo que devolve 202. |
| Dispatcher | Assinatura de webhook do WPP. Traduz filtro de domínio (device, tipo de mensagem, condição JSONPath) numa assinatura do Webhooks Engine. |
| Owner / posse | A réplica do worker que tem o socket vivo de um device. Registrada em wpp:device-owner:<org>:<device> com TTL de 45 segundos. |
| Device-bound job | Job que só funciona na réplica dona do socket. Roteado para o inbox dela; sem dono, é estacionado. |
| Fantasma | Device com status open no banco e nenhuma posse viva no Redis. A linha diz conectado e não existe socket. |
| tctoken | Token de confiança que o WhatsApp cria quando você recebe mensagem de alguém. Sem ele, o envio de alcance é recusado com erro 463. |
Modelo de dados — schema wpp no PostgreSQL, 14 modelos.
| Modelo Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
WppDevice | wpp.wpp_devices | Um número conectado | Único (organizationId, deviceId), status, driver, jid, lid, lastQr, qrGeneratedAt, pairingCode, syncFullHistory |
WppAuth | wpp.wpp_auth | Credencial da sessão Baileys, cifrada | Único (organizationId, deviceId, key), value (Bytes) |
WppMessage | wpp.wpp_messages | Mensagem enviada ou recebida | keyId, remoteJid, fromMe, type, status, ack, payload (JSON), mediaFileId |
WppMessageReaction | wpp.wpp_message_reactions | Reação a uma mensagem | Único (organizationId, messageId, fromJid), emoji |
WppOutboundMessage | wpp.wpp_outbound_messages | Fila legada de saída de texto | remoteJid, text, status (padrão queued) |
WppChat | wpp.wpp_chats | Conversa | Único (organizationId, remoteJid), type, unreadCount, archived, lastMessageAt |
WppContact | wpp.wpp_contacts | Agenda, escopada por device | Único (organizationId, deviceId, jid), lid, phone (só dígitos), isBlocked |
WppGroup | wpp.wpp_groups | Metadados de grupo | Único (organizationId, groupJid), chatId único, subject, size, announce, restrict |
WppGroupParticipant | wpp.wpp_group_participants | Participante de grupo | Único (organizationId, groupId, jid), role, leftAt |
WppOperationResult | wpp.wpp_operation_results | Resultado de operação assíncrona | correlationId único, operation, status, request, result, error |
WppConnectionLog | wpp.wpp_connection_logs | Diário de conexão do device | eventType, statusCode, disconnectReason, attemptNumber, backoffMs |
WppQrConnectionLink | wpp.wpp_qr_connection_links | Link de pareamento delegado entre organizações | token único, status, expiresAt — sem rota HTTP hoje, ver §15 |
WppConfig | wpp.wpp_configs | Configuração por organização | organizationId único, retryAttempts, retryBackoffMs, flags persist*, downloadMedia |
WppDispatcher | wpp.wpp_dispatchers | Assinatura de webhook do WPP | mode, status, events[], messageTypes[], deviceId, endpointUrl, conditions, subscriptionId |
Enumerações
| Enum | Valores |
|---|---|
WppDeviceStatus | connecting · qr · pairing · open · closed · reset · banned · logged_out |
WppDeviceDriver | baileys |
WppMessageStatus | pending · sent · delivered · read · played · failed |
WppChatType | individual · group · broadcast |
WppOperationStatus | pending · processing · completed · failed |
WppConnectionEventType | state_change · pairing · reconnect_attempt · reconnect_skipped · circuit_breaker · lock_event · error |
WppDispatcherMode | BASIC · ADVANCED |
WppDispatcherStatus | ACTIVE · PAUSED |
Ciclo de vida do device — a fonte número um de dúvida de integração.
POST /devices (linha criada, status inicial = qr)
│
│ POST /devices/:deviceId/connect → 202 + correlationId
▼
┌────────────┐
│ connecting │ socket abrindo. NADA reconcilia este estado sozinho (§15).
└─────┬──────┘
│ o socket emite o QR
▼
┌──────────┐ GET /devices/:deviceId/qr ── QR vale 60s e é reemitido ──┐
│ qr │◀──────────────────────────────────────────────────────────────┘
└────┬─────┘
│ ┌──────────────────────────────────────────────┐
│ usuário lê o QR │ caminho alternativo — código de 8 caracteres │
│ │ POST /devices/:deviceId/pairing-code │
│ │ exige connect ANTES (socket precisa existir)│
│ └───────────────────┬──────────────────────────┘
│ ▼
│ ┌──────────┐
│ │ pairing │
│ └────┬─────┘
▼ │ usuário digita no celular
┌──────────────────────────────────────────────────────┐
│ open │ conectado e útil
└───┬──────────────────────────────────┬───────────────┘
│ queda recuperável │ queda NÃO recuperável
│ (rede, 428, 515) │ 401 LoggedOut · 410 BadSession · 405 Banned
▼ ▼
reconexão automática ┌────────────┐ ┌────────┐
backoff exponencial │ logged_out │ │ banned │ TERMINAIS
base 2000ms, até 5 tentativas └─────┬──────┘ └───┬────┘
│ │ │
│ estourou o teto └───────┬───────┘
▼ │ connect simples → 409 requires_repair
┌────────┐ │ exige POST /connect {"force": true}
│ closed │ circuito aberto; reconectável ▼ (apaga a credencial morta)
└────────┘ por connect explícito volta a `qr` e repareia do zero
POST /devices/:deviceId/reset → status `reset`, descarta a sessão. Último recurso.
Como um envio chega ao WhatsApp
POST /messages/send 202 + correlationId (a API para aqui)
│
▼
wpp_operation_results (pending)
│
▼
WppQueueService ── device-bound ──▶ quem tem a posse do device?
│ │
│ dono X ─────────┴──────── ninguém
│ │ │
▼ ▼ ▼
wpp:inbox:<X> wpp:pending:<org>:<dev>
│ + enfileira session-restore
▼ │
BaileysDriver.dispatchSend │ quando alguém
│ │ assume o device,
normaliza @lid → <phone>@s.whatsapp.net │ o pending é drenado
confere USync/onWhatsApp └──────────┘
│
┌───────────────┴──────────────┐
▼ ▼
número existe número NÃO existe
envia, grava messageId erro not_on_whatsapp (422)
│ — em vez de "entregue" mentiroso
▼
operation = completed, evento wpp.v1.message.sent
09Referência da API
Prefixo: /wpp. Em staging, a base segue o padrão https://wpp.bb.stg.catalisa.app/wpp (AMBIENTES.md).
Todas as rotas abaixo exigem authMiddleware (Bearer JWT do IAM) e requireOrganization — um token sem organizationId recebe 403 antes de qualquer regra de negócio. Não há rota pública neste building block, com exceção de /wpp/health.
Permissões do WPP
| Permissão | Concede |
|---|---|
WPP_READ | Leitura de tudo, mais ping (que só sonda o socket) |
WPP_SEND | Envio de mensagem em qualquer formato |
WPP_ADMIN | Ciclo de vida do device e operações de grupo |
WPP_DISPATCHERS_READ | Leitura de dispatchers e logs de entrega |
WPP_DISPATCHERS_WRITE | Criar, alterar, pausar e reenviar |
Devices — /wpp/api/v1/devices
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /wpp/api/v1/devices | Cria o device. 201 | WPP_ADMIN |
GET | /wpp/api/v1/devices | Lista, paginado. Filtros filter[status], filter[driver] | WPP_READ |
GET | /wpp/api/v1/devices/:id | Busca por UUID interno | WPP_READ |
DELETE | /wpp/api/v1/devices/:id | Remove por UUID interno. 204 | WPP_ADMIN |
POST | /wpp/api/v1/devices/:deviceId/connect | Enfileira conexão. 202 | WPP_ADMIN |
POST | /wpp/api/v1/devices/:deviceId/disconnect | Enfileira desconexão. 202 | WPP_ADMIN |
POST | /wpp/api/v1/devices/:deviceId/reconnect | Enfileira reconexão. 202 | WPP_ADMIN |
POST | /wpp/api/v1/devices/:deviceId/reset | Descarta a sessão e força novo pareamento. 202 | WPP_ADMIN |
POST | /wpp/api/v1/devices/:deviceId/ping | Sonda a conectividade do socket. 202 | WPP_READ |
POST | /wpp/api/v1/devices/:deviceId/pairing-code | Pede código de 8 caracteres. 202 | WPP_ADMIN |
GET | /wpp/api/v1/devices/:deviceId/pairing-code | Lê o código já gerado | WPP_READ |
GET | /wpp/api/v1/devices/:deviceId/qr | Lê o QR corrente | WPP_READ |
GET | /wpp/api/v1/devices/:deviceId/status | Só o status | WPP_READ |
Armadilha real.
GETeDELETE /devices/:idusam o UUID interno e validam o formato; todas as demais rotas usam odeviceIdque você escolheu. Passar odeviceIdemGET /devices/:iddevolve erro de validação de UUID, não404.
Mensagens — /wpp/api/v1/messages
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /wpp/api/v1/messages/send | Texto. 202 | WPP_SEND |
POST | /wpp/api/v1/messages/send-media | Imagem, vídeo, áudio, documento, figurinha. 202 | WPP_SEND |
POST | /wpp/api/v1/messages/send-reaction | Reação a uma mensagem. 202 | WPP_SEND |
POST | /wpp/api/v1/messages/send-template | Template. 202 — ver §15 | WPP_SEND |
POST | /wpp/api/v1/messages/send-location | Localização. 202 | WPP_SEND |
POST | /wpp/api/v1/messages/send-contact | Cartão de contato. 202 | WPP_SEND |
GET | /wpp/api/v1/messages | Lista com filtros | WPP_READ |
GET | /wpp/api/v1/messages/media/:messageId | Materializa a mídia. 302 ou 202 | WPP_READ |
GET | /wpp/api/v1/messages/:id | Busca por UUID interno | WPP_READ |
Filtros de GET /messages: filter[chatId], filter[remoteJid], filter[fromMe], filter[type], filter[status], filter[startDate], filter[endDate], mais page[number] e page[size].
Conversas e contatos — /wpp/api/v1/chats
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /wpp/api/v1/chats | Lista conversas. Filtros filter[type], filter[archived] | WPP_READ |
GET | /wpp/api/v1/chats/contacts | Lista contatos. deviceId é obrigatório | WPP_READ |
deviceIdna query é exigido em/chats/contactspor segurança: a agenda é do número, não da organização. Sem ele a rota devolve400, e é assim de propósito — dois números da mesma empresa não compartilham agenda.
Grupos — /wpp/api/v1/groups
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /wpp/api/v1/groups | Cria grupo. 202 | WPP_ADMIN |
GET | /wpp/api/v1/groups | Lista, paginado | WPP_READ |
GET | /wpp/api/v1/groups/:id | Busca grupo com participantes | WPP_READ |
GET | /wpp/api/v1/groups/:groupJid/participants | Só os participantes | WPP_READ |
PUT | /wpp/api/v1/groups/:groupJid/subject | Renomeia. 202 | WPP_ADMIN |
PUT | /wpp/api/v1/groups/:groupJid/description | Altera a descrição. 202 | WPP_ADMIN |
POST | /wpp/api/v1/groups/:groupJid/participants | Adiciona, remove, promove, rebaixa. 202 | WPP_ADMIN |
POST | /wpp/api/v1/groups/:groupJid/leave | Sai do grupo. 202 | WPP_ADMIN |
DELETE | /wpp/api/v1/groups/:groupJid | Encerra o grupo. 202. Exige deviceId no corpo | WPP_ADMIN |
Inconsistência conhecida. As duas rotas de leitura —
GET /groups/:ideGET /groups/:groupJid/participants— resolvem pelo UUID interno do grupo, apesar do nome do parâmetro na segunda. As rotas de escrita usam o JID de verdade. Passar o JID nas rotas de leitura devolve404. Está registrado na §15.
Configuração — /wpp/api/v1/config
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /wpp/api/v1/config | Configuração da organização | WPP_READ |
PATCH | /wpp/api/v1/config | Altera a configuração | WPP_ADMIN |
Operações — /wpp/api/v1/operations
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /wpp/api/v1/operations | Lista. Filtro filter[status] | WPP_READ |
GET | /wpp/api/v1/operations/:correlationId | Resultado de uma operação | WPP_READ |
Dispatchers — /wpp/api/v1/dispatchers
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /wpp/api/v1/dispatchers | Cria e provisiona a assinatura no Webhooks Engine. 201 | WPP_DISPATCHERS_WRITE |
GET | /wpp/api/v1/dispatchers | Lista. limit, offset, status | WPP_DISPATCHERS_READ |
GET | /wpp/api/v1/dispatchers/:id | Busca | WPP_DISPATCHERS_READ |
PATCH | /wpp/api/v1/dispatchers/:id | Altera e ressincroniza a assinatura | WPP_DISPATCHERS_WRITE |
DELETE | /wpp/api/v1/dispatchers/:id | Exclusão lógica — deletedAt preenchido, assinatura desabilitada, logs preservados. 204 | WPP_DISPATCHERS_WRITE |
POST | /wpp/api/v1/dispatchers/:id/toggle | Alterna ACTIVE ↔ PAUSED. Evento recebido enquanto pausado é descartado, não enfileirado | WPP_DISPATCHERS_WRITE |
GET | /wpp/api/v1/dispatchers/:id/delivery-logs | Log de entregas. page, pageSize, status ∈ PENDING · DELIVERED · FAILED · RETRYING | WPP_DISPATCHERS_READ |
POST | /wpp/api/v1/dispatchers/:id/delivery-logs/:logId/retry | Reenvia uma entrega, ignorando o backoff | WPP_DISPATCHERS_WRITE |
Saúde e eventos ao vivo
| Método | Rota | Processo | Descrição |
|---|---|---|---|
GET | /wpp/health | API | Sonda de vida do serviço HTTP |
GET | /api/v1/events/:organizationId | SSE (processo separado) | Stream Server-Sent Events da organização |
O SSE aceita o token no header Authorization: Bearer ou na query ?access_token=, porque EventSource de navegador não envia header. O organizationId do token precisa ser igual ao do path — não há como assinar o canal de outra organização.
POST /wpp/api/v1/devices
Request
{ "deviceId": "atendimento-principal", "driver": "baileys", "syncFullHistory": false }
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
deviceId | string (1–100) | Sim | Identificador que você escolhe. Único dentro da organização |
driver | "baileys" | Não | Padrão baileys. É o único valor aceito |
syncFullHistory | boolean | Não | Puxa o histórico completo no pareamento. Padrão false — ligar custa tempo e memória |
O corpo também é aceito no formato JSON:API ({"data":{"attributes":{...}}}).
Erros — 400 de schema, 409 se o deviceId já existe na organização.
POST /wpp/api/v1/devices/:deviceId/connect
Enfileira a criação do socket. Corpo opcional.
{ "force": true }
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
force | boolean | Não | Reparo explícito. Apaga a credencial armazenada antes de tentar registrar do zero. Necessário para device em logged_out ou banned |
Resposta 202
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }
Erros
| Status | Quando |
|---|---|
403 | Token sem organizationId |
404 | Device inexistente na organização |
409 | requires_repair — device em logged_out/banned sem force: true |
409 | connect_budget_exceeded — mais de 30 tentativas nesta hora para este número |
409 | owner_held — outra réplica viva já tem o socket. Transitório: a posse expira em 45s |
O
202significa enfileirado, não conectado. Acompanhe porGET /operations/:correlationIdou porGET /devices/:deviceId/status.
GET /wpp/api/v1/devices/:deviceId/qr
Resposta 200
{
"qr": "2@Xb3k...",
"generatedAt": "2026-08-16T14:02:11.000Z",
"status": "qr",
"expired": false,
"expiresIn": 43
}
O QR vale 60 segundos. Passado esse tempo o campo qr volta null e expired fica true — o socket gera outro sozinho, então basta consultar de novo. Renderize o conteúdo de qr como QR code; ele já é a string que o WhatsApp espera.
POST /wpp/api/v1/messages/send
{
"deviceId": "atendimento-principal",
"jid": "5511999999999@s.whatsapp.net",
"text": "Sua proposta foi aprovada."
}
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
deviceId | string | Sim | Qual número envia |
jid | string | Sim | Destino. Use sempre o PN, <dígitos>@s.whatsapp.net |
text | string | Sim | Conteúdo |
Resposta 202 — {"data":{"enqueued":true,"correlationId":"..."}}.
Erros que aparecem depois, na operation — o 202 só diz que o job entrou na fila. O resultado real chega em GET /operations/:correlationId:
error da operation | Significa | O que fazer |
|---|---|---|
not_on_whatsapp | O número não tem conta de WhatsApp. Bloqueado de propósito, para não fingir entrega | Valide o número na origem |
ack com 463 | Time-lock de alcance: o destinatário nunca te escreveu, ou a conta acumulou envios frios | Espere o contato iniciar. Não insista — o bloqueio agrava |
No active session | Não havia socket vivo quando o job foi processado | Reconecte o device e reenvie |
GET /wpp/api/v1/messages/media/:messageId
Materializa a mídia de uma mensagem recebida no File Storage. Tem dois comportamentos:
- Já materializada →
302para a URL assinada. Siga o redirecionamento. - Ainda não →
202comcorrelationIdepollUrl, porque baixar exige o socket vivo, que está em outro processo.
{
"data": {
"enqueued": true,
"correlationId": "b21c...",
"pollUrl": "/api/v1/operations/b21c..."
}
}
| Status | Quando |
|---|---|
400 | A mensagem não é de um tipo com mídia (image, video, audio, document, sticker) |
404 | Mensagem inexistente ou de outra organização |
409 | Linha antiga, sem deviceId gravado — não há como saber qual socket usar |
A mídia do WhatsApp expira no CDN da Meta. Materializar meses depois falha, e não há recuperação. Se a mídia importa, materialize assim que o evento chegar.
POST /wpp/api/v1/dispatchers
{
"name": "CRM — mensagens recebidas",
"mode": "BASIC",
"messageTypes": ["text", "image"],
"deviceId": "atendimento-principal",
"endpointUrl": "https://api.suaempresa.com.br/webhooks/wpp",
"timeoutMs": 15000,
"retryConfig": { "maxRetries": 5, "retryBackoffMs": 1000, "retryBackoffMultiplier": 2 }
}
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
name | string (1–200) | Sim | Nome do dispatcher |
mode | BASIC | ADVANCED | Não | Padrão BASIC. ADVANCED usa a lista events diretamente |
messageTypes | string[] | Não | Em BASIC, vira wpp.v1.message.received.<tipo> |
events | string[] | Não | Em ADVANCED, os tipos de evento, com * e ** como curinga |
deviceId | string | Não | Restringe a um número. Ativa o pré-filtro no WPP |
endpointUrl | string (URL) | Sim | Precisa ser host público. localhost, 10.*, 172.16–31.*, 192.168.*, .internal e .local são recusados |
conditions | object | Não | Filtros JSONPath com match: "all" ou "any" |
timeoutMs | int 1000–60000 | Não | Padrão 30000 |
retryConfig | object | Não | maxRetries 0–10 (padrão 5), backoff e multiplicador |
Como o filtro é montado. Se houver deviceId ou conditions, o WPP avalia primeiro e só republica o que passou, como wpp.dispatch.<id>; a assinatura no Webhooks Engine escuta só esse evento. Sem pré-filtro, o modo ADVANCED assina os events informados, o BASIC assina wpp.v1.message.received.<tipo> para cada messageTypes, e sem nenhum dos dois cai no padrão wpp.v1.message.received.*.
Catálogo de eventos v1
| Evento | Quando |
|---|---|
wpp.v1.device.connected | Socket abriu |
wpp.v1.device.disconnected | Socket caiu. Carrega recoverable, requiresRepair, deviceStatus |
wpp.v1.device.qr.updated | Novo QR disponível |
wpp.v1.device.status.changed | Mudança de status |
wpp.v1.message.received.<tipo> | Mensagem recebida — text, image, audio, video, document, sticker, location, contact, reaction... |
wpp.v1.message.sent | Mensagem enviada por você confirmada |
wpp.v1.message.failed | Envio falhou |
wpp.v1.message.status.updated | Reservado — não é emitido hoje. Ver §15 |
wpp.v1.message.reaction | Reação |
wpp.v1.chat.updated · wpp.v1.group.updated · wpp.v1.contact.updated | Sincronização de conversa, grupo e agenda |
Curingas: * casa um segmento, ** casa vários. wpp.v1.message.received.* e wpp.v1.device.* são os padrões mais úteis.
Assine
wpp.v1.*, nuncawpp.*. O namespace semv1é interno e não é destinado a assinante externo. Assinarwpp.message.receivednão gera erro — simplesmente não chega evento nenhum, o que é bem pior de diagnosticar.
Corpo de wpp.v1.message.received.text
{
"organizationId": "b0000000-0000-0000-0000-000000000001",
"messageId": "0f2a...",
"keyId": "3EB0C767D0...",
"remoteJid": "5511999999999@s.whatsapp.net",
"fromMe": false,
"type": "text",
"deviceId": "atendimento-principal",
"pushName": "Maria",
"senderPhone": null
}
senderPhonesó é preenchido quando oremoteJidé um@lidde conversa individual e o driver consegue resolver o telefone. Em grupo, o remetente está emparticipante a resolução de LID não acontece hoje.
10Início rápido
Do zero ao primeiro WhatsApp pareado e à primeira mensagem enviada.
Os comandos abaixo não foram executados na escrita desta documentação: parear um WhatsApp exige um celular com um número real na mão. Os formatos de request e response vêm do código.
1. Autenticar no IAM
BASE=https://wpp.bb.stg.catalisa.app/wpp/api/v1
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
2. Criar o device
curl -s -X POST "$BASE/devices" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"deviceId":"demo-01","driver":"baileys"}' | jq
{ "data": { "id": "9c1f...", "deviceId": "demo-01", "status": "qr", "driver": "baileys" } }
3. Pedir a conexão
curl -s -X POST "$BASE/devices/demo-01/connect" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' | jq
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }
4. Ler o QR e parear
curl -s "$BASE/devices/demo-01/qr" -H "Authorization: Bearer $TOKEN" | jq
{ "qr": "2@Xb3k...", "status": "qr", "expired": false, "expiresIn": 43 }
Renderize o valor de qr como QR code — qrencode -t ANSIUTF8 "$(...)" resolve no terminal — e leia no celular em WhatsApp → Aparelhos conectados → Conectar um aparelho. Se expired vier true, consulte de novo: o socket reemite sozinho.
5. Confirmar que conectou
curl -s "$BASE/devices/demo-01/status" -H "Authorization: Bearer $TOKEN" | jq
{ "status": "open" }
open é o único status em que enviar funciona.
6. Enviar a primeira mensagem
CID=$(curl -s -X POST "$BASE/messages/send" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"deviceId": "demo-01",
"jid": "5511999999999@s.whatsapp.net",
"text": "Primeira mensagem pelo Catalisa WPP."
}' | jq -r '.data.correlationId')
curl -s "$BASE/operations/$CID" -H "Authorization: Bearer $TOKEN" | jq
{
"data": {
"correlationId": "1a2b...",
"operation": "send-message",
"status": "completed",
"result": { "messageId": "3EB0C767D0...", "remoteJid": "5511999999999@s.whatsapp.net" }
}
}
Envie primeiro para um número que já conversou com você. Um destinatário que nunca te escreveu costuma ser recusado com o time-lock
463— é a regra anti-spam da Meta, não um defeito da plataforma. Detalhes na §11.
Credenciais de staging, de AMBIENTES.md. Nunca use credencial de produção em documentação ou script de exemplo.
11Receitas
Parear pelo código de 8 caracteres, sem QR
Serve para quem vai parear por telefone com alguém do outro lado da linha, sem tela para mostrar o QR.
# 1. O socket precisa existir ANTES. Sem connect, o pedido falha com "No active session".
curl -s -X POST "$BASE/devices/demo-01/connect" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
sleep 12
# 2. Pedir o código
CID=$(curl -s -X POST "$BASE/devices/demo-01/pairing-code" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"phoneNumber":"5511930802555"}' | jq -r '.data.correlationId')
# 3. Ler o resultado
curl -s "$BASE/operations/$CID" -H "Authorization: Bearer $TOKEN" | jq '.data.result'
# 4. Ou ler direto do device
curl -s "$BASE/devices/demo-01/pairing-code" -H "Authorization: Bearer $TOKEN" | jq
No celular: WhatsApp → Aparelhos conectados → Conectar aparelho → Conectar com número de telefone → digite o código.
Armadilhas.
- Conecte antes. Pedir o código num device sem socket em memória falha. Os 12 segundos de espera não são superstição: o socket precisa chegar ao estado
qr. - Digite rápido. O socket reemite o QR a cada 20–60 segundos, e isso encurta a validade real do código. O
expiresAtque devolvemos é uma dica de 180 segundos; quem impõe a validade de verdade é o WhatsApp. - O telefone é normalizado para dígitos. O schema remove
+, espaço e hífen automaticamente — um+cru vira JID inválido e o vínculo falha com 401. Mínimo de 10 dígitos. - Não reaproveite device antigo. Device que já esteve pareado e caiu tende a falhar aqui; use um recém-criado ou passe por
reset.
Histórico completo em `docs/wpp-pairing-code-fix.md`.
Receber mensagens no seu sistema por webhook
# 1. Criar o dispatcher
DISP=$(curl -s -X POST "$BASE/dispatchers" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "CRM — recebidas do atendimento",
"mode": "BASIC",
"messageTypes": ["text", "image", "audio"],
"deviceId": "demo-01",
"endpointUrl": "https://api.suaempresa.com.br/webhooks/wpp",
"timeoutMs": 15000
}' | jq -r '.data.id')
# 2. Conferir as entregas
curl -s "$BASE/dispatchers/$DISP/delivery-logs?status=FAILED" \
-H "Authorization: Bearer $TOKEN" | jq
# 3. Reenviar uma entrega específica
curl -s -X POST "$BASE/dispatchers/$DISP/delivery-logs/<logId>/retry" \
-H "Authorization: Bearer $TOKEN" | jq
Verifique a assinatura. A entrega vem com x-webhook-id, x-webhook-timestamp, x-webhook-signature, x-webhook-key-id e x-webhook-version. A assinatura é RSA-SHA256 (não HMAC) sobre id\ntimestamp\ncorpo-cru, e a chave pública se busca pelo keyId no Webhooks Engine. Receita completa, com código em Node, Python, Go e bash, em `docs/wpp/webhook-integration-guide.md`.
Armadilhas. Estas são as que o suporte de integração mais responde.
- Seja idempotente. O Webhooks Engine reenvia em
429,5xxe timeout — 5 tentativas por padrão, backoff1s → 2s → 4s → 8s → 16s. Deduplique porx-webhook-idnuma tabela com chave primária. - Não é HMAC. É RSA-SHA256 sobre
id\ntimestamp\ncorpo, com\nliteral. Tire o prefixov1=antes de decodificar o base64, e verifique sobre os bytes crus do corpo — reserializar o JSON invalida a assinatura. Useexpress.raw()ou equivalente. - Cacheie a chave pública por
keyId. As chaves rotacionam; busque de novo só quando aparecer umkeyIddesconhecido. - Rejeite deriva de relógio maior que 5 minutos no
x-webhook-timestamp. - Responda
200em até 2 segundos e jogue o trabalho pesado numa fila sua. O timeout padrão é 30 segundos, mas segurar a conexão é a causa mais comum de retentativa desnecessária. 3xxnão é retentado. O engine não segue redirect — aponte o dispatcher para a URL final.endpointUrlprecisa ser público. Host privado é recusado na criação com400— é proteção contra SSRF, não um bug de validação. Em desenvolvimento, use um túnel.messageTypesé minúsculo aqui. No wpp-business é MAIÚSCULO. Quem integra os dois erra este.ADVANCEDnão deriva nada. Nesse modo, oseventsque você informar são exatamente os assinados. Errou a grafia, não chega evento nenhum e não há erro.- Pré-filtro muda o que a assinatura escuta. Com
deviceIdouconditions, a assinatura passa a escutarwpp.dispatch.<id>e o corpo entregue trazoriginalPayloadeoriginalEventType. Trate os dois formatos.
Enviar sem tomar 463 nem sumir com a mensagem
Esta é a receita que o suporte mais responde.
# SEMPRE envie para o PN. Nunca para o @lid cru.
curl -s -X POST "$BASE/messages/send" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"deviceId":"demo-01","jid":"5511999999999@s.whatsapp.net","text":"..."}'
As quatro regras que evitam quase todo incidente de entrega.
- Envie sempre para
<dígitos>@s.whatsapp.net. O driver normaliza@lidpara PN antes de enviar, mas quem chama deve mandar o PN. O campophonedeGET /chats/contactsexiste exatamente para isso — ele já vem só com dígitos. - Espere o contato falar primeiro. O WhatsApp cria o
tctokenquando você recebe mensagem de alguém. Sem ele, o envio de alcance volta com ack463. Não é bug, é anti-spam da Meta. - Não faça disparo frio em série. O time-lock
463é cumulativo na conta remetente. Vários frios seguidos travam o número inteiro, inclusive para contatos que antes recebiam. É o caminho mais direto para perder o número. - Não confie em "delivered" sozinho. A operação pode ficar
completedcommessageIdenquantowpp_messages.statusvirafailed. Status real é ack semerror, idealmente com confirmação de leitura.
Investigação completa em `docs/wpp-send-463-lid-learnings.md`.
Recuperar um device que parou de funcionar
Na ordem, do menos invasivo ao mais.
# 1. Qual o estado?
curl -s "$BASE/devices/demo-01/status" -H "Authorization: Bearer $TOKEN" | jq
| Status | Leitura | Ação |
|---|---|---|
open | Saudável | Nenhuma. Se ainda assim não envia, veja a receita do 463 |
connecting com o registro velho | Preso. Nada reconcilia este estado sozinho | connect |
closed | Circuito aberto após esgotar as reconexões | connect |
logged_out | Credencial morta no WhatsApp | connect com force: true, depois repareie |
banned | Número bloqueado pelo WhatsApp | Código não resolve. Ver §15 |
# 2. CONNECT, não RECONNECT.
curl -s -X POST "$BASE/devices/demo-01/connect" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
# 3. Device terminal exige reparo explícito
curl -s -X POST "$BASE/devices/demo-01/connect" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"force": true}'
Armadilhas.
reconnectnão serve para socket morto. Ele é device-bound: exige a possewpp:device-owner:*viva, e ela tem TTL de 45 segundos. Socket parado há mais que isso significa posse expirada e falha de roteamento. Useconnect, que é cluster-wide e cria o socket. Este é o erro de diagnóstico mais comum (postmortem de 2026-07-29, §6).409 owner_heldé transitório. Outra réplica viva tem o socket. Espere 45 segundos e tente de novo, ou simplesmente não faça nada — se o socket está vivo, o device está funcionando.409 connect_budget_exceededé você. Trinta tentativas nesta hora para este número. O freio existe para proteger o número; insistir por outro caminho aumenta o risco de bloqueio. Descubra o que está martelando.reseté o último recurso. Ele descarta a sessão e exige novo pareamento presencial, com celular na mão.
Materializar a mídia que chegou
MSG=$(curl -s "$BASE/messages?filter[type]=image&page[size]=1" \
-H "Authorization: Bearer $TOKEN" | jq -r '.data[0].id')
# -L segue o 302 quando a mídia já está no File Storage
curl -sL "$BASE/messages/media/$MSG" -H "Authorization: Bearer $TOKEN" \
-o imagem.jpg -w "%{http_code}\n"
Se voltar 202, a mídia ainda não foi baixada: acompanhe o pollUrl da resposta e repita depois.
Armadilhas.
- A mídia expira no CDN da Meta. Materialize cedo. Depois de expirada não há recuperação.
downloadMediapode estar desligado na configuração da organização. Confira emGET /config.- Linhas antigas sem
deviceIddevolvem409— não há como saber qual socket usar para baixar.
Casar a agenda do WhatsApp com a sua base de clientes
O jid e o lid são opacos e não servem de chave. Use o campo phone, que já vem só com dígitos.
curl -s "$BASE/chats/contacts?deviceId=demo-01&page[size]=100" \
-H "Authorization: Bearer $TOKEN" | jq '.data[] | {phone, name, jid}'
{ "phone": "5511934481322", "name": "Maria Silva", "jid": "216457879245011@lid" }
Do seu lado, normalize com re.sub(r"\D", "", numero) e case por igualdade exata.
Armadilhas.
- Case por
phone, nunca por nome. Nome de contato é livre e muda sozinho. phone: nullsignifica "ainda não resolvido", não "não existe". Contatos@lid— identidade opaca, comum em conta comercial — só ganham telefone quando o mapeamento é aprendido, o que acontece na primeira interação. A reconciliação é incremental por natureza. Numa validação em staging com 1.353 contatos, 99,8% deles@lid, cerca de 90% resolveram; os demais resolvem conforme conversam.- O
phonenunca é sobrescrito pornull. Uma vez conhecido, fica. - A sua agenda precisa de DDI. Um número gravado como
11934481322não casa com5511934481322. Isso não é defeito do serviço. - Compare a agenda de um device contra os contatos daquele device. A agenda é escopada por número, e por isso o
deviceIdé obrigatório.
Detalhes de derivação, migração e o script de backfill do acervo antigo estão em `docs/wpp-contacts-phone-equivalence.md`.
Escutar eventos ao vivo num painel
ORG=b0000000-0000-0000-0000-000000000001
curl -N "https://wpp-sse.bb.stg.catalisa.app/api/v1/events/$ORG?access_token=$TOKEN"
Armadilhas.
- É outro processo e outra porta. O SSE não está montado sob
/wpp; ele tem o próprio serviço. - O token manda, não o path. A organização do token precisa bater com a do path, senão
401. ?access_token=existe porqueEventSourcenão manda header. Token em query aparece em log de proxy — prefira o header quando o cliente permitir.- SSE não substitui webhook. Ele é para tela aberta. Evento que precisa chegar mesmo com o navegador fechado é dispatcher.
12Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token; organizationId e permissões WPP_* vêm dele | Sim |
| Webhooks Engine | Cada dispatcher provisiona uma assinatura lá; assinatura, retry e log de entrega são dele | Sim, para receber webhook |
| File Storage | Mídia recebida é materializada lá; GET /messages/media/:id redireciona para a URL assinada | Sim, para mídia |
| wpp-business | Canal oficial da Meta, mesmo formato de evento wpp.v1.*. É a alternativa e o caminho de saída | Não |
| Audit Trail | Registra quem disparou operação em nome da empresa | Não |
| Customers | Reconcilia o phone do contato com a base de clientes | Não |
┌──────────┐ Bearer JWT
│ IAM │───────────────┐
└──────────┘ │
▼
┌────────────┐ ┌─────────────┐ mídia ┌────────────────┐
│ WhatsApp │◀════▶│ WPP │─────────▶│ File Storage │
│ (Baileys) │ │ não-oficial│ │ (S3) │
└────────────┘ └──────┬──────┘ └────────────────┘
│ wpp.v1.*
▼
┌───────────────┐ RSA-SHA256 ┌──────────────────┐
│ Dispatcher │───────────────▶│ Webhooks Engine │
│ device+JSONPath│ │ retry + log │
└───────────────┘ └────────┬─────────┘
▼
┌───────────────────────┐
│ Sistema do cliente │
│ (CRM, esteira, bot) │
└───────────────────────┘
┌────────────────┐ Cloud API oficial ┌──────────┐
│ wpp-business │─────────────────────▶│ Meta │ mesmo IAM,
│ 151 rotas │ └──────────┘ mesmo webhooks-engine,
└────────────────┘ mesmo formato wpp.v1.*
Este último bloco é o argumento comercial mais forte aqui, e vale dizê-lo em voz alta na conversa de venda: a Catalisa vende os dois canais. O cliente que começa no não-oficial porque não pode migrar o número tem, no mesmo catálogo e com a mesma integração, o caminho oficial para o dia em que o risco deixar de ser aceitável. Trocar de canal vira uma mudança de rota HTTP, não um projeto de substituição de fornecedor.
13Configuração e operação
Variáveis de ambiente
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
WPP_BAILEYS_MASTER_KEY | Chave AES-256-GCM das credenciais de sessão. 64 caracteres hex. Sem ela, nenhum device conecta | Sim | — |
DATABASE_URL | PostgreSQL, schema wpp | Sim | — |
REDIS_URL | Redis — Streams, registro de posse, orçamento, pub/sub do SSE | Sim | — |
JWT_SECRET | Verificação do token do IAM | Sim | — |
WPP_SESSION_WORKER_ENABLED | Liga o consumidor de streams no worker de sessão | Não | true |
WPP_SSE_ENABLED | Liga o processo de SSE | Não | false |
WPP_CONNECT_BUDGET_PER_HOUR | Teto de tentativas de conexão por número por hora | Não | 30 |
WPP_RESTORE_ENQUEUE_DELAY_MS | Espera antes de distribuir os jobs de restore, para dar tempo às outras réplicas entrarem no grupo | Não | 10000 |
WPP_BULLMQ_PREFIX | Prefixo de fila | Não | wpp |
MODULE_WPP_URL | URL do WPP para outros módulos em standalone | Não | '' |
S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION | Destino da mídia, via File Storage | Sim, para mídia | — |
DEPLOYMENT_MODE | monolith ou standalone | Não | monolith |
Configuração por organização — PATCH /wpp/api/v1/config, não é variável de ambiente.
| Campo | Faixa | Padrão | Efeito |
|---|---|---|---|
retryAttempts | 0–10 | 5 | Reconexões antes de abrir o circuito |
retryBackoffMs | 100–60000 | 2000 | Base do backoff exponencial |
qrLinkExpirationHours | 1–168 | 24 | Validade do link de pareamento delegado (§15) |
persistMessagesUpsert / Update / Delete / Reaction | booleano | true | O que persistir do fluxo de mensagem |
persistChats / persistGroups / persistContacts | booleano | true | O que persistir de sincronização |
downloadMedia | booleano | true | Baixar mídia automaticamente |
Dependências de infraestrutura
| Dependência | Para quê |
|---|---|
| PostgreSQL | Schema wpp, 14 tabelas, incluindo a credencial cifrada da sessão |
| Redis | Streams de job, inbox por réplica, registro de posse, orçamento de conexão, pub/sub do SSE |
| S3 / MinIO | Mídia materializada, via File Storage |
| Dependência externa que não temos contrato nenhum com — é a origem de toda a §15 |
Limites e constantes operacionais
| Limite | Valor | Onde |
|---|---|---|
| Orçamento de conexão | 30 por número por hora, janela deslizante | WPP_CONNECT_BUDGET_PER_HOUR |
| TTL da posse do device | 45 segundos | OWNER_TTL_SECONDS |
| Heartbeat do worker | 30 segundos, sempre abaixo do TTL da posse | main-session-worker.ts |
| Validade do QR exibido | 60 segundos | DeviceService.getQrCode |
| Dica de validade do pairing code | 180 segundos (o WhatsApp impõe a real) | PAIRING_CODE_TTL_MS |
| Reconexões antes do circuito abrir | 5, backoff exponencial de base 2000ms | WppConfig |
| Reaproveitamento de job órfão | A cada 15s, para entradas paradas há mais de 60s | WppConsumer |
| Retentativa de restore transitório | Até 5, a cada 60s | RESTORE_RETRY_MAX_ATTEMPTS |
| Varredura de fantasma | Trava de 55s, alarme com 3 strikes em 10 minutos | detectPhantomDevices |
deviceId | 1 a 100 caracteres | createDeviceSchema |
| Timeout de dispatcher | 1000 a 60000 ms | createDispatcherSchema |
Catálogo de erros
| Status | Código / motivo | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | Corpo reprovado no Zod | Confira os campos contra a §9 |
400 | — | deviceId ausente em /chats/contacts | Passe ?deviceId= |
400 | — | Mensagem sem mídia baixável | Só image, video, audio, document, sticker |
401 | UNAUTHORIZED | Token ausente, inválido ou expirado | Renove no IAM |
403 | — | Token sem organizationId | Autentique informando a organização |
403 | FORBIDDEN | Falta permissão WPP_* | Confira o papel no IAM |
404 | NOT_FOUND | Recurso inexistente ou de outra organização | Confira o identificador — e se é UUID ou deviceId |
409 | requires_repair | Device em logged_out/banned | connect com force: true e repareie |
409 | connect_budget_exceeded | Mais de 30 conexões na hora para este número | Pare de tentar. Investigue o que martela |
409 | owner_held | Outra réplica viva tem o socket | Transitório. A posse expira em 45s |
409 | — | Mensagem antiga sem deviceId para baixar mídia | Não recuperável para aquela linha |
422 | not_on_whatsapp | O destino não tem conta de WhatsApp | Valide o número na origem |
503 | SERVICE_UNAVAILABLE | Falha ao criar ou conectar o driver | Verifique worker, Redis e WPP_BAILEYS_MASTER_KEY |
Observabilidade. Esta seção existe porque um incidente real mostrou que faltava. Em 2026-07-29 o WPP de staging ficou 8 horas sem processar mensagens, e nenhum alarme disparou: o cliente Redis tinha morrido em silêncio, os 31 health checks HTTP passavam, e o socket do WhatsApp seguiu vivo persistindo direto no Postgres — o que estava morto era o laço de consumo (postmortem completo).
wpp_connection_logsé o diário do device. Toda transição, tentativa de reconexão, recusa por posse, estouro de orçamento e abertura de circuito vira linha, comstatusCode,disconnectReason,attemptNumberebackoffMs. É o primeiro lugar para olhar quando um número para.- Detecção de fantasma roda sozinha. A cada minuto, uma réplica eleita procura device com status
openno banco e nenhuma posse viva no Redis. É a assinatura exata do incidente de 2026-08-15 e o único indicador que teria pego as quatro ocorrências sozinho. São necessários 3 strikes em 10 minutos para alarmar, porque um device fica legitimamente sem dono por alguns segundos durante um rollout. GET /wpp/healthsonda a API, não o worker. O worker de sessão não expõe HTTP. Um health check verde não prova que mensagem está sendo processada — foi exatamente essa a lição de 2026-07-29.- O que monitorar de verdade: contagem de
Connection is closednos logs do worker; devices comstatus='open'eupdatedAtantigo; devices presos emconnecting; profundidade das streamswpp:*e daswpp:pending:*; e taxa de falha no log de entrega dos dispatchers. - Consulta que responde mais rápido que qualquer painel:
select "deviceId", status, "lastError", "updatedAt", now() - "updatedAt" as parado_ha
from wpp.wpp_devices
where "organizationId" = :org
order by "updatedAt" desc;
14Segurança e compliance
Isolamento entre tenants. As 14 tabelas do schema wpp carregam organizationId, e cada um dos sete routers declara o próprio requireOrganization, que devolve 403 quando o token não traz o claim. Nenhuma rota lê organizationId do corpo da requisição — ele vem sempre do JWT assinado pelo IAM. O canal de SSE é explícito nisso: mesmo com o organizationId no path, o handler compara com o claim do token e recusa se divergirem.
Agenda isolada por número, não por empresa. WppContact tem chave única (organizationId, deviceId, jid) e a rota de listagem exige deviceId. É uma decisão de segurança: dois números da mesma empresa podem ser de áreas diferentes, e a agenda de um não deve vazar pelo outro.
Credencial de sessão cifrada em repouso. As linhas de wpp_auth guardam o estado do Baileys cifrado em AES-256-GCM, no formato base64(iv ‖ authTag ‖ ciphertext). A chave-mestra vive em WPP_BAILEYS_MASTER_KEY, fora do banco, e é validada no formato (64 hex) antes de qualquer uso. Um dump do Postgres não é suficiente para sequestrar uma sessão de WhatsApp.
Endpoint de webhook restrito a host público. A validação de endpointUrl recusa localhost, faixas privadas (10.*, 172.16–31.*, 192.168.*, 169.254.*), IPv6 de escopo local e sufixos .internal, .local e .localhost. É defesa contra SSRF: sem isso, criar um dispatcher seria uma forma de fazer a plataforma bater em serviço interno.
Autenticação e permissões. Toda rota exige token válido e uma das cinco permissões WPP_*. A separação entre WPP_SEND e WPP_ADMIN importa: um sistema que só precisa enviar mensagem não deve poder desconectar ou resetar o número da empresa.
LGPD. O WPP processa conteúdo de conversa, número de telefone, nome de exibição e mídia enviada por titulares — tudo dado pessoal, e parte pode ser dado sensível conforme o que o cliente escreve. Três pontos que a área jurídica precisa saber:
- A empresa é controladora do conteúdo que trafega. A Catalisa opera a infraestrutura; o que é dito na conversa é responsabilidade de quem opera o número.
- Retenção é ilimitada por padrão. Mensagem, contato e mídia ficam até serem apagados. Não há política automática de expurgo (§15). Quem tem obrigação de retenção definida precisa implementá-la.
- É possível não persistir. As flags
persistMessagesUpsert,persistChats,persistContactsedownloadMediaemPATCH /configpermitem operar sem guardar conteúdo, entregando só por webhook. É o caminho para quem quer minimizar a base.
O ponto de compliance que não é técnico. Operar um canal não-oficial significa acessar o WhatsApp por meio que os Termos de Serviço da Meta não autorizam. Isso não é uma vulnerabilidade do software — é uma característica do produto, e precisa estar na avaliação de risco de quem contrata, junto com a §15.
15Limitações conhecidas
Riscos que vêm do fato de o canal ser não-oficial. Estes não têm correção no roadmap, porque não são defeito nosso.
| Limitação | Impacto | Situação |
|---|---|---|
| O número pode ser banido | Os Termos de Serviço da Meta proíbem acesso automatizado não autorizado e envio em massa, e permitem suspender ou encerrar o acesso a qualquer momento (ToS). O ecossistema Baileys registra relatos recorrentes (issue #1869) | Risco inerente. Mitigamos com orçamento de conexão e posse única de socket; eliminar é impossível. Use número dedicado |
| Não há SLA de entrega | A Meta não é parte do contrato. Não há garantia contratual de que uma mensagem chega | Por natureza. Quem precisa de SLA usa o wpp-business |
Time-lock 463 em contato frio | O WhatsApp recusa a primeira mensagem a quem nunca te escreveu, e o bloqueio acumula na conta | Regra anti-spam da Meta. A operação precisa inverter a régua (§11) |
| A biblioteca depende de protocolo não documentado | Mudança no WhatsApp pode quebrar envio, mídia ou pareamento sem aviso, e a correção depende de terceiro | Por natureza. Já aconteceu — ver o caso do companion_platform_id no pareamento |
| Sem botões, catálogo, WhatsApp Flows ou pagamento | Recursos interativos da plataforma oficial não existem aqui | Por design. Isso é wpp-business |
| Sem selo de conta verificada | O número não recebe a verificação da Meta | Por natureza do canal |
Funcionalidades no schema, mas não implementadas. Estão aqui, e não na §3, porque não funcionam.
| Limitação | Impacto | Situação |
|---|---|---|
WppQrConnectionLink não tem rota HTTP | O modelo de link de pareamento delegado entre organizações existe no banco, com token, status e expiresAt, e qrLinkExpirationHours é configurável — mas não há endpoint que crie ou consuma o link | Especificado, não implementado |
WppOutboundMessage é fila legada | A tabela existe e nenhum caminho ativo escreve nela; o envio de hoje passa por operation e Redis Streams | Resíduo. Não use |
POST /messages/send-template | A rota existe e enfileira, mas template é um conceito da API oficial — aqui não há aprovação da Meta nem HSM. Na prática vira mensagem comum montada a partir dos componentes | Documente com o cliente antes de prometer |
wpp.v1.message.status.updated não é emitido | O nome está no catálogo de eventos e nada o publica. Os acks do WhatsApp chegam ao driver e atualizam wpp_messages.status, mas não viram evento público | Reservado, não implementado. Não crie dispatcher contando com ele |
?force=true em chats, contatos e grupos | O parâmetro é lido e descartado; não existe atualização ao vivo a partir do WhatsApp | Marcado como futuro no código |
callbackUrl na operation | WppOperationResult tem callbackUrl, callbackSent e callbackAt, mas nenhuma rota aceita o campo e nada dispara o callback | Especificado, não implementado. Use dispatcher |
Gargalos e arestas operacionais.
| Limitação | Impacto | Situação |
|---|---|---|
connecting é estado órfão | Nada reconcilia um device preso em connecting; já houve um em staging parado por 91 dias | Conhecido, sem reaper. Recupera-se com connect |
| Health check não cobre o worker | O worker de sessão não expõe HTTP. Deploy "com sucesso" já conviveu com WPP quebrado por 8 horas | Conhecido desde o postmortem de 2026-07-29 |
| Rotas de leitura de grupo usam UUID, não JID | GET /groups/:id e GET /groups/:groupJid/participants resolvem pelo UUID interno, apesar do nome do parâmetro; as de escrita usam JID | Inconsistência de API conhecida |
| Resolução de LID não cobre grupo | senderPhone só é resolvido para @lid em conversa individual. Em grupo o remetente fica em participant, sem resolução | Fora de escopo por ora |
| Sem política de retenção | Mensagem, contato e mídia ficam para sempre. Não há expurgo automático | Roadmap |
| Mídia expira no CDN da Meta | Materializar meses depois falha, sem recuperação | Por natureza. Materialize cedo |
| Distribuição de sessão entre réplicas não é perfeita | O atraso de 10s antes de distribuir o restore é calibrado para a cadência do Swarm; uma réplica que sobe em mais de 30s pode ficar sem carga | Conhecido. Alternativa documentada em `auto-restore-and-distribution.md` |
| As duas defesas falham abertas | Com o Redis fora, pré-voo de posse e orçamento de conexão liberam a operação | Trade-off deliberado. Redis indisponível derrubaria todo connect |
| Um único driver real | Só baileys. O mock é de teste, e a abstração IWhatsAppDriver está pronta para um segundo, que não existe | Por design, hoje |
16Perguntas frequentes
Meu número vai ser banido?
Pode ser. Essa é a resposta honesta, e qualquer fornecedor que diga outra coisa está vendendo confiança que não tem. Os Termos de Serviço da Meta proíbem acesso automatizado não autorizado, e a Meta pode encerrar o acesso a qualquer momento. O que reduz risco na prática: use um número dedicado à integração, nunca o que a empresa não pode perder; não faça disparo frio; deixe o cliente iniciar a conversa; e não fique reconectando em laço — o orçamento de 30 conexões por hora existe para impedir isso mesmo quando o seu código tenta. Se o risco não é aceitável, o produto certo é o wpp-business.
Qual a diferença entre o wpp e o wpp-business?
São canais diferentes para o mesmo aplicativo. O wpp é o não-oficial: pareia um número comum por QR ou código, o celular continua funcionando, não há template para aprovar, não há custo por mensagem — e existe risco de banimento. O wpp-business é a Cloud API oficial da Meta: o número migra e sai do app comum, toda mensagem proativa exige template aprovado, a Meta cobra por mensagem entregue — e não há esse risco. O wpp-business também tem 151 rotas contra as 45 daqui, porque a plataforma oficial oferece campanha, catálogo, fluxos, pagamento e caixa de entrada com agentes. Os dois usam o mesmo IAM, o mesmo Webhooks Engine e o mesmo formato de evento wpp.v1.*, o que torna a troca de canal barata.
Preciso de um celular ligado o tempo todo?
Não. O pareamento é multi-device: depois de parear, a plataforma mantém a própria conexão e funciona com o celular desligado. Mas o celular precisa se conectar de tempos em tempos — se ficar desconectado por muito tempo, o WhatsApp desvincula os aparelhos companheiros e o device volta para logged_out.
Por que enviar mensagem devolve 202 e não o resultado?
Porque o socket do WhatsApp vive em outro processo, possivelmente em outra máquina, e o envio pode levar dezenas de segundos. A API grava uma operation, enfileira o job e devolve 202 com correlationId. Você descobre o resultado em GET /wpp/api/v1/operations/:correlationId ou pelo evento wpp.v1.message.sent no seu webhook. Não existe caminho síncrono, e não vai existir — ele criaria timeout de HTTP como modo de falha padrão.
A mensagem aparece como enviada mas o destinatário não recebeu. Por quê?
Três causas, nesta ordem de frequência. Time-lock 463: o destinatário nunca te escreveu, então não há token de confiança e o WhatsApp recusa; a operation fica completed mas wpp_messages.status vira failed. Envio para @lid cru: quando a sessão de criptografia foi estabelecida sob outro identificador, o ack diz entregue e a mensagem não abre do outro lado — por isso o driver normaliza para PN, e por isso você deve mandar sempre <dígitos>@s.whatsapp.net. Número sem WhatsApp: aqui o serviço bloqueia antes e devolve not_on_whatsapp, justamente para não fingir entrega. A investigação completa está em `docs/wpp-send-463-lid-learnings.md`.
Posso usar para disparo em massa?
Não deveria, e o serviço não vai ajudar. Além de violar os Termos da Meta, tecnicamente não funciona: o time-lock 463 acumula na conta remetente, então uma sequência de contatos frios trava o número inteiro — inclusive para quem antes recebia. Disparo proativo em volume é caso de uso da API oficial, com template aprovado, ou seja, wpp-business.
Quantos números posso conectar?
Não há limite no código. O limite prático é a memória do worker de sessão: cada número mantém um socket vivo, e a distribuição entre réplicas é por dispositivo, com um dono único garantido em Redis. Escalar em número de dispositivos significa adicionar réplicas do worker.
O que acontece com as sessões quando eu faço deploy?
O worker que está saindo entrega as sessões que possui antes de morrer (handOffOwnedSessions), e as réplicas que ficam ou sobem reassumem. Na pior das hipóteses, a posse expira em 45 segundos e outra réplica reivindica. Um device pode piscar por alguns segundos, o que aparece no log como Handed off owned WPP sessions before exit — nível warn, de propósito, para explicar a oscilação às três da manhã.
Como recebo as mensagens no meu sistema?
Por dispatcher, que é a forma correta. Você cria um em POST /wpp/api/v1/dispatchers apontando para uma URL pública sua, e o Webhooks Engine entrega com assinatura RSA-SHA256, retentativa e log auditável. O SSE existe para painel com tela aberta, não para integração — se o navegador fecha, o evento se perde. O guia completo, com verificação de assinatura em quatro linguagens, está em `docs/wpp/webhook-integration-guide.md`.
Meu device está em connecting há horas. É bug?
É uma limitação conhecida: nada reconcilia esse estado sozinho (§15). Chame POST /devices/:deviceId/connect — e não reconnect, que exige a posse do socket viva e falha justamente quando o socket está morto. Essa confusão é o erro de diagnóstico mais comum, e está documentada no postmortem de 2026-07-29.
Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md · Aprofundamentos: `docs/wpp/`