WhatsApp Cloud API¶
Papel e localização¶
A integração recebe mensagens da Cloud API oficial da Meta e envia respostas de texto livre. A entrada adapta o webhook para os contratos internos; a saída implementa WhatsAppClient.send_message(*, to: PhoneNumber, text: str) -> StubResponse.
| Arquivo | Responsabilidade |
|---|---|
src/webhook_handler/app.py |
GET de verificação, HMAC, lote inbound, caminhos síncronos e dispatch SQS |
src/core/integrations/whatsapp/adapter.py |
WhatsAppCloudInboundMessage, extract_inbound_messages, normalização |
src/core/integrations/whatsapp/cloud.py |
WhatsAppCloudConfig, WhatsAppCloudClient, request HTTP de saída |
src/core/integrations/whatsapp/interface.py |
WhatsAppClient |
src/core/integrations/whatsapp/stub.py |
WhatsAppClientStub, ID mock_wa_msg |
src/core/integrations/factory.py |
build_whatsapp_client() |
src/orchestrator/app.py |
Entrega/reentrega da resposta persistida |
Consumidores de saída incluem webhook, orchestrator, atendimento humano e notificação de operadores. O client não altera estado de sessão; quem chama decide o que persistir e como reagir à falha.
Autenticação e verificação do webhook¶
O endpoint SAM é /webhook. Para ativação na Meta:
GET /webhook?hub.mode=subscribe&hub.verify_token=<VERIFY_TOKEN>&hub.challenge=desafio-ficticio
_handle_meta_verification() compara o token com WHATSAPP_VERIFY_TOKEN usando hmac.compare_digest. Sucesso responde 200, Content-Type: text/plain, corpo desafio-ficticio; token ausente/incorreto ou modo diferente responde 403 {"error":"verification_failed"}. Esse GET não precisa carregar todas as settings/OpenAI.
Com Cloud habilitado, POST Meta exige X-Hub-Signature-256: sha256=<HMAC> calculado sobre os bytes originais do corpo, com WHATSAPP_APP_SECRET. O código aceita body base64 de API Gateway, normaliza o nome do header e usa comparação constante. Assinatura inválida produz 401 {"error":"invalid_signature"} antes de persistir o inbound.
O formato interno {phone_number, content} do emulador recebe 403 em Cloud mode, evitando que uma entrada sem assinatura alcance comandos de operador. Com Cloud desabilitado, a validação de assinatura não é exigida; mantenha esse modo restrito ao desenvolvimento controlado.
Request inbound e transformação¶
Exemplo estrutural sanitizado; os identificadores em <...> são placeholders, não valores para enviar ao provider:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "<WABA_ID>",
"changes": [{
"field": "messages",
"value": {
"metadata": {"phone_number_id": "<PHONE_NUMBER_ID>"},
"messages": [{
"from": "5500000000000",
"id": "wamid.EXEMPLO",
"timestamp": "1789383600",
"type": "text",
"text": {"body": "Quero informações sobre um serviço."}
}]
}
}]
}]
}
O número acima é fictício e não deve ser usado como destino de envio. O adapter percorre todas as mensagens de todas as entradas/changes, remove caracteres não numéricos de from, acrescenta +, preserva id como provider_message_id e converte o timestamp Unix para UTC. Timestamp inválido/ausente usa o horário UTC atual. metadata.phone_number_id é transportado; a instância default no adapter é gogenetic, sem roteamento por WABA para goyou.
{
"phone_number": "+5500000000000",
"content": "Quero informações sobre um serviço.",
"message_type": "text",
"instance": "gogenetic",
"provider_message_id": "wamid.EXEMPLO",
"received_at": "2026-09-14T11:00:00+00:00",
"phone_number_id": "<PHONE_NUMBER_ID>"
}
O webhook registra o lote inteiro no DynamoDB antes de despachar qualquer mensagem, permitindo que latest-message-wins observe a mensagem mais recente do mesmo lote. O SQS transporta somente phone_number e message_id; o conteúdo durável está no receipt. Status de entrega/leitura sem mensagem são ignorados com 200 {"status":"ignored"}. Entrada JSON inválida ou campos obrigatórios ausentes retorna 400.
Mídia e respostas síncronas¶
Tipos conhecidos: text, image, audio, video, document, sticker; tipo desconhecido é classificado como document. Texto usa text.body; mídia usa caption, quando existe, ou marcador [audio], [image] etc. Não há download, transcrição nem interpretação multimodal nesta integração. src/core/application/non_text_intercept.py decide a interceptação e resposta fixa; não envie o marcador ao LLM como se o arquivo tivesse sido compreendido.
Comandos de operador, debug, mídia e mensagens em atendimento humano podem ser tratados diretamente no webhook. Veja fluxo da mensagem e operadores.
Request outbound e response¶
POST https://graph.facebook.com/<VERSION>/<PHONE_NUMBER_ID>/messages
Authorization: Bearer <TOKEN>
Content-Type: application/json
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "5500000000000",
"type": "text",
"text": {"preview_url": false, "body": "Como podemos ajudar?"}
}
O número de saída fica só com dígitos. Texto vazio é sucesso local com detail="empty_message_skipped", sem request. Destino sem nenhum dígito lança ValueError antes do loop de transporte. Uma resposta fictícia {"messages":[{"id":"wamid.RESPOSTA_EXEMPLO"}]} vira StubResponse(success=True, mock_id="wamid.RESPOSTA_EXEMPLO"); JSON válido sem ID também é aceito como sucesso, com ID nulo.
Falha HTTP 401, por exemplo, retorna StubResponse(success=False, detail="whatsapp_http_401"). Os adapters não repassam o corpo de erro da Meta ao cliente final. Não há templates, campanhas ou APIs de mídia de saída.
Timeouts, retries e idempotência¶
| Camada | Comportamento efetivo |
|---|---|
| Client HTTP | Timeout default 10 s por chamada; até 2 retries para HTTP 429 ou 5xx, esperas 1 s e 2 s (min(2**attempt, 8)) |
| Rede/JSON | OSError, URLError ou JSON inválido retornam falha imediatamente; não entram no retry HTTP |
| Configuração de retry | WhatsAppCloudConfig.retry_max=2; não existe WHATSAPP_RETRY_MAX em settings/factory |
| Inbound | Receipts e provider_message_id; SQS dedupe por SHA-256 do ID e grupo por telefone |
| Outbound no orchestrator | Reutiliza resposta persistida na falha e solicita retry parcial do item SQS, evitando nova chamada LLM para a mesma resposta |
Isso não garante exactly-once no provider: se a Meta aceitar o POST e a resposta se perder, a aplicação pode reenviar. Não há Idempotency-Key outbound nem confirmação por status callback. WHATSAPP_FREE_ONLY=true é uma restrição de implementação; o client não mede a janela de atendimento para cada envio e não prova gratuidade. A renotificação do cliente tem uma checagem própria de 24 h, descrita em handoff.
Configuração, logs e segurança¶
WHATSAPP_CLOUD_ENABLED=false escolhe stub. Habilitar exige WHATSAPP_ACCESS_TOKEN, WHATSAPP_APP_SECRET, WHATSAPP_VERIFY_TOKEN e WHATSAPP_PHONE_NUMBER_ID. WHATSAPP_BUSINESS_ACCOUNT_ID é carregado, mas não compõe a URL de envio. Versão default WHATSAPP_GRAPH_API_VERSION=v23.0; timeout em WHATSAPP_SEND_TIMEOUT_SECONDS. WHATSAPP_FREE_ONLY=false é rejeitado com Paid WhatsApp modes are not implemented. Veja variáveis.
Eventos úteis: webhook_received, whatsapp_signature_invalid, internal_payload_rejected_in_cloud_mode, whatsapp_cloud_send_success, whatsapp_cloud_send_http_failed, whatsapp_cloud_send_failed, whatsapp_send_failed, sync_whatsapp_reply_failed. Os logs de transporte guardam tamanho do texto e status, não token nem corpo. O logger não é um anonimizado universal de telefones; use request_id, session_id e ID fictício/sanitizado ao compartilhar diagnóstico.
Testes e migração¶
Testes locais: tests/unit/integrations/test_whatsapp_adapter.py, test_whatsapp_cloud_client.py, test_factory.py, tests/unit/lambdas/test_webhook_handler.py e test_orchestrator.py. Cobrem lote, assinatura, bloqueio do formato interno, transporte, retries e reentrega. Execute um arquivo sem rede:
python -m pytest tests/unit/integrations/test_whatsapp_cloud_client.py -q
scripts/smoke.py --target whatsapp usa o runner em src/core/application/smokes/whatsapp_smoke.py; envio exige SMOKE_ALLOW_REAL_SEND e destino configurado. Consulte as restrições do smoke antes de executar.
Para trocar provider, implemente WhatsAppClient e ajuste build_whatsapp_client; preserve StubResponse e os testes de falha. Se mudar a entrada, adicione outro adapter e mantenha verificação de origem, IDs estáveis, normalização de telefone, registro integral do lote e receipts. Trocar somente o client de saída não migra o webhook Meta.