Infraestrutura própria, em produção

A carteira debita antes de a mensagem sair

O hub é a camada que fica entre os nossos produtos e a API oficial do WhatsApp: ela envia, recebe, cobra e guarda a prova de cada conversa. A restrição que desenhou tudo o que vem depois é comercial antes de ser técnica: a Meta cobra por conversa e não por tentativa, então uma fila que reprocessa sem controle não produz log feio, produz fatura.

  • 2, cada um com número e marca próprios
    Produtos em produção sobre o hub
  • 29
    Modelos aprovados pela Meta
  • 5, mais fila morta em Postgres
    Filas de trabalho
  • 25 documentos
    Decisões de arquitetura registradas
  • 523 unitários e 46 de integração
    Testes automáticos
  • cerca de 5 minutos
    Do push ao código no ar
  • R$ 50 por padrão, ajustável
    Teto de gasto diário por produto

O canal que todo mundo quer e a fatura que ninguém vê chegar

Toda empresa quer falar com o cliente onde ele já está, e ele já está no WhatsApp. O caminho oficial da Meta, porém, cobra por conversa iniciada, e o preço muda com a categoria: marketing custa mais do que utilidade e autenticação, enquanto a conversa de serviço, aberta dentro da janela de vinte e quatro horas depois de uma resposta do cliente, não custa nada. Quem integra sem entender essa tabela não erra num relatório, erra numa fatura.

O segundo problema não é de preço, é de repetição. A Cloud API não aceita chave de idempotência no envio, então um POST que estoura o tempo limite pode ter entregado a mensagem ou não, e a resposta honesta a essa ambiguidade é tentar de novo. Sem um contrato próprio de unicidade acima do transporte, cada retentativa vira uma conversa nova cobrada e, pior, uma mensagem repetida no telefone de alguém que não pediu duas.

O terceiro é jurídico e não perdoa improviso: mensagem de marketing exige consentimento registrado, pedido de saída precisa valer na hora, e o histórico tem que existir para ser exportado ou apagado depois. Resolver as três coisas uma vez, num lugar só, sai mais barato do que resolvê-las pela metade dentro de cada produto: foi essa a conta que deu origem ao hub.

Uma camada só, dois produtos e uma marca que nunca aparece

O hub é multi-tenant por desenho, e não por configuração: cada produto entra por uma chave de API própria, tem carteira própria e enxerga apenas os seus dados, enquanto o roteamento leva cada tenant ao seu próprio número. Dois produtos rodam assim em produção hoje, sob o portfólio Meta da MVAS e com uma conta do WhatsApp Business separada para cada um. O teto é de dois números por portfólio e sobe para vinte quando o portfólio é verificado, então crescer daqui é decisão de conta, não obra nova no código.

O que faz isso funcionar como marca branca é um detalhe do próprio WhatsApp: o nome de exibição é aprovado por número, individualmente, pela Meta. Quem recebe a mensagem vê a marca daquele número e nada além dela: o intermediador não assina rodapé, não aparece no perfil e não entra na conversa. Trocar o nome depois exige nova aprovação, então o dinamismo mora no roteamento de tenant para número, e não na mensagem.

Uma premissa nossa caiu por medição no meio do caminho. Assumíamos que a Meta só entrega número de teste e que produção exigiria linha própria, com chip e verificação por SMS ou voz. No fluxo de nome de exibição, a Meta provisiona um número dela que já nasce em modo ativo, verificado e no tier de duzentas e cinquenta conversas únicas por dia: conferimos os dois pela Graph API antes de acreditar. Trazer linha própria continua possível, mas por posse e escala, não por requisito.

O débito acontece antes de a Meta ser chamada

A regra que organiza o resto é uma inversão simples: a carteira do produto é debitada antes de a Meta ser chamada, e a conciliação vem depois, quando o callback confirma o que aconteceu. É o contrário do intuitivo, porque parece mais natural cobrar quando a entrega confirma, só que cobrar depois significa gastar sem saldo durante toda a janela entre a chamada e a resposta, e é exatamente nessa janela que uma fila com defeito multiplica o prejuízo.

O preço sai em real, e o câmbio não pode derrubar envio. O cálculo usa a PTAX do dia com um spread configurável, e a degradação é encadeada: se a cotação do dia falha, vale o cache; se o cache falha, vale a última taxa gravada no banco; se nada disso existe, vale a taxa fixa do ambiente. O serviço de câmbio não lança exceção por princípio, porque dinheiro não pode parar por causa da API de um terceiro. Cada número entra em produção com quarenta e oito regras de preço semeadas.

Em cima disso ficam as travas de gasto. Existe um teto diário por produto, cinquenta reais por padrão e ajustável, para que um laço mal escrito do outro lado não vire fatura de quatro dígitos. Envio agendado aceita até trinta dias de antecedência e debita no aceite; cancelar até sessenta segundos antes do disparo estorna e emite o evento de cancelamento. Campanha é tudo ou nada: o valor inteiro sai da carteira quando ela é aceita e, se algo falha no meio, o lote é cancelado e estornado por completo em vez de sair pela metade.

Estorno em dobro é o erro que ninguém percebe olhando log. Fechamos essa porta no banco, com um índice único parcial sobre a referência do estorno, e ensinamos o serviço de carteira a tratar a violação desse índice como operação já feita, e não como falha: a segunda tentativa vira uma não-operação silenciosa em vez de derrubar um worker. A trava é do banco, então ela vale mesmo quando o erro estiver no código.

Entregar uma vez, e uma vez só

A idempotência é por chave escolhida pelo produto: o mesmo identificador de mensagem chegando duas vezes produz um efeito só. É isso que torna seguro tentar de novo, e tentar de novo é obrigatório, porque a Cloud API não oferece chave de idempotência própria e um tempo limite estourado no envio é ambíguo por natureza. Assumimos entrega ao menos uma vez no transporte e resolvemos a unicidade acima dele, onde temos banco e controle.

A política de retentativa é uma taxonomia, não um número. Erro da faixa 400 vindo da Meta é determinístico e nunca é reenviado pelo cliente HTTP; erro da faixa 500 e limite de taxa sobem marcados como recuperáveis e deixam a fila tentar de novo com espera crescente; e o limite de envio da Meta, que é por número e cresce por qualidade, vira um acelerador por identificador de número dentro do worker de envio. Os erros que mudam a conduta do produto: janela de vinte e quatro horas expirada, destinatário inalcançável, parâmetro de template incompatível, token vencido: são mapeados um a um, com nome, e não caem num balde genérico.

No sentido inverso, o webhook da Meta tem contrato de tempo: responder 200 em até vinte segundos, sempre, mesmo com erro interno, senão a Meta reenvia o evento. Então a rota confere a assinatura antes de processar qualquer coisa, publica o evento e devolve; o trabalho real acontece em cinco filas separadas, uma para envio, uma para o webhook de entrada, uma para o callback ao produto, uma para eliminação de dados a pedido e uma para auto-resposta. O que falha depois de todas as tentativas não some: cai numa fila morta em Postgres, com motivo registrado e caminho de reprocessamento.

Esse mesmo caminho de envio sustenta o código de verificação como serviço. O produto pede a emissão, o hub gera seis dígitos, guarda apenas o hash em BCrypt (nunca o código em claro, nunca em log), aplica limite duplo em Redis por telefone e por produto, invalida o código anterior a cada nova emissão e resolve a verificação com uma troca atômica de estado, de modo que dois cliques simultâneos não consumam o mesmo código duas vezes. O envio reaproveita o fluxo que já existe, com débito, fila e idempotência resolvidos, em vez de abrir uma segunda porta para o dinheiro.

Consentimento como trava de código, não como cláusula de contrato

Mensagem de marketing só sai para quem registrou aceite, e a checagem é fechada por padrão: sem o registro de opt-in, o envio é recusado com erro próprio antes do débito, e não depois. Campanha não descobre isso no meio do disparo: ela pré-filtra a lista e devolve quantos destinatários ficaram de fora por falta de aceite. Template de categoria desconhecida é resolvido como marketing, porque em dúvida a decisão conservadora é a que não gera reclamação.

A saída é igualmente literal. Uma mensagem de entrada com a palavra exata de descadastro marca o contato como fora da lista, sem resposta automática e sem cerimônia; o casamento é exato de propósito, para que a palavra no meio de uma frase não tire da lista quem não pediu. E a marcação roda em melhor esforço: se ela falhar, não derruba o processamento do webhook, porque perder o evento inteiro seria pior do que repetir a tentativa.

O resto é higiene de dados tratada como código. Retenção, exportação e eliminação a pedido rodam num worker próprio, com o pedido virando trabalho rastreável em vez de tarefa manual. Os tokens da Meta ficam cifrados em repouso com AES-256-GCM, as chaves de API dos produtos existem apenas como hash, e telefone é tratado como dado pessoal em toda a malha de log: no máximo os quatro últimos dígitos aparecem.

O que segura isso quando ninguém está olhando

A operação roda numa VPS de oito gigabytes com Docker Compose e Caddy cuidando do certificado, sem plataforma de orquestração no meio: foi decisão explícita para não gastar memória com painel. Em produção só o proxy publica porta; banco, fila, API e console ficam na rede interna, e o contêiner da API roda sem privilégio de root. Cada mudança na branch principal passa pelos testes, vira imagem publicada no registro e chega à VPS pelo ciclo do atualizador: do push ao código no ar, cerca de cinco minutos, sem SSH e sem credencial de produção guardada no CI.

A malha de verificação é o que permite mexer sem medo: quinhentos e vinte e três testes unitários e quarenta e seis de integração rodando contra Postgres e Redis reais em contêiner, além dos testes de ponta a ponta do console, dezoito migrações versionadas e vinte e cinco decisões de arquitetura escritas: cada uma dizendo o que foi decidido, o que foi descartado e por quê. É por isso que integrar com o hub não depende de alguém lembrar de nada.

Dois achados recentes dizem mais do que qualquer declaração de rigor. O tempo real do console não funcionava no navegador havia semanas sem produzir um único erro em log: sequestrar a resposta para escrever o fluxo de eventos descarta os cabeçalhos postos por hooks, inclusive o de CORS, e a reconexão automática do próprio EventSource mascarava a queda a cada cinco segundos. O outro foi de observabilidade: o rótulo de rota das métricas caía para o caminho cru quando a rota não existia, e um varredor com dez mil caminhos criaria dez mil séries e derrubaria o monitoramento da máquina. Hoje esse rótulo é constante, e o conjunto de valores possíveis de cada um é fechado antes de a métrica existir.

Nada disso é maquete: o hub responde num endereço próprio, o console em outro, e dois produtos em produção estão conectados por chave de API, cada um com o seu número e os seus modelos aprovados pela Meta. Parte do caminho está pronta e desligada de propósito: a recarga por Pix aguarda credencial, a auto-resposta com IA existe atrás de uma chave por produto e hoje está desligada nos dois, e o cadastro embutido da Meta espera revisão de aplicativo. Preferimos assim: ligar por variável de ambiente é decisão de negócio, escrever de novo é obra.