Catalisa.Building Blocks
Catálogo/Comunicação/WPP

WPP

Produção

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
2026-05
Desde

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
O que dá para fazer

47 endpoints em 8 recursos.

Explorar a API →
01

Resumo 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)

02

O problema

negó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.

Diante desse número, a empresa tem quatro caminhos hoje — e cada um cobra um preço diferente:

flowchart LR
  N["O número que a empresa já divulga"]
  A["Migrar para a Cloud API oficial"]
  B["Assinar um gateway não-oficial de terceiro"]
  C["Subir Baileys em servidor próprio"]
  D["Continuar só no celular do atendente"]
  A1["Sai do app comum e toda proativa exige template aprovado"]
  B1["A conversa fica hospedada fora e não conversa com o seu IAM"]
  C1["Sessão, réplica e isolamento viram projeto de infraestrutura"]
  D1["Histórico fora do sistema, sem auditoria"]
  N --> A --> A1
  N --> B --> B1
  N --> C --> C1
  N --> D --> D1

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.


03

Proposta de valor

negó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.

sequenceDiagram
  autonumber
  participant R1 as Réplica 1
  participant R2 as Réplica 2
  participant Redis as Redis
  participant WA as WhatsApp
  R1->>Redis: EVAL CLAIM_IF_FREE_OR_MINE do device
  Redis-->>R1: posse concedida com TTL de 45s
  R2->>Redis: EVAL CLAIM_IF_FREE_OR_MINE do mesmo device
  Redis-->>R2: recusado, o dono é a Réplica 1
  R1->>WA: abre o socket único do número
  loop a cada 30s
    R1->>Redis: heartbeat renova a posse antes do TTL vencer
  end

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.

flowchart LR
  W["Mídia chega pelo WhatsApp"] --> B["Blob criptografado no CDN da Meta"]
  B --> D["O socket vivo baixa e descriptografa"]
  D --> F["File Storage grava em S3 e devolve o mediaFileId"]
  F --> U["GET /messages/media/:messageId responde 302 para a URL assinada"]

04

Casos de uso reais

negó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.

flowchart LR
  subgraph P["Pareamento, uma vez só"]
    C1["POST /devices/:deviceId/connect"] --> C2["GET /devices/:deviceId/qr"] --> C3["O supervisor lê o QR no celular"]
  end
  C3 --> D["Device em open, celular seguindo normal no mesmo número"]
  D --> M["Mensagem recebida persiste em wpp_messages"]
  M --> E["Evento wpp.v1.message.received.text"]
  E --> CRM["Dispatcher entrega no CRM"]
  CRM --> R["POST /messages/send responde pelo 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.

flowchart LR
  A["Carteira em atraso"] --> B["Primeiro contato por canal oficial: SMS, e-mail ou wpp-business com template aprovado"]
  B --> C{"O devedor responde?"}
  C -->|"não"| D["Nada sai pelo canal não-oficial — o disparo frio nunca acontece"]
  C -->|"sim"| E["Chega wpp.v1.message.received.text e a janela de conversa abre"]
  E --> F["Dispatcher com condição JSONPath sobre senderPhone roteia para a fila certa"]
  F --> G["Negociação inteira pelo WPP, sem template e sem custo por mensagem"]
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.

sequenceDiagram
  autonumber
  participant Rot as Roteirizador
  participant API as WPP API
  participant W as Session Worker
  participant WA as WhatsApp
  Rot->>API: POST /groups com a lista de participantes da rota
  API-->>Rot: 202 e um correlationId
  API->>W: job de grupo no inbox da réplica dona do socket
  W->>WA: cria o grupo e adiciona os participantes
  WA-->>W: groupJid e um resultado por participante
  W->>API: grava o resultado da operation
  Rot->>API: consulta a operation pelo correlationId
  API-->>Rot: groupJid do grupo criado
  Note over Rot,WA: Ao fim do dia, POST /groups/:groupJid/leave encerra a participação
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

No 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.

A solução com o BB

A Catalisa não endereça isso com uma promessa de que não vai acontecer, porque essa promessa não é honesta. Endereça 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.

flowchart LR
  Q{"A operação tolera perder o número?"}
  Q -->|"sim, com número dedicado à integração"| NO["Canal não-oficial — Catalisa WPP"]
  Q -->|"não"| OF["Canal oficial — wpp-business"]
  NO --> M1["Orçamento de 30 conexões por número por hora"]
  NO --> M2["Posse única de socket registrada em Redis"]
  NO --> M3["Risco residual assumido: o banimento continua possível"]
  OF --> O1["Sem risco de banimento por uso de biblioteca"]
  OF --> O2["Em troca: migra o número e exige template aprovado"]
  M3 -.->|"quando o risco deixa de ser aceitável"| OF
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.


05

Mercado e diferenciais

negó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. A árvore abaixo é a mesma que usamos numa conversa de pré-venda:

flowchart TD
  S["Qual é o seu caso?"] --> A{"Marketing, disparo proativo em volume ou operação regulada que não pode perder o canal?"}
  A -->|"sim"| O["Via oficial: wpp-business, Cloud API direto ou Twilio"]
  A -->|"não"| B{"Precisa de botões, catálogo, WhatsApp Flows ou conta verificada?"}
  B -->|"sim"| O
  B -->|"não"| C{"Um único número, sem exigência de multi-tenancy, auditoria ou alta disponibilidade?"}
  C -->|"sim"| E["Evolution API ou WPPConnect, sem custo de licença"]
  C -->|"não"| D{"Quer contratar em vez de operar, e aceita que a conversa passe por terceiro?"}
  D -->|"sim"| Z["Gateway brasileiro como a Z-API"]
  D -->|"não"| W["Catalisa WPP"]

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.


06

Modelo de cobrança e ROI

negó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. São três parcelas, duas a favor e uma contra:

flowchart LR
  R["Conta de guardanapo do WPP"]
  G1["Ganho direto: atendimento em janela aberta não paga por mensagem"]
  G2["Ganho indireto: nenhuma migração, nenhuma aprovação de template, nenhum projeto de infraestrutura"]
  C1["Custo contra: probabilidade de perder o número"]
  R --> G1
  R --> G2
  R --> C1

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.

O custo do outro lado da conta

Do outro lado 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.


07

Arquitetura

O WPP roda em três processos separados, ligados por Redis Streams. Nenhum deles faz o trabalho do outro, e é essa separação que sustenta todas as decisões desta seção.

flowchart TD
  CLI["Cliente HTTP com Bearer JWT do IAM"]

  subgraph P1["Processo 1 — API · src/wpp/main.ts · porta 3023 · Hono basePath /wpp"]
    API["7 routers · 45 rotas · mais /wpp/health"]
    OPS["Grava wpp_operation_results, enfileira o job e devolve 202 com correlationId"]
    API --> OPS
  end

  QUE["WppQueueService.enqueue sobre Redis Streams"]

  subgraph P2["Processo 2 — Session Worker · src/wpp/main-session-worker.ts · sem HTTP"]
    CONS["WppConsumer · grupo wpp-consumers · XREADGROUP mais XAUTOCLAIM a cada 15s"]
    SM["SessionManager"]
    OWN["OwnerRegistry · wpp:device-owner por org e device · claim e refresh por EVAL Lua · TTL 45s · heartbeat a cada 30s"]
    DF["DriverFactory"]
    BD["BaileysDriver"]
    CONS --> SM
    SM --> DF
    DF --> BD
    SM --- OWN
  end

  subgraph P3["Processo 3 — SSE · src/wpp/main-sse.ts"]
    SSE["GET /api/v1/events/:organizationId"]
  end

  WA["WhatsApp"]
  PG[("PostgreSQL · schema wpp · 14 tabelas")]
  PUB["Redis pub/sub · wpp-events por organização"]
  EVP["EventPublisher · wpp.v1.*"]
  DSP["DispatcherService · pré-filtro por device e por JSONPath"]
  WHE["Webhooks Engine · RSA-SHA256 · retry · log de entrega"]
  SYS["Sistema do cliente: CRM, esteira ou bot"]

  CLI --> API
  OPS --> QUE
  QUE --> CONS
  BD <--> WA
  BD --> PG
  BD --> PUB
  BD --> EVP
  PUB --> SSE
  EVP --> DSP
  DSP --> WHE
  WHE --> SYS
  API --> PG

As 45 rotas da API se distribuem assim — a leitura sempre vem do Postgres, nunca do socket:

PrefixoRouterRotas
/api/v1/devicesdeviceRouter13
/api/v1/messagesmessageRouter9
/api/v1/groupsgroupRouter9
/api/v1/dispatchersdispatcherRouter8
/api/v1/chatschatRouter2
/api/v1/configconfigRouter2
/api/v1/operationsoperationRouter2
/health—sonda de vida

Decisões não óbvias. Cada uma abaixo custou um incidente, um postmortem ou uma semana de depuração.

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.

sequenceDiagram
  autonumber
  participant C as Cliente
  participant API as WPP API
  participant PG as Postgres
  participant RS as Redis Streams
  participant W as Session Worker
  participant WA as WhatsApp
  C->>API: POST /messages/send
  API->>PG: grava a operation como pending
  API->>RS: enfileira o job
  API-->>C: 202 com correlationId
  W->>RS: XREADGROUP puxa o job
  W->>WA: envia pelo socket vivo
  WA-->>W: ack
  W->>PG: operation completed e linha em wpp_messages
  C->>API: consulta a operation pelo correlationId
  API-->>C: o resultado real do envio

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.

ProcessoEntrypointSobe comEscala por
APIsrc/wpp/main.tsporta 3023volume de requisição HTTP
Session Workersrc/wpp/main-session-worker.tsbun run dist/wpp/main-session-worker.mjsnúmero de dispositivos conectados
SSEsrc/wpp/main-sse.tsbun run dist/wpp/main-sse.mjsnúmero de espectadores com tela aberta

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 antes de escolher a stream:

flowchart TD
  J["Job enfileirado por WppQueueService"] --> B{"É device-bound?"}
  B -->|"não: connect e session-restore"| G["Stream global — qualquer réplica pega, porque estes jobs criam socket e devem se distribuir"]
  B -->|"sim: envio, grupo, ping, pairing-code, reconnect, reset, disconnect e download de mídia"| O{"Existe dono vivo do device?"}
  O -->|"dono X"| I["wpp:inbox:X — a réplica que tem o socket"]
  O -->|"ninguém"| P["wpp:pending por org e device — o job fica estacionado"]
  P --> R["Enfileira um session-restore para acordar o número"]
  R --> G
  I --> EX["Executa contra o socket vivo"]

Quando alguém assume o device, o wpp:pending:<org>:<device> é drenado e os jobs estacionados seguem para o inbox do novo dono.

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.

sequenceDiagram
  autonumber
  participant A as Réplica A
  participant B as Réplica B
  participant R as Redis
  Note over A,B: O que NÃO fazemos — GET seguido de SET deixa uma janela aberta
  A->>R: GET do dono
  R-->>A: vazio
  B->>R: GET do dono
  R-->>B: vazio
  A->>R: SET dono igual a A
  B->>R: SET dono igual a B
  Note over A,B: Dois sockets com a mesma credencial e o WhatsApp invalida a sessão

O script CLAIM_IF_FREE_OR_MINE roda inteiro numa execução do Redis, o que fecha essa janela. 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.

Atenção. 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, com a chave-mestra em WPP_BAILEYS_MASTER_KEY (64 caracteres hex). Um dump do Postgres, sozinho, não sequestra nenhum WhatsApp. O layout do valor armazenado é este:

texto
base64(  iv  ‖  authTag  ‖  ciphertext  )
        12B     16B         n bytes
base64(  iv  ‖  authTag  ‖  ciphertext  )
        12B     16B         n bytes

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ê.


08

Conceitos 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, expiresAt — sem 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

Como as tabelas se ligam. Só quatro modelos têm chave estrangeira de verdade; o resto se amarra por organizationId e deviceId em coluna, sem FK — o que permite apagar um device sem cascatear a conversa.

erDiagram
  WppChat ||--o{ WppMessage : "agrupa as mensagens de"
  WppMessage ||--o{ WppMessageReaction : "recebe"
  WppChat ||--o| WppGroup : "é a conversa do"
  WppGroup ||--o{ WppGroupParticipant : "lista"

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. O diagrama abaixo reproduz as transições que o código realmente executa, conferidas em SessionManager (updateDeviceStatus, wipeAuthState, handleDisconnect, scheduleReconnect) e no padrão do Prisma.

stateDiagram-v2
  direction TB
  [*] --> qr: POST /devices cria a linha com o status padrão do Prisma
  qr --> qr: o socket reemite o QR a cada 60s enquanto ninguém pareia
  qr --> open: QR lido no celular e sessão estabelecida
  connecting --> qr: o socket pede novo pareamento e emite QR
  connecting --> open: credencial válida, conexão aberta
  connecting --> connecting: nova tentativa de reconexão dentro do teto
  open --> connecting: queda recuperável de rede, 428 ou 515 agenda reconexão com backoff
  connecting --> closed: teto de 5 tentativas estourado, circuito aberto
  open --> closed: disconnect manual ou destroySession
  open --> logged_out: 401 LoggedOut ou 410 BadSession
  open --> banned: 405 Banned
  closed --> open: POST /connect com credencial ainda válida
  closed --> qr: POST /connect sem credencial válida
  logged_out --> connecting: POST /connect com force true apaga a credencial morta
  banned --> connecting: POST /connect com force true apaga a credencial morta

Três coisas que o diagrama não diz em voz alta e que valem a leitura:

PontoO que acontece de fato
Terminaislogged_out e banned são estados terminais. Um connect simples ali devolve 409 requires_repair; só POST /connect com {"force": true} sai deles, porque apaga a credencial morta antes de registrar do zero
connecting é órfãoNada reconcilia sozinho um device preso em connecting (§15). A saída é um connect explícito
resetPOST /devices/:deviceId/reset destrói a sessão, apaga a credencial persistida e devolve o device para connecting, de onde ele reconecta do zero. É o último recurso

Atenção. O enum WppDeviceStatus também declara pairing e reset, mas nenhum caminho de código atribui esses dois valores hoje. Pedir o código de pareamento (POST /devices/:deviceId/pairing-code) grava pairingCode, pairingPhoneNumber e pairingExpiresAt no device e não muda o status — o mesmo socket pode continuar mostrando QR, então o device permanece em qr ou connecting. E o fluxo de reset passa por connecting, nunca por reset. Não escreva integração que espere por esses dois valores.

O caminho alternativo do código de 8 caracteres. Ele não é um estado à parte: é outra forma de fechar o pareamento a partir do mesmo socket.

flowchart LR
  A["POST /devices/:deviceId/connect"] --> B["Socket existe e chega ao estado qr"]
  B --> C["POST /devices/:deviceId/pairing-code com o phoneNumber"]
  C --> D["Grava pairingCode no device, sem mexer no status"]
  D --> E["O usuário digita o código no celular"]
  E --> F["Device vai para open"]
  B --> G["Ou o usuário lê o QR de GET /devices/:deviceId/qr"]
  G --> F

Como um envio chega ao WhatsApp

flowchart TD
  S["POST /messages/send"] --> R202["202 com correlationId — a API para aqui"]
  R202 --> OP["wpp_operation_results gravado como pending"]
  OP --> Q["WppQueueService classifica: envio é device-bound"]
  Q --> W{"Quem tem a posse do device?"}
  W -->|"dono X"| IN["wpp:inbox:X"]
  W -->|"ninguém"| PE["wpp:pending por org e device, mais um session-restore enfileirado"]
  PE -.->|"quando alguém assume o device, o pending é drenado"| IN
  IN --> DS["BaileysDriver.dispatchSend"]
  DS --> NZ["Normaliza @lid para o PN e confere USync ou onWhatsApp"]
  NZ --> EX{"O número existe no WhatsApp?"}
  EX -->|"sim"| OK["Envia e grava o messageId"]
  EX -->|"não"| ERR["Erro not_on_whatsapp com 422, em vez de um entregue mentiroso"]
  OK --> FIN["Operation completed e evento wpp.v1.message.sent"]

09

Referê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. 202 — ver §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ógica — deletedAt preenchido, assinatura desabilitada, logs preservados. 204WPP_DISPATCHERS_WRITE
POST/wpp/api/v1/dispatchers/:id/toggleAlterna ACTIVE ↔ PAUSED. Evento recebido enquanto pausado é descartado, não enfileiradoWPP_DISPATCHERS_WRITE
GET/wpp/api/v1/dispatchers/:id/delivery-logsLog de entregas. page, pageSize, status ∈ PENDING · 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

json
{ "deviceId": "atendimento-principal", "driver": "baileys", "syncFullHistory": false }
{ "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":{...}}}).

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.

json
{ "force": true }
{ "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

json
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }
{ "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

json
{
  "qr": "2@Xb3k...",
  "generatedAt": "2026-08-16T14:02:11.000Z",
  "status": "qr",
  "expired": false,
  "expiresIn": 43
}
{
  "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

json
{
  "deviceId": "atendimento-principal",
  "jid": "5511999999999@s.whatsapp.net",
  "text": "Sua proposta foi aprovada."
}
{
  "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á materializada → 302 para a URL assinada. Siga o redirecionamento.
  • Ainda não → 202 com correlationId e pollUrl, porque baixar exige o socket vivo, que está em outro processo.
flowchart TD
  G["GET /messages/media/:messageId"] --> Q{"A mídia já está no File Storage?"}
  Q -->|"sim"| R["302 para a URL assinada"]
  Q -->|"não"| E["202 com correlationId e pollUrl"]
  E --> J["Job de download no inbox da réplica dona do device"]
  J --> D["O socket vivo baixa e descriptografa a mídia"]
  D --> S["Grava no File Storage e preenche mediaFileId"]
  S --> R
json
{
  "data": {
    "enqueued": true,
    "correlationId": "b21c...",
    "pollUrl": "/api/v1/operations/b21c..."
  }
}
{
  "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

json
{
  "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 }
}
{
  "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.*.

flowchart TD
  C["POST /dispatchers"] --> F{"Tem deviceId ou conditions?"}
  F -->|"sim"| PRE["O WPP pré-filtra e republica como wpp.dispatch.id — a assinatura escuta só esse evento"]
  F -->|"não"| M{"Qual é o mode?"}
  M -->|"ADVANCED"| A["Assina exatamente os events informados"]
  M -->|"BASIC com messageTypes"| B["Assina wpp.v1.message.received.tipo para cada tipo da lista"]
  M -->|"BASIC sem messageTypes"| D["Cai no padrão wpp.v1.message.received.*"]
  PRE --> WH["Assinatura provisionada no Webhooks Engine"]
  A --> WH
  B --> WH
  D --> WH

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

json
{
  "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
}
{
  "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.


10

Início rápido

Do zero ao primeiro WhatsApp pareado e à primeira mensagem enviada. São seis passos, e este é o desenho deles antes do primeiro curl:

sequenceDiagram
  autonumber
  participant V as Você
  participant IAM as IAM
  participant API as WPP API
  participant W as Session Worker
  participant Cel as Celular com o WhatsApp
  V->>IAM: 1. POST /users/login
  IAM-->>V: accessToken
  V->>API: 2. POST /devices
  API-->>V: 201 com o device em status qr
  V->>API: 3. POST /devices/demo-01/connect
  API-->>V: 202 com correlationId
  W->>W: cria o socket e recebe o QR do WhatsApp
  V->>API: 4. GET /devices/demo-01/qr
  API-->>V: a string do QR, válida por 60s
  V->>Cel: renderiza e lê o QR
  Cel->>W: pareia como aparelho companheiro
  V->>API: 5. GET /devices/demo-01/status
  API-->>V: open
  V->>API: 6. POST /messages/send
  API-->>V: 202 com correlationId
  V->>API: consulta a operation pelo correlationId
  API-->>V: completed com o messageId

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

bash
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)
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

bash
curl -s -X POST "$BASE/devices" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"deviceId":"demo-01","driver":"baileys"}' | jq
curl -s -X POST "$BASE/devices" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"deviceId":"demo-01","driver":"baileys"}' | jq
json
{ "data": { "id": "9c1f...", "deviceId": "demo-01", "status": "qr", "driver": "baileys" } }
{ "data": { "id": "9c1f...", "deviceId": "demo-01", "status": "qr", "driver": "baileys" } }

3. Pedir a conexão

bash
curl -s -X POST "$BASE/devices/demo-01/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' | jq
curl -s -X POST "$BASE/devices/demo-01/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' | jq
json
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }

4. Ler o QR e parear

bash
curl -s "$BASE/devices/demo-01/qr" -H "Authorization: Bearer $TOKEN" | jq
curl -s "$BASE/devices/demo-01/qr" -H "Authorization: Bearer $TOKEN" | jq
json
{ "qr": "2@Xb3k...", "status": "qr", "expired": false, "expiresIn": 43 }
{ "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

bash
curl -s "$BASE/devices/demo-01/status" -H "Authorization: Bearer $TOKEN" | jq
curl -s "$BASE/devices/demo-01/status" -H "Authorization: Bearer $TOKEN" | jq
json
{ "status": "open" }
{ "status": "open" }

open é o único status em que enviar funciona.

6. Enviar a primeira mensagem

bash
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
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
json
{
  "data": {
    "correlationId": "1a2b...",
    "operation": "send-message",
    "status": "completed",
    "result": { "messageId": "3EB0C767D0...", "remoteJid": "5511999999999@s.whatsapp.net" }
  }
}
{
  "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.


11

Receitas

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. A ordem importa mais aqui do que em qualquer outra receita:

sequenceDiagram
  autonumber
  participant V as Você
  participant API as WPP API
  participant W as Session Worker
  participant WA as WhatsApp
  participant P as Pessoa ao telefone
  V->>API: POST /devices/demo-01/connect
  W->>WA: cria o socket e chega ao estado qr
  Note over V,W: espere cerca de 12s — sem socket, o próximo passo falha
  V->>API: POST /devices/demo-01/pairing-code com o phoneNumber
  API-->>V: 202 com correlationId
  W->>WA: pede o código contra o socket vivo
  WA-->>W: código de 8 caracteres
  V->>API: consulta a operation ou o device
  API-->>V: pairingCode
  V->>P: dita o código pelo telefone
  P->>WA: digita no celular e o device vai para open

1. Conectar primeiro. O socket precisa existir antes; sem connect, o pedido falha com "No active session".

bash
curl -s -X POST "$BASE/devices/demo-01/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
sleep 12
curl -s -X POST "$BASE/devices/demo-01/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
sleep 12
json
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }

2. Pedir o código

bash
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')
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')
json
{ "data": { "enqueued": true, "correlationId": "3f5a91b0-..." } }
{ "data": { "enqueued": true, "correlationId": "3f5a91b0-..." } }

3. Ler o resultado pela operation

bash
curl -s "$BASE/operations/$CID" -H "Authorization: Bearer $TOKEN" | jq '.data.result'
curl -s "$BASE/operations/$CID" -H "Authorization: Bearer $TOKEN" | jq '.data.result'
json
{ "pairingCode": "ABCD1234" }
{ "pairingCode": "ABCD1234" }

4. Ou ler direto do device

bash
curl -s "$BASE/devices/demo-01/pairing-code" -H "Authorization: Bearer $TOKEN" | jq
curl -s "$BASE/devices/demo-01/pairing-code" -H "Authorization: Bearer $TOKEN" | jq
json
{ "pairingCode": "ABCD1234", "phoneNumber": "5511930802555", "expiresAt": "2026-08-16T14:05:11.000Z" }
{ "pairingCode": "ABCD1234", "phoneNumber": "5511930802555", "expiresAt": "2026-08-16T14:05:11.000Z" }

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

O caminho que uma mensagem recebida percorre até bater no seu endpoint passa por três peças, e saber qual delas você está depurando economiza horas:

sequenceDiagram
  autonumber
  participant WA as WhatsApp
  participant W as Session Worker
  participant D as DispatcherService
  participant WH as Webhooks Engine
  participant S as Seu endpoint
  WA-->>W: mensagem recebida
  W->>W: persiste em wpp_messages
  W->>D: publica wpp.v1.message.received.text
  D->>D: aplica o pré-filtro de device e as conditions JSONPath
  D->>WH: republica o que passou
  WH->>S: POST assinado em RSA-SHA256
  S-->>WH: 200 em até 2s
  Note over WH,S: em 429, 5xx ou timeout, 5 tentativas com backoff 1s, 2s, 4s, 8s e 16s

1. Criar o dispatcher

bash
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')
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')
json
{ "data": { "id": "4c81...", "name": "CRM — recebidas do atendimento", "mode": "BASIC", "status": "ACTIVE" } }
{ "data": { "id": "4c81...", "name": "CRM — recebidas do atendimento", "mode": "BASIC", "status": "ACTIVE" } }

2. Conferir as entregas

bash
curl -s "$BASE/dispatchers/$DISP/delivery-logs?status=FAILED" \
  -H "Authorization: Bearer $TOKEN" | jq
curl -s "$BASE/dispatchers/$DISP/delivery-logs?status=FAILED" \
  -H "Authorization: Bearer $TOKEN" | jq
json
{ "data": [ { "id": "a10f...", "status": "FAILED", "attempts": 5, "responseStatus": 502 } ] }
{ "data": [ { "id": "a10f...", "status": "FAILED", "attempts": 5, "responseStatus": 502 } ] }

3. Reenviar uma entrega específica

bash
curl -s -X POST "$BASE/dispatchers/$DISP/delivery-logs/<logId>/retry" \
  -H "Authorization: Bearer $TOKEN" | jq
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. O ponto que quase ninguém sabe é que o direito de enviar nasce de o contato ter escrito primeiro:

sequenceDiagram
  autonumber
  participant Cont as Contato
  participant WA as WhatsApp
  participant W as Seu número no WPP
  Cont->>WA: escreve para o seu número
  WA->>W: entrega a mensagem e cria o tctoken de confiança
  W->>WA: envio permitido, com ack normal
  Note over W,WA: sem esse primeiro contato, o envio de alcance volta com ack 463
  W-->>WA: envio frio para quem nunca escreveu
  WA-->>W: ack 463 e o bloqueio acumula na conta remetente
bash
# 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":"..."}'
# 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. O diagnóstico inteiro cabe numa árvore:

flowchart TD
  S["GET /devices/demo-01/status"] --> A{"Qual é o status?"}
  A -->|"open"| OK["Saudável — se ainda assim não envia, vá para a receita do 463"]
  A -->|"connecting com o registro velho"| CN["Preso: nada reconcilia sozinho. Chame connect"]
  A -->|"closed"| CL["Circuito aberto após esgotar as reconexões. Chame connect"]
  A -->|"logged_out"| LO["Credencial morta. Chame connect com force true e repareie"]
  A -->|"banned"| BN["Número bloqueado pelo WhatsApp — código não resolve. Ver §15"]
  CN --> C["POST /connect — nunca reconnect"]
  CL --> C
  LO --> F["POST /connect com force true"]
  C --> R{"Voltou para open?"}
  F --> R
  R -->|"não"| RST["POST /devices/demo-01/reset — último recurso, exige novo pareamento presencial"]

1. Descobrir o estado

bash
curl -s "$BASE/devices/demo-01/status" -H "Authorization: Bearer $TOKEN" | jq
curl -s "$BASE/devices/demo-01/status" -H "Authorization: Bearer $TOKEN" | jq
json
{ "status": "connecting" }
{ "status": "connecting" }
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

bash
curl -s -X POST "$BASE/devices/demo-01/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -s -X POST "$BASE/devices/demo-01/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
json
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }
{ "data": { "enqueued": true, "correlationId": "7d9f1c22-..." } }

3. Device terminal exige reparo explícito

bash
curl -s -X POST "$BASE/devices/demo-01/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"force": true}'
curl -s -X POST "$BASE/devices/demo-01/connect" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"force": true}'

Sem o force, um device em logged_out ou banned responde 409, com a mensagem começando em requires_repair:

json
{
  "error": "CONFLICT",
  "message": "requires_repair: device status is 'logged_out' — stored credentials are dead; re-pair explicitly (connect with force=true, then request a new QR/pairing code)"
}
{
  "error": "CONFLICT",
  "message": "requires_repair: device status is 'logged_out' — stored credentials are dead; re-pair explicitly (connect with force=true, then request a new QR/pairing code)"
}

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

1. Achar uma mensagem com imagem

bash
MSG=$(curl -s "$BASE/messages?filter[type]=image&page[size]=1" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data[0].id')
MSG=$(curl -s "$BASE/messages?filter[type]=image&page[size]=1" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data[0].id')

2. Pedir a mídia, seguindo o redirecionamento

bash
# -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"
# -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"
texto
200
200

3. Se voltar 202, a mídia ainda não foi baixada

Acompanhe o pollUrl da resposta e repita depois:

json
{ "data": { "enqueued": true, "correlationId": "b21c...", "pollUrl": "/api/v1/operations/b21c..." } }
{ "data": { "enqueued": true, "correlationId": "b21c...", "pollUrl": "/api/v1/operations/b21c..." } }

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. Ele nasce de dois caminhos diferentes, e é isso que explica o null:

flowchart TD
  C["Contato sincronizado do WhatsApp"] --> T{"Qual é o formato do jid?"}
  T -->|"digitos@s.whatsapp.net"| P1["phone sai direto da parte de usuário do jid"]
  T -->|"opaco@lid"| M{"O mapeamento lid para PN já foi aprendido?"}
  M -->|"sim"| P2["phone resolvido"]
  M -->|"ainda não"| P3["phone fica null até a primeira interação"]
  P3 -.->|"o contato conversa e o mapeamento é aprendido"| P2
  P1 --> K["Case por igualdade exata contra a sua base, com DDI"]
  P2 --> K
bash
curl -s "$BASE/chats/contacts?deviceId=demo-01&page[size]=100" \
  -H "Authorization: Bearer $TOKEN" | jq '.data[] | {phone, name, jid}'
curl -s "$BASE/chats/contacts?deviceId=demo-01&page[size]=100" \
  -H "Authorization: Bearer $TOKEN" | jq '.data[] | {phone, name, jid}'
json
{ "phone": "5511934481322", "name": "Maria Silva", "jid": "216457879245011@lid" }
{ "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

SSE e dispatcher resolvem problemas diferentes, e escolher errado é a origem de "meu evento sumiu":

SSEDispatcher (webhook)
Para quêPainel com tela abertaIntegração que precisa receber sempre
Entrega com o cliente fechadoNão, o evento se perdeSim, com retentativa e log
Assinatura criptográficaNãoSim, RSA-SHA256
Onde rodaProcesso e porta próprios, fora de /wppWebhooks Engine
AutenticaçãoHeader Authorization ou ?access_token=Chave pública por keyId
bash
ORG=b0000000-0000-0000-0000-000000000001
curl -N "https://wpp-sse.bb.stg.catalisa.app/api/v1/events/$ORG?access_token=$TOKEN"
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.

12

Integraçã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
flowchart LR
  IAM["IAM"] -->|"Bearer JWT com organizationId e permissões WPP_*"| WPP
  WA["WhatsApp via Baileys"] <--> WPP["WPP — canal não-oficial"]
  WPP -->|"mídia recebida"| FS["File Storage em S3"]
  WPP -->|"quem disparou o quê"| AT["Audit Trail"]
  WPP -->|"phone do contato"| CU["Customers"]
  WPP -->|"eventos wpp.v1.*"| DSP["Dispatcher — filtro por device e JSONPath"]
  DSP -->|"RSA-SHA256"| WHE["Webhooks Engine — retry e log de entrega"]
  WHE --> SYS["Sistema do cliente: CRM, esteira ou bot"]

E o caminho de saída, no mesmo catálogo e com as mesmas peças em volta:

flowchart LR
  IAM["IAM — o mesmo token"] --> WB
  WB["wpp-business — 151 rotas"] -->|"Cloud API oficial"| META["Meta"]
  WB -->|"mesmo formato de evento wpp.v1.*"| WHE["Webhooks Engine — o mesmo"]
  WHE --> SYS["O mesmo sistema do cliente, sem reescrever a integração"]

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.


13

Configuraçã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ção — PATCH /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
400—deviceId ausente em /chats/contactsPasse ?deviceId=
400—Mensagem sem mídia baixávelSó image, video, audio, document, sticker
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove no IAM
403—Token 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
409—Mensagem 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).

O desenho do ponto cego é este, e vale colar na parede de quem opera:

flowchart LR
  HC["GET /wpp/health"] --> API["Processo da API"]
  API --> V["Verde — só prova que o HTTP está de pé"]
  W["Session Worker sem HTTP"] -.->|"não é coberto por health check nenhum"| HC
  RD["Cliente Redis morto em silêncio"] --> W
  W --> X["Laço de consumo parado — nenhuma mensagem é processada"]
  WA["WhatsApp"] --> SK["Socket segue vivo e persiste direto no Postgres"]
  SK --> ILU["A ilusão de normalidade que segurou o incidente por 8 horas"]
  • 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:
sql
select "deviceId", status, "lastError", "updatedAt", now() - "updatedAt" as parado_ha
from wpp.wpp_devices
where "organizationId" = :org
order by "updatedAt" desc;
select "deviceId", status, "lastError", "updatedAt", now() - "updatedAt" as parado_ha
from wpp.wpp_devices
where "organizationId" = :org
order by "updatedAt" desc;

14

Seguranç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.

A corrente de verificação, na ordem em que roda, é esta:

flowchart LR
  REQ["Requisição HTTP"] --> A["authMiddleware — valida o JWT do IAM"]
  A -->|"token ausente ou inválido"| E401["401"]
  A --> P["requirePermission WPP_* — confere a permissão exata da rota"]
  P -->|"falta permissão"| E403P["403"]
  P --> O["requireOrganization — exige o claim organizationId"]
  O -->|"token sem organizationId"| E403O["403"]
  O --> S["Serviço e repositório sempre com o organizationId do token, nunca do corpo"]
  S --> DB[("Linha do schema wpp, que também carrega organizationId")]

Atenção. No canal de SSE a verificação tem um passo a mais: o organizationId do path é comparado com o do token e a conexão é recusada quando divergem. Não há como assinar o canal de outra organização.

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.


15

Limitaçõ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 realSó baileys. O mock é de teste, e a abstração IWhatsAppDriver está pronta para um segundo, que não existePor design, hoje

16

Perguntas 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.

wppwpp-business
CanalNão-oficial, via BaileysCloud API oficial da Meta
PareamentoQR ou código de 8 caracteresMigração do número para a plataforma
Celular continua no mesmo númeroSimNão
Template aprovadoNão existeExigido para toda proativa
Custo por mensagemNãoSim, por mensagem entregue
Risco de banimento por ferramentaSimNão
Rotas45151
Campanha, catálogo, fluxos, pagamento, caixa com agentesNãoSim
IAM, Webhooks Engine e formato wpp.v1.*Os mesmosOs mesmos

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/`