Como construímos a integração com a WhatsApp Cloud API oficial da Meta — o que aprendemos
Integrar WhatsApp Cloud API oficial da Meta parece simples. Não é.
Documentação incompleta, sandbox que mente, token que expira
silenciosamente em produção. Passamos por tudo isso construindo
o RelayOS. Documentamos aqui o que aprendemos
O problema que queríamos resolver
As opções disponíveis no mercado brasileiro eram ruins:
- Z-API / Evolution API: funciona, mas não-oficial. Vimos número ser banido em produção.
- Twilio: caro em dólar, contrato, overhead desnecessário.
- Construir do zero: semanas só de infra, antes de mandar a primeira mensagem.
Decidimos construir uma camada simples, confiável e oficial em cima da Meta Cloud API.
Stack
Java 21 + Spring Boot 3.4
PostgreSQL 16 + Flyway
Railway (deploy)
O que implementamos
Retry com backoff exponencial
Se a Meta estiver instável, a mensagem não se perde. Um scheduler
roda a cada 10s buscando mensagens elegíveis para reenvio.
Usamos FOR UPDATE SKIP LOCKED para garantir que funciona
em múltiplas instâncias sem precisar de Redis ou lock externo:
SELECT * FROM messages
WHERE status IN ('QUEUED', 'FAILED')
AND next_attempt_at <= NOW()
AND processing_started_at IS NULL
FOR UPDATE SKIP LOCKED
LIMIT 10
Backoff: 30s → 2min → 10min → 30min → 2h. Após 5 tentativas, status vira FAILED.
Idempotency-Key por header
Padrão do Stripe. Se o cliente mandar o mesmo request duas vezes
(retry de rede, bug no sistema dele), só uma mensagem é enviada.
curl -X POST https://api.relayos.com.br/v1/messages \
-H "Authorization: Bearer rly_live_..." \
-H "Idempotency-Key: pedido-42-lembrete" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511999998888",
"template": "lembrete_consulta",
"language": "pt_BR",
"variables": {"1": "João", "2": "14h"}
}'
A chave é escopada por projeto. Mesmo Idempotency-Key em projetos
diferentes não conflita.
Outbound webhooks com retry
Quando o status de uma mensagem muda (SENT → DELIVERED → READ),
fazemos um POST no callbackUrl do cliente automaticamente.
{
"event": "message.delivered",
"message_id": "uuid-...",
"to": "+5511999998888",
"template": "lembrete_consulta",
"status": "DELIVERED",
"occurred_at": "2026-05-14T14:22:10Z"
}
Se o endpoint do cliente falhar, reprocessamos com backoff.
O cliente também pode consultar o histórico via GET /v1/webhook-deliveries.
Bugs que nos custaram tempo
1. @Async + @Transactional na mesma classe — Spring AOP silencioso
Spring usa proxy AOP para interceptar chamadas de método.
Quando você chama this.metodo() dentro da mesma classe,
a chamada não passa pelo proxy — @Transactional é ignorado
silenciosamente, sem erro, sem aviso.
O sintoma: método marcado como @Transactional rodava sem transação.
save() não persistia. Dados sumiam.
Fix: extrair qualquer método @Transactional que precisa ser chamado
de dentro do mesmo bean para uma classe separada:
// ❌ Não funciona — this.applyStatus() ignora @Transactional
@Async
public void deliver(UUID messageId) {
MetaSendResponse resp = metaClient.send(req);
this.applyStatus(messageId, resp); // proxy não intercepta
}
// ✅ Funciona — bean externo passa pelo proxy AOP
@Async
public void deliver(UUID messageId) {
MetaSendResponse resp = metaClient.send(req);
messageStatusService.applyStatus(messageId, resp); // proxy intercepta
}
Mesma regra vale para @Scheduled + @Transactional na mesma classe.
2. Sandbox da Meta com números brasileiros
Durante os testes, mensagens retornavam 200 OK com wamid real:
{
"messages": [{"id": "wamid.HBgL...", "message_status": "accepted"}]
}
Mas não chegavam no dispositivo. Sem erro, sem log de falha.
Depois de horas debugando, descobrimos: é uma limitação conhecida
do sandbox da Meta com alguns números brasileiros (especialmente Vivo).
O número é modificado internamente (5551991246202 → 555191246202)
e a entrega falha silenciosamente.
Solução: usar número de produção próprio em vez do sandbox para testes reais.
3. eSIM novo já vem com WhatsApp ativo
Compramos um eSIM para vincular na Cloud API da Meta.
O número veio com WhatsApp Business pré-ativado pelo operador.
A Meta bloqueia o registro na Cloud API se o número já está
em uso no app WhatsApp:
Error: Account not registered
Code: 133010
Fix: instalar o WhatsApp no número, acessar
Configurações → Conta → Deletar minha conta,
aguardar 5 minutos e tentar o registro novamente via API:
curl -X POST \
https://graph.facebook.com/v25.0/{phone-number-id}/register \
-H "Authorization: Bearer {token}" \
-d '{"messaging_product": "whatsapp", "pin": "000000"}'
4. Token temporário da Meta expira em 24h
O token gerado pelo painel do developers.facebook.com expira em 24 horas.
Em produção isso quebra tudo silenciosamente.
Solução: criar um System User no Meta Business Manager,
gerar token sem expiração com as permissões:
whatsapp_business_messagingwhatsapp_business_management
Esse token não expira e é o correto para uso em produção.
O resultado
O RelayOS está no ar em produção:
curl -X POST https://api.relayos.com.br/v1/messages \
-H "Authorization: Bearer rly_live_..." \
-H "Idempotency-Key: consulta-42-lembrete-24h" \
-H "Content-Type: application/json" \
-d '{
"to": "+5511999998888",
"template": "lembrete_consulta",
"language": "pt_BR",
"variables": {"1": "João Silva", "2": "14h"}
}'
{
"id": "uuid-...",
"status": "QUEUED",
"queuedAt": "2026-05-14T..."
}
Em ~2 segundos: QUEUED → SENT → DELIVERED → READ.
Free tier: 100 mensagens/mês, sem cartão de crédito.
Se você está integrando WhatsApp Cloud API e travou em algum
desses pontos — ou em outro que não está aqui — comenta abaixo.
→ relayos.com.br
→ docs.relayos.com.br
→ api.relayos.com.br/docs
Aberto a feedback técnico e perguntas sobre a implementação.