Catalisa.
Building blocks/ComunicaçãoProdução

WPP

WhatsApp no número que a empresa já usa, sem trocar de linha nem migrar conversa

45
Endpoints
14
Entidades
1
Provedores
Tenant
Escopo
3023
Porta

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.

Para quem é
  • 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
Substitui
  • 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
O que nã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.

AtributoValor
Identificadorwpp
CategoriaComunicação
EscopoTenant (exige organizationId no token)
Porta (standalone)3023 — o processo de SSE sobe separado, na 3024 do compose
Path alias@wpp
Prefixo HTTP/wpp
StatusProdução desde 2026-05
Depende dePostgreSQL (schema wpp), Redis (Streams + registro de posse), S3 via File Storage, IAM, Webhooks Engine
Driver realbaileys (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

AntesDepois
Conversa mora no celular do atendenteCada mensagem persiste no schema wpp, com chat, contato e grupo relacionados
Integrar exige migrar o número para a Meta e aprovar templateVocê pareia o número em minutos, por QR ou código, e o celular continua funcionando
Rodar Baileys em produção é um projeto de infraestruturaSessã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 servidorCredencial cifrada em AES-256-GCM no Postgres, com chave-mestra fora do banco
Receber evento exige poller ou socket próprioWebhook 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érioCatalisa WPPEvolution APIWPPConnectZ-APIMeta Cloud API
Precisa migrar o númeroNãoNãoNãoNãoSim
Template aprovado pela MetaNão exigeNão exigeNão exigeNão exigeExige para proativas
Custo por mensagemNãoNãoNãoNão (assinatura por instância)Sim, por mensagem entregue
Risco de banimento do númeroSimSimSimSimNão, por esta causa
Quem operaJá vem operadoVocêVocêO fornecedorA Meta
Isolamento multi-tenantorganizationId em toda linha e em todo routerPor instância; separação é suaPor sessão; separação é suaPor instância contratadaPor WABA
Autorização integrada ao seu IAMSim, permissões WPP_*NãoNãoNãoNão
Onde o dado de conversa ficaNo seu bancoNo seu bancoNo seu bancoNo fornecedorNa Meta
Sessão distribuída com dono únicoSim, registro em Redis com TTL de 45sNão nativoNão nativoNão expostoNão se aplica
Webhook assinado com log e reenvioSim, RSA-SHA256 via Webhooks EngineWebhook simplesWebhook simplesWebhook simplesSim
Botões, catálogo, pagamentoNãoParcial (via Cloud API)NãoParcialSim
Licença/custo de softwareIncluso na plataformaApache 2.0MITAssinatura mensalPor 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

  1. A posse do socket é um problema resolvido, e resolvido em Lua. Reivindicar dono para um número usando GET seguido de SET tem 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. O OwnerRegistry faz a reivindicação num único EVAL, 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.
  2. 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 organizationId está em cada uma das 14 tabelas e o token do IAM é a única fonte dele.
  3. 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.
  4. 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.

DriverPor quê
Números conectados simultaneamenteCada um mantém um socket vivo e uma fatia de memória no worker de sessão
Volume de mensagens processadasPersistência no Postgres, avaliação de dispatchers e publicação de eventos
Armazenamento de mídia recebidaMaterialização no File Storage — foto, áudio e documento ficam em S3
Entregas de webhookCada 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 WPPZ-APIMeta Cloud API / BSP
Base de cálculoPor número conectadoPor instância conectadaPor mensagem entregue, por categoria
Custo das 170 mil mensagens em janela de atendimentoSem custo por mensagemSem custo por mensagemMensagens de serviço em janela de 24h são gratuitas
Custo das 30 mil proativasSem custo por mensagemSem custo por mensagemCobradas como utility ou marketing, com preço por país
Ordem de grandeza mensal do softwarePrecificação em definição5 instâncias na faixa de R$ 99,99 cadaDepende integralmente do mix de categoria e do país
Migração do númeroNãoNãoSim
Risco de perder o númeroExisteExisteNã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 devolve 202 com correlationId. 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.mjs e o SSE com dist/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_QUEUES classifica 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>); connect e session-restore são cluster-wide, porque criam socket e devem se distribuir. Sem dono vivo, o job é estacionado em wpp:pending:<org>:<device> e um session-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 → bun produz 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() usa HOSTNAME, que o Docker Swarm garante único por tarefa.

  • Reivindicar posse é uma operação atômica em Lua. GET seguido de SET tem uma janela em que duas réplicas leem vazio simultaneamente — é o que acontece num restart em massa, e a assinatura já foi observada: dois XADD de hand-off para o mesmo device no mesmo milissegundo, vindos de réplicas diferentes. O script CLAIM_IF_FREE_OR_MINE roda inteiro numa execução do Redis. A liberação usa RELEASE_IF_MINE, porque um DEL incondicional 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 connect do 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_auth guardam Bytes cifrado em AES-256-GCM, no formato base64(iv ‖ authTag ‖ ciphertext), com a chave-mestra em WPP_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 o connect externo, 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_out e banned significam credencial morta. Um connect comum ali só produz 401 em laço contra o WhatsApp — o caminho mais rápido para agravar o bloqueio. A rota devolve 409 requires_repair e exige force: 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

TermoSignifica
DeviceUm 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.
JIDEndereço do WhatsApp. 5511999999999@s.whatsapp.net para pessoa, ...@g.us para grupo, ...@broadcast para lista, ...@lid para identidade opaca.
LIDLinked 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.
PNPhone Number. O JID no formato telefone, <dígitos>@s.whatsapp.net. É sempre o destino correto de um envio.
PareamentoLigar o número à plataforma, por QR code ou por código de oito caracteres digitado no celular.
OperationRegistro de uma operação assíncrona, endereçado por correlationId. É como você descobre o resultado de tudo que devolve 202.
DispatcherAssinatura de webhook do WPP. Traduz filtro de domínio (device, tipo de mensagem, condição JSONPath) numa assinatura do Webhooks Engine.
Owner / posseA 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 jobJob que só funciona na réplica dona do socket. Roteado para o inbox dela; sem dono, é estacionado.
FantasmaDevice com status open no banco e nenhuma posse viva no Redis. A linha diz conectado e não existe socket.
tctokenToken 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 PrismaTabelaPropósitoCampos-chave
WppDevicewpp.wpp_devicesUm número conectadoÚnico (organizationId, deviceId), status, driver, jid, lid, lastQr, qrGeneratedAt, pairingCode, syncFullHistory
WppAuthwpp.wpp_authCredencial da sessão Baileys, cifradaÚnico (organizationId, deviceId, key), value (Bytes)
WppMessagewpp.wpp_messagesMensagem enviada ou recebidakeyId, remoteJid, fromMe, type, status, ack, payload (JSON), mediaFileId
WppMessageReactionwpp.wpp_message_reactionsReação a uma mensagemÚnico (organizationId, messageId, fromJid), emoji
WppOutboundMessagewpp.wpp_outbound_messagesFila legada de saída de textoremoteJid, text, status (padrão queued)
WppChatwpp.wpp_chatsConversaÚnico (organizationId, remoteJid), type, unreadCount, archived, lastMessageAt
WppContactwpp.wpp_contactsAgenda, escopada por deviceÚnico (organizationId, deviceId, jid), lid, phone (só dígitos), isBlocked
WppGroupwpp.wpp_groupsMetadados de grupoÚnico (organizationId, groupJid), chatId único, subject, size, announce, restrict
WppGroupParticipantwpp.wpp_group_participantsParticipante de grupoÚnico (organizationId, groupId, jid), role, leftAt
WppOperationResultwpp.wpp_operation_resultsResultado de operação assíncronacorrelationId único, operation, status, request, result, error
WppConnectionLogwpp.wpp_connection_logsDiário de conexão do deviceeventType, statusCode, disconnectReason, attemptNumber, backoffMs
WppQrConnectionLinkwpp.wpp_qr_connection_linksLink de pareamento delegado entre organizaçõestoken único, status, expiresAtsem rota HTTP hoje, ver §15
WppConfigwpp.wpp_configsConfiguração por organizaçãoorganizationId único, retryAttempts, retryBackoffMs, flags persist*, downloadMedia
WppDispatcherwpp.wpp_dispatchersAssinatura de webhook do WPPmode, status, events[], messageTypes[], deviceId, endpointUrl, conditions, subscriptionId

Enumerações

EnumValores
WppDeviceStatusconnecting · qr · pairing · open · closed · reset · banned · logged_out
WppDeviceDriverbaileys
WppMessageStatuspending · sent · delivered · read · played · failed
WppChatTypeindividual · group · broadcast
WppOperationStatuspending · processing · completed · failed
WppConnectionEventTypestate_change · pairing · reconnect_attempt · reconnect_skipped · circuit_breaker · lock_event · error
WppDispatcherModeBASIC · ADVANCED
WppDispatcherStatusACTIVE · 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ãoConcede
WPP_READLeitura de tudo, mais ping (que só sonda o socket)
WPP_SENDEnvio de mensagem em qualquer formato
WPP_ADMINCiclo de vida do device e operações de grupo
WPP_DISPATCHERS_READLeitura de dispatchers e logs de entrega
WPP_DISPATCHERS_WRITECriar, alterar, pausar e reenviar

Devices — /wpp/api/v1/devices

MétodoRotaDescriçãoPermissão
POST/wpp/api/v1/devicesCria o device. 201WPP_ADMIN
GET/wpp/api/v1/devicesLista, paginado. Filtros filter[status], filter[driver]WPP_READ
GET/wpp/api/v1/devices/:idBusca por UUID internoWPP_READ
DELETE/wpp/api/v1/devices/:idRemove por UUID interno. 204WPP_ADMIN
POST/wpp/api/v1/devices/:deviceId/connectEnfileira conexão. 202WPP_ADMIN
POST/wpp/api/v1/devices/:deviceId/disconnectEnfileira desconexão. 202WPP_ADMIN
POST/wpp/api/v1/devices/:deviceId/reconnectEnfileira reconexão. 202WPP_ADMIN
POST/wpp/api/v1/devices/:deviceId/resetDescarta a sessão e força novo pareamento. 202WPP_ADMIN
POST/wpp/api/v1/devices/:deviceId/pingSonda a conectividade do socket. 202WPP_READ
POST/wpp/api/v1/devices/:deviceId/pairing-codePede código de 8 caracteres. 202WPP_ADMIN
GET/wpp/api/v1/devices/:deviceId/pairing-codeLê o código já geradoWPP_READ
GET/wpp/api/v1/devices/:deviceId/qrLê o QR correnteWPP_READ
GET/wpp/api/v1/devices/:deviceId/statusSó o statusWPP_READ

Armadilha real. GET e DELETE /devices/:id usam o UUID interno e validam o formato; todas as demais rotas usam o deviceId que você escolheu. Passar o deviceId em GET /devices/:id devolve erro de validação de UUID, não 404.

Mensagens — /wpp/api/v1/messages

MétodoRotaDescriçãoPermissão
POST/wpp/api/v1/messages/sendTexto. 202WPP_SEND
POST/wpp/api/v1/messages/send-mediaImagem, vídeo, áudio, documento, figurinha. 202WPP_SEND
POST/wpp/api/v1/messages/send-reactionReação a uma mensagem. 202WPP_SEND
POST/wpp/api/v1/messages/send-templateTemplate. 202ver §15WPP_SEND
POST/wpp/api/v1/messages/send-locationLocalização. 202WPP_SEND
POST/wpp/api/v1/messages/send-contactCartão de contato. 202WPP_SEND
GET/wpp/api/v1/messagesLista com filtrosWPP_READ
GET/wpp/api/v1/messages/media/:messageIdMaterializa a mídia. 302 ou 202WPP_READ
GET/wpp/api/v1/messages/:idBusca por UUID internoWPP_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étodoRotaDescriçãoPermissão
GET/wpp/api/v1/chatsLista conversas. Filtros filter[type], filter[archived]WPP_READ
GET/wpp/api/v1/chats/contactsLista contatos. deviceId é obrigatórioWPP_READ

deviceId na query é exigido em /chats/contacts por segurança: a agenda é do número, não da organização. Sem ele a rota devolve 400, e é assim de propósito — dois números da mesma empresa não compartilham agenda.

Grupos — /wpp/api/v1/groups

MétodoRotaDescriçãoPermissão
POST/wpp/api/v1/groupsCria grupo. 202WPP_ADMIN
GET/wpp/api/v1/groupsLista, paginadoWPP_READ
GET/wpp/api/v1/groups/:idBusca grupo com participantesWPP_READ
GET/wpp/api/v1/groups/:groupJid/participantsSó os participantesWPP_READ
PUT/wpp/api/v1/groups/:groupJid/subjectRenomeia. 202WPP_ADMIN
PUT/wpp/api/v1/groups/:groupJid/descriptionAltera a descrição. 202WPP_ADMIN
POST/wpp/api/v1/groups/:groupJid/participantsAdiciona, remove, promove, rebaixa. 202WPP_ADMIN
POST/wpp/api/v1/groups/:groupJid/leaveSai do grupo. 202WPP_ADMIN
DELETE/wpp/api/v1/groups/:groupJidEncerra o grupo. 202. Exige deviceId no corpoWPP_ADMIN

Inconsistência conhecida. As duas rotas de leitura — GET /groups/:id e GET /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 devolve 404. Está registrado na §15.

Configuração — /wpp/api/v1/config

MétodoRotaDescriçãoPermissão
GET/wpp/api/v1/configConfiguração da organizaçãoWPP_READ
PATCH/wpp/api/v1/configAltera a configuraçãoWPP_ADMIN

Operações — /wpp/api/v1/operations

MétodoRotaDescriçãoPermissão
GET/wpp/api/v1/operationsLista. Filtro filter[status]WPP_READ
GET/wpp/api/v1/operations/:correlationIdResultado de uma operaçãoWPP_READ

Dispatchers — /wpp/api/v1/dispatchers

MétodoRotaDescriçãoPermissão
POST/wpp/api/v1/dispatchersCria e provisiona a assinatura no Webhooks Engine. 201WPP_DISPATCHERS_WRITE
GET/wpp/api/v1/dispatchersLista. limit, offset, statusWPP_DISPATCHERS_READ
GET/wpp/api/v1/dispatchers/:idBuscaWPP_DISPATCHERS_READ
PATCH/wpp/api/v1/dispatchers/:idAltera e ressincroniza a assinaturaWPP_DISPATCHERS_WRITE
DELETE/wpp/api/v1/dispatchers/:idExclusão lógicadeletedAt preenchido, assinatura desabilitada, logs preservados. 204WPP_DISPATCHERS_WRITE
POST/wpp/api/v1/dispatchers/:id/toggleAlterna ACTIVEPAUSED. Evento recebido enquanto pausado é descartado, não enfileiradoWPP_DISPATCHERS_WRITE
GET/wpp/api/v1/dispatchers/:id/delivery-logsLog de entregas. page, pageSize, statusPENDING · DELIVERED · FAILED · RETRYINGWPP_DISPATCHERS_READ
POST/wpp/api/v1/dispatchers/:id/delivery-logs/:logId/retryReenvia uma entrega, ignorando o backoffWPP_DISPATCHERS_WRITE

Saúde e eventos ao vivo

MétodoRotaProcessoDescrição
GET/wpp/healthAPISonda de vida do serviço HTTP
GET/api/v1/events/:organizationIdSSE (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 }
CampoTipoObrigatórioDescrição
deviceIdstring (1–100)SimIdentificador que você escolhe. Único dentro da organização
driver"baileys"NãoPadrão baileys. É o único valor aceito
syncFullHistorybooleanNãoPuxa 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":{...}}}).

Erros400 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 }
CampoTipoObrigatórioDescrição
forcebooleanNãoReparo 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

StatusQuando
403Token sem organizationId
404Device inexistente na organização
409requires_repair — device em logged_out/banned sem force: true
409connect_budget_exceeded — mais de 30 tentativas nesta hora para este número
409owner_held — outra réplica viva já tem o socket. Transitório: a posse expira em 45s

O 202 significa enfileirado, não conectado. Acompanhe por GET /operations/:correlationId ou por GET /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."
}
CampoTipoObrigatórioDescrição
deviceIdstringSimQual número envia
jidstringSimDestino. Use sempre o PN, <dígitos>@s.whatsapp.net
textstringSimConteú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 operationSignificaO que fazer
not_on_whatsappO número não tem conta de WhatsApp. Bloqueado de propósito, para não fingir entregaValide o número na origem
ack com 463Time-lock de alcance: o destinatário nunca te escreveu, ou a conta acumulou envios friosEspere o contato iniciar. Não insista — o bloqueio agrava
No active sessionNão havia socket vivo quando o job foi processadoReconecte 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á materializada302 para a URL assinada. Siga o redirecionamento.
  • Ainda não202 com correlationId e pollUrl, porque baixar exige o socket vivo, que está em outro processo.
{
  "data": {
    "enqueued": true,
    "correlationId": "b21c...",
    "pollUrl": "/api/v1/operations/b21c..."
  }
}
StatusQuando
400A mensagem não é de um tipo com mídia (image, video, audio, document, sticker)
404Mensagem inexistente ou de outra organização
409Linha 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 }
}
CampoTipoObrigatórioDescrição
namestring (1–200)SimNome do dispatcher
modeBASIC | ADVANCEDNãoPadrão BASIC. ADVANCED usa a lista events diretamente
messageTypesstring[]NãoEm BASIC, vira wpp.v1.message.received.<tipo>
eventsstring[]NãoEm ADVANCED, os tipos de evento, com * e ** como curinga
deviceIdstringNãoRestringe a um número. Ativa o pré-filtro no WPP
endpointUrlstring (URL)SimPrecisa ser host público. localhost, 10.*, 172.16–31.*, 192.168.*, .internal e .local são recusados
conditionsobjectNãoFiltros JSONPath com match: "all" ou "any"
timeoutMsint 1000–60000NãoPadrão 30000
retryConfigobjectNãomaxRetries 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

EventoQuando
wpp.v1.device.connectedSocket abriu
wpp.v1.device.disconnectedSocket caiu. Carrega recoverable, requiresRepair, deviceStatus
wpp.v1.device.qr.updatedNovo QR disponível
wpp.v1.device.status.changedMudança de status
wpp.v1.message.received.<tipo>Mensagem recebida — text, image, audio, video, document, sticker, location, contact, reaction...
wpp.v1.message.sentMensagem enviada por você confirmada
wpp.v1.message.failedEnvio falhou
wpp.v1.message.status.updatedReservado — não é emitido hoje. Ver §15
wpp.v1.message.reactionReação
wpp.v1.chat.updated · wpp.v1.group.updated · wpp.v1.contact.updatedSincronizaçã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.*, nunca wpp.*. O namespace sem v1 é interno e não é destinado a assinante externo. Assinar wpp.message.received nã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
}

senderPhone só é preenchido quando o remoteJid é um @lid de conversa individual e o driver consegue resolver o telefone. Em grupo, o remetente está em participant e 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 expiresAt que 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, 5xx e timeout — 5 tentativas por padrão, backoff 1s → 2s → 4s → 8s → 16s. Deduplique por x-webhook-id numa tabela com chave primária.
  • Não é HMAC. É RSA-SHA256 sobre id\ntimestamp\ncorpo, com \n literal. Tire o prefixo v1= antes de decodificar o base64, e verifique sobre os bytes crus do corpo — reserializar o JSON invalida a assinatura. Use express.raw() ou equivalente.
  • Cacheie a chave pública por keyId. As chaves rotacionam; busque de novo só quando aparecer um keyId desconhecido.
  • Rejeite deriva de relógio maior que 5 minutos no x-webhook-timestamp.
  • Responda 200 em 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.
  • 3xx não é retentado. O engine não segue redirect — aponte o dispatcher para a URL final.
  • endpointUrl precisa ser público. Host privado é recusado na criação com 400 — é 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.
  • ADVANCED não deriva nada. Nesse modo, os events que 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 deviceId ou conditions, a assinatura passa a escutar wpp.dispatch.<id> e o corpo entregue traz originalPayload e originalEventType. 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.

  1. Envie sempre para <dígitos>@s.whatsapp.net. O driver normaliza @lid para PN antes de enviar, mas quem chama deve mandar o PN. O campo phone de GET /chats/contacts existe exatamente para isso — ele já vem só com dígitos.
  2. Espere o contato falar primeiro. O WhatsApp cria o tctoken quando você recebe mensagem de alguém. Sem ele, o envio de alcance volta com ack 463. Não é bug, é anti-spam da Meta.
  3. 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.
  4. Não confie em "delivered" sozinho. A operação pode ficar completed com messageId enquanto wpp_messages.status vira failed. Status real é ack sem error, 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
StatusLeituraAção
openSaudávelNenhuma. Se ainda assim não envia, veja a receita do 463
connecting com o registro velhoPreso. Nada reconcilia este estado sozinhoconnect
closedCircuito aberto após esgotar as reconexõesconnect
logged_outCredencial morta no WhatsAppconnect com force: true, depois repareie
bannedNúmero bloqueado pelo WhatsAppCó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.

  • reconnect não serve para socket morto. Ele é device-bound: exige a posse wpp:device-owner:* viva, e ela tem TTL de 45 segundos. Socket parado há mais que isso significa posse expirada e falha de roteamento. Use connect, 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.
  • downloadMedia pode estar desligado na configuração da organização. Confira em GET /config.
  • Linhas antigas sem deviceId devolvem 409 — 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: null significa "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 phone nunca é sobrescrito por null. Uma vez conhecido, fica.
  • A sua agenda precisa de DDI. Um número gravado como 11934481322 não casa com 5511934481322. 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 porque EventSource nã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 blockComo se relacionaObrigatório
IAMEmite o token; organizationId e permissões WPP_* vêm deleSim
Webhooks EngineCada dispatcher provisiona uma assinatura lá; assinatura, retry e log de entrega são deleSim, para receber webhook
File StorageMídia recebida é materializada lá; GET /messages/media/:id redireciona para a URL assinadaSim, para mídia
wpp-businessCanal oficial da Meta, mesmo formato de evento wpp.v1.*. É a alternativa e o caminho de saídaNão
Audit TrailRegistra quem disparou operação em nome da empresaNão
CustomersReconcilia o phone do contato com a base de clientesNã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ávelDescriçãoObrigatóriaPadrão
WPP_BAILEYS_MASTER_KEYChave AES-256-GCM das credenciais de sessão. 64 caracteres hex. Sem ela, nenhum device conectaSim
DATABASE_URLPostgreSQL, schema wppSim
REDIS_URLRedis — Streams, registro de posse, orçamento, pub/sub do SSESim
JWT_SECRETVerificação do token do IAMSim
WPP_SESSION_WORKER_ENABLEDLiga o consumidor de streams no worker de sessãoNãotrue
WPP_SSE_ENABLEDLiga o processo de SSENãofalse
WPP_CONNECT_BUDGET_PER_HOURTeto de tentativas de conexão por número por horaNão30
WPP_RESTORE_ENQUEUE_DELAY_MSEspera antes de distribuir os jobs de restore, para dar tempo às outras réplicas entrarem no grupoNão10000
WPP_BULLMQ_PREFIXPrefixo de filaNãowpp
MODULE_WPP_URLURL do WPP para outros módulos em standaloneNão''
S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGIONDestino da mídia, via File StorageSim, para mídia
DEPLOYMENT_MODEmonolith ou standaloneNãomonolith

Configuração por organizaçãoPATCH /wpp/api/v1/config, não é variável de ambiente.

CampoFaixaPadrãoEfeito
retryAttempts0–105Reconexões antes de abrir o circuito
retryBackoffMs100–600002000Base do backoff exponencial
qrLinkExpirationHours1–16824Validade do link de pareamento delegado (§15)
persistMessagesUpsert / Update / Delete / ReactionbooleanotrueO que persistir do fluxo de mensagem
persistChats / persistGroups / persistContactsbooleanotrueO que persistir de sincronização
downloadMediabooleanotrueBaixar mídia automaticamente

Dependências de infraestrutura

DependênciaPara quê
PostgreSQLSchema wpp, 14 tabelas, incluindo a credencial cifrada da sessão
RedisStreams de job, inbox por réplica, registro de posse, orçamento de conexão, pub/sub do SSE
S3 / MinIOMídia materializada, via File Storage
WhatsAppDependência externa que não temos contrato nenhum com — é a origem de toda a §15

Limites e constantes operacionais

LimiteValorOnde
Orçamento de conexão30 por número por hora, janela deslizanteWPP_CONNECT_BUDGET_PER_HOUR
TTL da posse do device45 segundosOWNER_TTL_SECONDS
Heartbeat do worker30 segundos, sempre abaixo do TTL da possemain-session-worker.ts
Validade do QR exibido60 segundosDeviceService.getQrCode
Dica de validade do pairing code180 segundos (o WhatsApp impõe a real)PAIRING_CODE_TTL_MS
Reconexões antes do circuito abrir5, backoff exponencial de base 2000msWppConfig
Reaproveitamento de job órfãoA cada 15s, para entradas paradas há mais de 60sWppConsumer
Retentativa de restore transitórioAté 5, a cada 60sRESTORE_RETRY_MAX_ATTEMPTS
Varredura de fantasmaTrava de 55s, alarme com 3 strikes em 10 minutosdetectPhantomDevices
deviceId1 a 100 caracterescreateDeviceSchema
Timeout de dispatcher1000 a 60000 mscreateDispatcherSchema

Catálogo de erros

StatusCódigo / motivoSignificaO que fazer
400VALIDATIONCorpo reprovado no ZodConfira os campos contra a §9
400deviceId ausente em /chats/contactsPasse ?deviceId=
400Mensagem sem mídia baixávelimage, video, audio, document, sticker
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove no IAM
403Token sem organizationIdAutentique informando a organização
403FORBIDDENFalta permissão WPP_*Confira o papel no IAM
404NOT_FOUNDRecurso inexistente ou de outra organizaçãoConfira o identificador — e se é UUID ou deviceId
409requires_repairDevice em logged_out/bannedconnect com force: true e repareie
409connect_budget_exceededMais de 30 conexões na hora para este númeroPare de tentar. Investigue o que martela
409owner_heldOutra réplica viva tem o socketTransitório. A posse expira em 45s
409Mensagem antiga sem deviceId para baixar mídiaNão recuperável para aquela linha
422not_on_whatsappO destino não tem conta de WhatsAppValide o número na origem
503SERVICE_UNAVAILABLEFalha ao criar ou conectar o driverVerifique 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, com statusCode, disconnectReason, attemptNumber e backoffMs. É 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 open no 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/health sonda 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 closed nos logs do worker; devices com status='open' e updatedAt antigo; devices presos em connecting; profundidade das streams wpp:* e das wpp: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, persistContacts e downloadMedia em PATCH /config permitem 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çãoImpactoSituação
O número pode ser banidoOs 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 entregaA Meta não é parte do contrato. Não há garantia contratual de que uma mensagem chegaPor natureza. Quem precisa de SLA usa o wpp-business
Time-lock 463 em contato frioO WhatsApp recusa a primeira mensagem a quem nunca te escreveu, e o bloqueio acumula na contaRegra anti-spam da Meta. A operação precisa inverter a régua (§11)
A biblioteca depende de protocolo não documentadoMudança no WhatsApp pode quebrar envio, mídia ou pareamento sem aviso, e a correção depende de terceiroPor natureza. Já aconteceu — ver o caso do companion_platform_id no pareamento
Sem botões, catálogo, WhatsApp Flows ou pagamentoRecursos interativos da plataforma oficial não existem aquiPor design. Isso é wpp-business
Sem selo de conta verificadaO número não recebe a verificação da MetaPor natureza do canal

Funcionalidades no schema, mas não implementadas. Estão aqui, e não na §3, porque não funcionam.

LimitaçãoImpactoSituação
WppQrConnectionLink não tem rota HTTPO 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 linkEspecificado, não implementado
WppOutboundMessage é fila legadaA tabela existe e nenhum caminho ativo escreve nela; o envio de hoje passa por operation e Redis StreamsResíduo. Não use
POST /messages/send-templateA 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 componentesDocumente com o cliente antes de prometer
wpp.v1.message.status.updated não é emitidoO 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úblicoReservado, não implementado. Não crie dispatcher contando com ele
?force=true em chats, contatos e gruposO parâmetro é lido e descartado; não existe atualização ao vivo a partir do WhatsAppMarcado como futuro no código
callbackUrl na operationWppOperationResult tem callbackUrl, callbackSent e callbackAt, mas nenhuma rota aceita o campo e nada dispara o callbackEspecificado, não implementado. Use dispatcher

Gargalos e arestas operacionais.

LimitaçãoImpactoSituação
connecting é estado órfãoNada reconcilia um device preso em connecting; já houve um em staging parado por 91 diasConhecido, sem reaper. Recupera-se com connect
Health check não cobre o workerO worker de sessão não expõe HTTP. Deploy "com sucesso" já conviveu com WPP quebrado por 8 horasConhecido desde o postmortem de 2026-07-29
Rotas de leitura de grupo usam UUID, não JIDGET /groups/:id e GET /groups/:groupJid/participants resolvem pelo UUID interno, apesar do nome do parâmetro; as de escrita usam JIDInconsistência de API conhecida
Resolução de LID não cobre gruposenderPhone só é resolvido para @lid em conversa individual. Em grupo o remetente fica em participant, sem resoluçãoFora de escopo por ora
Sem política de retençãoMensagem, contato e mídia ficam para sempre. Não há expurgo automáticoRoadmap
Mídia expira no CDN da MetaMaterializar meses depois falha, sem recuperaçãoPor natureza. Materialize cedo
Distribuição de sessão entre réplicas não é perfeitaO 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 cargaConhecido. Alternativa documentada em `auto-restore-and-distribution.md`
As duas defesas falham abertasCom o Redis fora, pré-voo de posse e orçamento de conexão liberam a operaçãoTrade-off deliberado. Redis indisponível derrubaria todo connect
Um único driver realbaileys. O mock é de teste, e a abstração IWhatsAppDriver está pronta para um segundo, que não existePor 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/`

Building blocks relacionados