eAgenda Saúde · contrato v1.1 (extensão de áudio) · ambiente de testes
Integração WhatsApp eAgenda
Como o eAgenda Saúde envia mensagens de WhatsApp aos pacientes e recebe as respostas pelo serviço de WhatsApp Web. Aqui está tudo para chamar a API, receber o webhook e rodar os testes de aceite com o operador do serviço.
Substitua https://SEU-SERVIDOR pelo endereço que o operador do serviço informar.
Visão geral
O serviço de WhatsApp Web é um programa conectado a um número de WhatsApp. Ele envia as mensagens que o eAgenda pedir e avisa o eAgenda sobre o que acontece com elas. Também repassa ao eAgenda as mensagens que os pacientes escreverem.
O serviço só transporta. Quem decide o que enviar e como responder ao paciente é sempre o eAgenda.
O serviço faz
Envia o texto que o eAgenda mandar, exatamente como veio: acentos, emojis e quebras de linha.
Se o eAgenda pedir, transforma o texto em mensagem de voz (v1.1).
Avisa quando cada mensagem é enviada, entregue, lida ou falha.
Repassa ao eAgenda cada mensagem que alguém mandar ao número numa conversa individual.
Transcreve os áudios dos pacientes e entrega ao eAgenda o texto, com um índice de confiança (v1.1).
Guarda pedidos e eventos em disco. Nada se perde se ele reiniciar.
O serviço não faz
Não responde sozinho. Nada de resposta automática, saudação, menu ou mensagem de erro ao paciente.
Não interpreta o conteúdo. “SIM”, “não posso” ou um áudio chegam ao eAgenda como vieram. A transcrição só converte fala em texto.
Não marca a conversa como lida (o paciente não vê os tiques azuis nas mensagens dele) e não mostra “digitando…”.
Não repassa arquivos ao eAgenda. De uma mídia recebida chegam o tipo e a legenda; de um áudio, a transcrição. O arquivo de áudio nunca vai para o eAgenda.
Quem é quem
eAgenda
O sistema de vocês. Chama a API do serviço e recebe os eventos no webhook.
Serviço
O serviço de WhatsApp Web descrito aqui.
Operador
Quem mantém o serviço. Liga e reinicia o serviço, lê o QR code para conectar o número e acompanha mensagens e eventos num painel próprio, que só ele acessa.
Número do serviço
O número de WhatsApp de teste conectado ao serviço pelo WhatsApp Web. É ele que aparece para o paciente.
Celular de teste
O aparelho que faz o papel de paciente nos testes: recebe as mensagens e responde.
Webhook
Um endereço do eAgenda que o serviço chama (POST) para avisar que algo aconteceu.
Este é um ambiente de testes, com um número de teste. Use só celulares de teste e dados fictícios.
O fluxo
chamada à API do serviçowebhook para o eAgendaWhatsAppresposta HTTP
O passo 6 é uma decisão do eAgenda. Se o paciente mandou um áudio, por exemplo, o serviço repassa o evento, com a transcrição, e não faz mais nada. Se o eAgenda quiser responder, faz um novo POST /messages.
As três chamadas
Chamada
Quem chama
Para quê
POST /messages
eAgenda → serviço
Enviar uma mensagem.
GET /health
eAgenda → serviço
Saber se a sessão do WhatsApp está conectada.
POST na URL do webhook
serviço → eAgenda
Entregar eventos: status das mensagens enviadas e mensagens recebidas.
Regras que valem para as três: corpo em JSON UTF-8 com Content-Type: application/json; datas em ISO 8601, em UTC, terminando em Z (2026-11-17T12:31:40Z); telefones só com dígitos, com o DDI, sem + e sem espaço (5538999991234). Campos desconhecidos no JSON são ignorados pelos dois lados, para que campos novos possam ser adicionados sem quebrar nada.
contrato v1.1 · compatível com o v1
Extensão de áudio
A versão 1.1 do contrato acrescenta áudio nos dois sentidos. O eAgenda pode pedir que uma mensagem saia como mensagem de voz: o serviço gera a fala a partir do texto. E quando o paciente manda um áudio, o serviço transcreve e entrega ao eAgenda só o texto, com um índice de confiança. O arquivo de áudio nunca vai para o eAgenda.
Tudo é compatível com o v1. Os campos novos são opcionais na ida e adicionais na volta. Um eAgenda que segue o v1 e ignora campos desconhecidos continua funcionando sem mudar nada.
O v1 proíbe enviar dados a terceiros. A extensão de áudio exige isso: o texto das mensagens de voz vai para o Google (Gemini), que gera a fala, e o áudio do paciente vai para a OpenAI (Whisper), que transcreve. Os recursos de áudio só devem ser usados depois que o time do eAgenda aceitar essa mudança. Detalhes em Dados pessoais.
Fornecedores: a voz é gerada pelo Google Gemini (modelo gemini-3.8-flash-tts, voz Kore); o operador pode trocar pela OpenAI (gpt-4o-mini-tts). A transcrição é feita pelo OpenAI Whisper (whisper-1). Cada recurso pode estar desligado neste serviço, e o GET /health informa.
A especificação desta extensão, no formato do contrato, está no arquivo EXTENSAO_v1.1_AUDIO.md, entregue junto com o contrato.
Acesso
Antes do primeiro teste, o operador passa três informações ao eAgenda, e o eAgenda passa uma ao operador.
Informação
Quem informa
Para que serve
Endereço do serviço (URL base) https://SEU-SERVIDOR
Operador
Base de POST /messages e GET /health.
SERVICO_API_TOKEN
Operador
Vai em toda chamada do eAgenda ao serviço, no cabeçalho Authorization: Bearer <SERVICO_API_TOKEN>.
WEBHOOK_SECRET
Operador
Chave para o eAgenda verificar a assinatura dos webhooks. É diferente do token.
URL do webhook
eAgenda
Para onde o serviço manda os eventos. Formato: https://<host-do-eagenda>/api/v1/whatsapp/bot/webhook/, com https e com a barra final.
Segredos
O token e o segredo do webhook não estão nesta página. Combinem os valores por um canal seguro, como um gerenciador de senhas compartilhado. Cada um tem 32 caracteres ou mais. Guardem em variáveis de ambiente e nunca no repositório.
Enquanto a URL do webhook não estiver configurada no serviço, os eventos ficam guardados nele, aguardando. Para testar antes de ter o webhook pronto, veja Testar sem o webhook.
Se o servidor do eAgenda só aceita conexões de endereços conhecidos, peça ao operador o endereço IP de saída do serviço.
O webhook precisa de um certificado HTTPS válido. Com certificado autoassinado, a entrega falha como erro de rede.
A referência interativa (Swagger) permite chamar a API pelo navegador: clique em Authorize e cole o token. Cuidado: o envio é de verdade.
Início rápido
Os exemplos usam curl num terminal bash (Linux, macOS ou WSL). Primeiro, guarde o token numa variável. O comando abaixo espera você colar o token e teclar Enter; ele não aparece na tela nem fica no histórico.
Se session não for connected, avise o operador antes de seguir (veja Estado da sessão). O campo capabilities é da v1.1 e diz se os recursos de áudio estão ligados.
2. Primeira mensagem
Antes de rodar, troque 5538999991234 pelo número do celular de teste e o message_id por um UUID novo. Um message_id já usado neste ambiente recebe 202, mas a mensagem não é enviada de novo. Para gerar um: python3 -c "import uuid; print(uuid.uuid4())".
POST /messages
curl -sS -X POST https://SEU-SERVIDOR/messages \
-H "Authorization: Bearer $SERVICO_API_TOKEN" \
-H "Content-Type: application/json" \
-w '\nHTTP %{http_code}\n' \
-d '{"message_id":"8d3f6c1e-2b7a-4c59-9e41-0f6a2d9b7c13","to":"5538999991234","text":"Olá Julia Andrade, há uma vaga de atendimento em 20/11/2026 às 14:30 na Policlínica Municipal. Responda SIM para confirmar ou NÃO para cancelar."}'
Passar texto acentuado como argumento do curl no Windows (PowerShell, cmd ou Git Bash) pode trocar os bytes dos acentos. Grave o JSON num arquivo em UTF-8 e envie com --data-binary @pedido.json.
3. Os eventos chegam no webhook
Em poucos segundos a mensagem chega no celular de teste. O serviço então avisa o webhook do eAgenda: primeiro sent, depois delivered.
Quando a paciente abre a conversa, chega read. Quando ela responde, chega um message.received:
message.received · 12:31:40Z
{"event":"message.received","event_id":"e7b1d0a4-5c2f-4e8a-b3a9-6f1c2d4e5a70","inbound_id":"3EB0C431D5A2F98E6B11","from":"5538999991234","type":"text","text":"Sério? Justo agora, nossa vai ser difícil ir nesse dia, vou ter outra consulta","reply_to_message_id":null,"timestamp":"2026-11-17T12:31:40Z","transcription":null}
O serviço não responde nada. Para responder à paciente, o eAgenda faz outro POST /messages, com um message_id novo:
corpo do próximo POST /messages
{"message_id":"1c9e4b2a-7d3f-4f1a-8c6e-2b5d9a0e7f44","to":"5538999991234","text":"Entendo, Julia. Essa outra consulta é no mesmo dia e horário, 20/11/2026 às 14:30? Me confirme se você vai conseguir comparecer à Policlínica Municipal."}
Se ela responder citando essa mensagem (tocar e segurar, depois Responder), o evento traz o message_id dela em reply_to_message_id:
message.received · 12:36:12Z
{"event":"message.received","event_id":"9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a","inbound_id":"3EB0F7A19C2D4E5B6A70","from":"5538999991234","type":"text","text":"A outra é de manhã, então consigo ir sim","reply_to_message_id":"1c9e4b2a-7d3f-4f1a-8c6e-2b5d9a0e7f44","timestamp":"2026-11-17T12:36:12Z","transcription":null}
Na v1.1, todo message.received termina com o campo transcription: null para texto, preenchido para áudio.
4. Mensagem de voz (v1.1)
Se o GET /health mostrar "send_audio": true, o mesmo pedido com "type": "audio" faz o texto chegar como mensagem de voz. Use outro message_id novo. Antes, confirme que o eAgenda aceitou a mudança de dados pessoais.
POST /messages · mensagem de voz
curl -sS -X POST https://SEU-SERVIDOR/messages \
-H "Authorization: Bearer $SERVICO_API_TOKEN" \
-H "Content-Type: application/json" \
-w '\nHTTP %{http_code}\n' \
-d '{"message_id":"b4e2a7c9-0f1d-4e3b-9a6c-5d8e7f2a1b30","to":"5538999991234","type":"audio","text":"Olá Julia Andrade, sua vaga foi confirmada para 20 de novembro às 14 horas e 30 minutos."}'
A resposta é o mesmo 202. A voz é gerada na fila, em alguns segundos, e a paciente recebe só o áudio. Os status chegam como antes.
POST /messages
Enviar mensagem
Recebe um pedido de envio, grava numa fila em disco e responde em menos de 2 segundos. O envio pelo WhatsApp acontece depois, em segundo plano, uma mensagem por vez. O resultado chega pelo webhook message.status.
Requisição
requisição
POST https://SEU-SERVIDOR/messages
Authorization: Bearer <SERVICO_API_TOKEN>
Content-Type: application/json
{
"message_id": "8d3f6c1e-2b7a-4c59-9e41-0f6a2d9b7c13",
"to": "5538999991234",
"text": "Olá Julia Andrade, há uma vaga de atendimento em 20/11/2026 às 14:30 na Policlínica Municipal. Responda SIM para confirmar ou NÃO para cancelar."
}
Campo
Tipo
Regra
message_id
string, obrigatório
Um UUID (8-4-4-4-12 em hexadecimal). Gerado pelo eAgenda, um por mensagem; use UUID v4. Volta exatamente como foi enviado.
to
string, obrigatório
Telefone do paciente: só dígitos, começa com 55, 12 ou 13 dígitos. Sem +, espaço ou traço. O serviço resolve sozinho o nono dígito ao enviar.
text
string, obrigatório
De 1 a 4096 caracteres, contados em pontos de código Unicode (como o len() do Python). Não pode ser só espaços e quebras de linha. É enviado sem nenhuma alteração.
type
string, opcional (v1.1)
"text" (padrão) ou "audio". Com "audio", o text é falado: o paciente recebe uma mensagem de voz, sem o texto escrito. Veja Enviar em áudio.
Validação e mensagens de erro
O serviço confere nesta ordem: primeiro o token, depois se o corpo é JSON, depois os campos. Sem token, a resposta é 401 mesmo que o JSON esteja quebrado. Os campos são conferidos na ordem message_id, to, text e type, e a resposta aponta o primeiro problema. As mensagens seguem o formato "<campo>: <motivo>".
Problema
Status
error.code
error.message
Sem Authorization, ou token errado
401
unauthorized
token inválido
Corpo não é JSON válido
400
invalid_payload
corpo: JSON inválido
Corpo é JSON, mas não é um objeto (uma lista, por exemplo)
400
invalid_payload
corpo: deve ser um objeto JSON
Corpo maior que 64 KB
400
invalid_payload
corpo: maior que o permitido (64 KB)
Campo ausente
400
invalid_payload
<campo>: obrigatório, por exemplo to: obrigatório
message_id não é UUID
400
invalid_payload
message_id: deve ser um UUID
to ou text como número, sem aspas
400
invalid_payload
to: deve ser texto (string) ou text: deve ser texto (string)
to com +, espaço, traço ou parênteses
400
invalid_payload
to: deve conter só dígitos
to com menos de 12 ou mais de 13 dígitos
400
invalid_payload
to: deve ter 12 ou 13 dígitos
to sem o 55 na frente
400
invalid_payload
to: deve começar com 55
text vazio ou com mais de 4096 caracteres
400
invalid_payload
text: deve ter de 1 a 4096 caracteres
text só com espaços ou quebras de linha
400
invalid_payload
text: não pode ser só espaços
type diferente de "text" e "audio" (v1.1)
400
invalid_payload
type: use text ou audio
"type": "audio" com o envio em áudio desligado no serviço (v1.1)
400
invalid_payload
type: envio de áudio não está habilitado neste serviço
Decida pelo error.code. O error.message é para pessoas lerem nos logs. O aviso de áudio desligado só vem para um message_id novo: um message_id já aceito recebe 202, como no v1.
Respostas
Status
Corpo
Quando
O que o eAgenda faz
202
{"message_id": "…", "status": "queued"}
Pedido aceito e na fila.
Aguarda o webhook message.status.
202
O mesmo corpo da primeira vez
message_id já recebido antes. Nada entra na fila de novo.
Algo no caminho falhou (proxy, rede). Também vale para timeout de 10 s e erro de rede.
Tenta de novo com o mesmo message_id.
Quando tentar de novo
Repita o pedido só em 429, 500, 502, 503, 504, timeout ou erro de rede. No máximo 5 tentativas no total, sempre com o mesmomessage_id. O intervalo entre elas fica a critério do eAgenda; esperas crescentes (2, 4, 8 e 16 s) funcionam bem. Corte cada chamada em 10 segundos.
Por que repetir o mesmo message_id é seguro
O serviço guarda cada message_id recebido por 7 dias. Se o mesmo message_id chegar de novo, ele responde 202 com o mesmo corpo e não enfileira outra vez. O paciente recebe a mensagem no máximo uma vez.
Isso resolve o caso mais comum: o serviço aceitou o pedido, mas a resposta se perdeu no caminho. O eAgenda não sabe se deu certo e repete. Com o mesmo message_id, não sai mensagem dobrada.
O contrário também vale
Nunca reaproveite um message_id para outra mensagem: o serviço responde 202 e não envia o texto novo. Gere um UUID v4 para cada mensagem e grave-o junto do agendamento antes de chamar o serviço. É por ele que os webhooks de status e as respostas citadas se ligam ao registro certo.
O 503, explicado
O 503 session_unavailable quer dizer que nenhuma mensagem consegue sair agora e que o pedido não foi aceito. Acontece quando a sessão do WhatsApp está em qr_required ou disconnected. Só o operador resolve, lendo o QR code no painel dele.
Sessão (GET /health)
Resposta do POST /messages
connected
202 A mensagem sai na vez dela.
connecting
202 A mensagem espera na fila até a sessão voltar.
qr_required
503 Nada é enfileirado.
disconnected
503 Nada é enfileirado.
Em connecting (serviço iniciando ou reconectando), o pedido é aceito. Se a mensagem ficar mais de 30 minutos na fila sem conseguir sair, o serviço desiste e manda message.statusfailed com error.codesession_unavailable.
Um message_id que já foi aceito recebe 202 com o mesmo corpo, mesmo com a sessão fora do ar.
Enviar em áudio (v1.1)
Com "type": "audio", o text é o que será falado. O serviço gera a voz, converte para o formato de mensagem de voz do WhatsApp e envia. O paciente recebe só o áudio, sem o texto escrito.
corpo · mensagem de voz
{
"message_id": "b4e2a7c9-0f1d-4e3b-9a6c-5d8e7f2a1b30",
"to": "5538999991234",
"type": "audio",
"text": "Olá Julia Andrade, sua vaga foi confirmada para 20 de novembro às 14 horas e 30 minutos."
}
Confira antes capabilities.send_audio no GET /health. Com o envio em áudio desligado, a resposta é 400 com type: envio de áudio não está habilitado neste serviço. Nesse caso, envie como texto, com um message_id novo.
O text segue as mesmas regras: de 1 a 4096 caracteres. Texto longo vira áudio longo; prefira frases curtas.
A resposta 202 continua imediata. A voz é gerada na fila e leva alguns segundos (em teste, cerca de 5 s para uma frase), além do intervalo de 3 s entre envios.
Idempotência igual à do v1: o mesmo message_id gera no máximo um envio.
Status iguais: sent, delivered, read e failed. Aqui, read sai quando o paciente abre a conversa ou ouve o áudio, o que vier primeiro.
Se a voz não puder ser gerada depois de 3 tentativas, chega failed com error.codetts_failed, e nada é enviado. Um número sem WhatsApp é detectado antes de gerar a voz e recebe not_on_whatsapp, como no v1.
Se o paciente responder citando a mensagem de voz, reply_to_message_id traz o message_id dela, como no v1.
Escreva como se fala
Em vez de
Escreva
20/11/2026 às 14:30
20 de novembro às 14 horas e 30 minutos
*Policlínica Municipal*
Policlínica Municipal, sem asteriscos ou outra marcação
Um link
Mande o link numa mensagem de texto separada.
Emojis
Deixe de fora.
Exemplo em Python
Um cliente com as regras acima, usando requests. O parâmetro tipo é da v1.1; um serviço v1 ignora o campo type. Rode numa tarefa em segundo plano (Celery, RQ ou similar), e não dentro da requisição de um usuário: com as esperas, pode levar cerca de 30 segundos para desistir.
whatsapp/cliente.py
import os
import time
import requests
SERVICO_BASE_URL = os.environ["SERVICO_BASE_URL"] # ex.: https://SEU-SERVIDOR
SERVICO_API_TOKEN = os.environ["SERVICO_API_TOKEN"]
REPETIR_EM = {429, 500, 502, 503, 504} # 400 e 401 são definitivos
class EnvioRecusado(Exception):
"""400 ou 401: repetir o mesmo pedido não adianta."""
class ServicoIndisponivel(Exception):
"""5 tentativas sem 202. Tentar mais tarde com o MESMO message_id é seguro."""
def enviar_mensagem(message_id, to, text, tipo="text"):
"""message_id: UUID v4 novo por mensagem, já gravado junto do agendamento.
tipo: "text" ou "audio" (v1.1; confira capabilities.send_audio no /health)."""
corpo = {"message_id": message_id, "to": to, "text": text, "type": tipo}
cabecalhos = {"Authorization": f"Bearer {SERVICO_API_TOKEN}"}
for tentativa in range(1, 6): # até 5 tentativas no total
try:
r = requests.post(f"{SERVICO_BASE_URL}/messages", json=corpo,
headers=cabecalhos, timeout=10)
except requests.RequestException: # timeout ou erro de rede
r = None
if r is not None and r.status_code == 202:
return r.json() # {"message_id": "...", "status": "queued"}
if r is not None and r.status_code not in REPETIR_EM:
raise EnvioRecusado(f"HTTP {r.status_code}: {r.text}")
if tentativa < 5:
time.sleep(2 ** tentativa) # 2, 4, 8 e 16 s
raise ServicoIndisponivel(message_id)
GET /health
Estado da sessão
Informa se o serviço consegue enviar mensagens agora. Exige o mesmo token (sem ele, 401). Responde 200 sempre que o serviço estiver no ar, mesmo com o WhatsApp desconectado.
connected, connecting, qr_required ou disconnected.
phone
O número do serviço, só dígitos, ou null.
queue_size
Quantas mensagens foram aceitas e ainda não saíram.
capabilities
v1.1. send_audio: true se o envio em áudio está ligado. transcription: true se os áudios dos pacientes são transcritos. Um serviço v1 não manda este campo; trate a ausência como os dois false.
O que cada session significa na prática
session
O que está acontecendo
POST /messages
Quem age
connected
Tudo certo. As mensagens saem da fila uma por vez, com pelo menos 3 segundos entre elas.
202
Ninguém.
connecting
O serviço está iniciando ou reconectando ao WhatsApp. Costuma levar segundos.
202 espera na fila
Ninguém. Se passar de alguns minutos, avise o operador.
qr_required
O número precisa ser vinculado de novo. O painel do operador mostra um QR code.
503
O operador lê o QR code com o celular do número do serviço.
disconnected
A sessão está parada: o aparelho foi desvinculado, ou o QR code expirou sem ser lido.
503
O operador reconecta pelo painel.
Não é preciso chamar /health antes de cada envio: o POST /messages já responde 503 quando não dá para aceitar. Use /health para mostrar o estado numa tela de administração do eAgenda ou antes de um lote de envios.
Antes de usar áudio, confira capabilities. Com send_audio: false, um POST /messages com "type": "audio" recebe 400. Com transcription: false, os áudios dos pacientes chegam com transcription.status: "disabled".
queue_size alto quer dizer espera. Com 3 segundos entre envios, 20 mensagens na fila levam cerca de 1 minuto para sair.
Fora do contrato, o serviço também tem GET /livez, sem token, que responde {"status":"ok"} enquanto o processo está no ar. Serve para monitoramento.
POST https://<host-do-eagenda>/api/v1/whatsapp/bot/webhook/
Receber eventos: o webhook
O serviço avisa o eAgenda fazendo POST na URL do webhook. Cada requisição leva um único evento. O campo event diz o tipo: message.status ou message.received. Quem implementa este endereço é o eAgenda.
como o serviço chama o eAgenda
POST https://<host-do-eagenda>/api/v1/whatsapp/bot/webhook/
Content-Type: application/json
X-Bot-Timestamp: 1794918701
X-Bot-Signature: sha256=d59dad4a2d1ae928440d0131e40041488424773bf3775df19222b5540389f5d3
{"event":"message.received","event_id":"e7b1d0a4-5c2f-4e8a-b3a9-6f1c2d4e5a70","inbound_id":"3EB0C431D5A2F98E6B11","from":"5538999991234","type":"text","text":"Sério? Justo agora, nossa vai ser difícil ir nesse dia, vou ter outra consulta","reply_to_message_id":null,"timestamp":"2026-11-17T12:31:40Z"}
Cabeçalho
Conteúdo
X-Bot-Timestamp
Segundos desde 1970-01-01 UTC, no momento do envio. Novo a cada tentativa.
X-Bot-Signature
sha256= seguido da assinatura HMAC-SHA256 em hexadecimal (64 caracteres). Nova a cada tentativa.
Na v1.1, o corpo de message.received traz também o campo transcription, no fim. O exemplo acima é o corpo do vetor de teste do v1, que continua valendo: a assinatura funciona do mesmo jeito para qualquer corpo.
Como verificar a assinatura
A assinatura prova que o evento veio do serviço e não foi alterado no caminho. É um HMAC-SHA256: um código calculado com o WEBHOOK_SECRET sobre o timestamp e o corpo. Só quem tem o segredo consegue produzir a assinatura certa.
Leia o corpo bruto, em bytes, antes de qualquer conversão. No Django é request.body.
Confira o X-Bot-Timestamp: só dígitos e no máximo 300 segundos de diferença do seu relógio, para mais ou para menos. Fora disso, responda 401. Isso impede que alguém reaproveite um evento antigo capturado.
Calcule a assinatura esperada e compare com o X-Bot-Signature usando comparação de tempo constante (hmac.compare_digest no Python). Diferente, 401.
Só então leia o JSON. JSON inválido ou campo obrigatório faltando, 400.
Se o event_id (ou, em message.received, o inbound_id) já foi recebido, responda 200 e não processe de novo.
Guarde o evento e responda 200 {"status": "ok"} em menos de 10 segundos. Processe depois, fora da requisição.
Use os bytes exatos
Não converta o JSON e serialize de novo para verificar. O json.dumps do Python escreve Sério no lugar de Sério e põe espaços depois das vírgulas. Os bytes mudam e a assinatura não bate.
Responda 400 só quando o corpo estiver de fato malformado. Para qualquer problema passageiro do lado do eAgenda (banco fora do ar, deploy em andamento), responda 5xx: o serviço tenta de novo. Um 400 encerra a entrega daquele evento.
Vetor de teste
Use estes valores fixos num teste automatizado. Se a sua verificação aceita este corpo com esta assinatura, e recusa o mesmo corpo com um único byte trocado, ela está certa.
vetor de teste (contrato §5.2)
WEBHOOK_SECRET = segredo-de-exemplo
X-Bot-Timestamp = 1794918701
corpo = {"event":"message.received","event_id":"e7b1d0a4-5c2f-4e8a-b3a9-6f1c2d4e5a70","inbound_id":"3EB0C431D5A2F98E6B11","from":"5538999991234","type":"text","text":"Sério? Justo agora, nossa vai ser difícil ir nesse dia, vou ter outra consulta","reply_to_message_id":null,"timestamp":"2026-11-17T12:31:40Z"}
X-Bot-Signature = sha256=d59dad4a2d1ae928440d0131e40041488424773bf3775df19222b5540389f5d3
Para conferir o cálculo, rode este trecho. Ele deve imprimir exatamente o X-Bot-Signature acima. O corpo é uma linha só, sem espaços entre os campos, com os acentos em UTF-8.
vetor.py
import hashlib, hmac
segredo = b"segredo-de-exemplo"
ts = b"1794918701"
corpo = '{"event":"message.received","event_id":"e7b1d0a4-5c2f-4e8a-b3a9-6f1c2d4e5a70","inbound_id":"3EB0C431D5A2F98E6B11","from":"5538999991234","type":"text","text":"Sério? Justo agora, nossa vai ser difícil ir nesse dia, vou ter outra consulta","reply_to_message_id":null,"timestamp":"2026-11-17T12:31:40Z"}'.encode("utf-8")
print("sha256=" + hmac.new(segredo, ts + b"." + corpo, hashlib.sha256).hexdigest())
Exemplo completo em Django
Um app whatsapp com quatro partes: o modelo que guarda cada evento uma vez, a view que verifica e responde, a rota e o teste com o vetor. A view só guarda o evento; o processamento (atualizar o agendamento, decidir a resposta ao paciente) roda depois, fora da requisição.
settings.py
import os
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"] # combinado com o operador; não é o SERVICO_API_TOKEN
whatsapp/models.py
from django.db import models
class EventoWhatsApp(models.Model):
"""Caixa de entrada do webhook. Cada evento é guardado uma única vez."""
event_id = models.CharField(max_length=64, unique=True)
inbound_id = models.CharField(max_length=128, unique=True, null=True, blank=True)
tipo = models.CharField(max_length=32) # message.status | message.received
payload = models.JSONField()
recebido_em = models.DateTimeField(auto_now_add=True)
processado_em = models.DateTimeField(null=True, blank=True)
whatsapp/views.py
import hashlib
import hmac
import json
import logging
from time import time as agora
from django.conf import settings
from django.db import IntegrityError, transaction
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .models import EventoWhatsApp
log = logging.getLogger(__name__)
JANELA_SEGUNDOS = 300 # timestamps com mais de 300 s de diferença são recusados
CAMPOS_OBRIGATORIOS = {
"message.status": ("event_id", "message_id", "status", "timestamp"),
"message.received": ("event_id", "inbound_id", "from", "type", "timestamp"),
}
def assinatura_valida(segredo, timestamp, corpo, recebida):
mensagem = timestamp.encode("ascii") + b"." + corpo
esperada = "sha256=" + hmac.new(segredo.encode("utf-8"), mensagem, hashlib.sha256).hexdigest()
# Compara em tempo constante. Em bytes, para não quebrar com cabeçalho não ASCII.
return hmac.compare_digest(esperada.encode("ascii"), recebida.encode("utf-8"))
def erro(status, code, message=None):
corpo = {"code": code}
if message:
corpo["message"] = message
return JsonResponse({"error": corpo}, status=status)
@csrf_exempt # o serviço não tem sessão nem token CSRF; a assinatura faz esse papel
@require_POST
def webhook_whatsapp(request):
timestamp = request.headers.get("X-Bot-Timestamp", "")
assinatura = request.headers.get("X-Bot-Signature", "")
corpo = request.body # bytes exatos. Não use json.dumps(...) para verificar.# 1. Timestamp: só dígitos e no máximo 300 s de diferença do relógio local
if not (timestamp.isascii() and timestamp.isdigit()):
return erro(401, "invalid_signature")
if abs(agora() - int(timestamp)) > JANELA_SEGUNDOS:
return erro(401, "invalid_signature")
# 2. Assinatura
if not assinatura_valida(settings.WEBHOOK_SECRET, timestamp, corpo, assinatura):
return erro(401, "invalid_signature")
# 3. JSON e campos obrigatórios. Atenção: com 400 o serviço NÃO reenvia.
try:
evento = json.loads(corpo)
except ValueError:
return erro(400, "invalid_payload", "corpo: JSON inválido")
if not isinstance(evento, dict) or not evento.get("event"):
return erro(400, "invalid_payload", "event: obrigatório")
tipo = evento["event"]
if tipo not in CAMPOS_OBRIGATORIOS:
log.warning("webhook: tipo de evento desconhecido %r, ignorado", tipo)
return JsonResponse({"status": "ok"}) # tipos novos podem surgir em versões futuras
faltando = [campo for campo in CAMPOS_OBRIGATORIOS[tipo] if not evento.get(campo)]
if faltando:
return erro(400, "invalid_payload", f"{faltando[0]}: obrigatório")
# 4. Guarda uma vez só: event_id e inbound_id são UNIQUE no banco
try:
with transaction.atomic():
EventoWhatsApp.objects.create(
event_id=evento["event_id"],
inbound_id=evento["inbound_id"] if tipo == "message.received" else None,
tipo=tipo,
payload=evento,
)
except IntegrityError:
pass # repetido: já estava guardado; responda 200 de novo
except Exception:
log.exception("webhook: falha ao gravar event_id=%s", evento["event_id"])
return erro(500, "internal_error") # o serviço vai reenviar# 5. Responda rápido (limite de 10 s). Processe os eventos fora da requisição.
return JsonResponse({"status": "ok"})
urls.py (do projeto)
from django.urls import path
from whatsapp.views import webhook_whatsapp
urlpatterns = [
# Com a barra final. Sem ela, o Django redireciona (301) e o serviço não segue.
path("api/v1/whatsapp/bot/webhook/", webhook_whatsapp, name="whatsapp-webhook"),
]
whatsapp/tests.py
from unittest import mock
from django.test import TestCase, override_settings
from .models import EventoWhatsApp
URL = "/api/v1/whatsapp/bot/webhook/"
TS = "1794918701"
ASSINATURA = "sha256=d59dad4a2d1ae928440d0131e40041488424773bf3775df19222b5540389f5d3"
CORPO = '{"event":"message.received","event_id":"e7b1d0a4-5c2f-4e8a-b3a9-6f1c2d4e5a70","inbound_id":"3EB0C431D5A2F98E6B11","from":"5538999991234","type":"text","text":"Sério? Justo agora, nossa vai ser difícil ir nesse dia, vou ter outra consulta","reply_to_message_id":null,"timestamp":"2026-11-17T12:31:40Z"}'.encode("utf-8")
@override_settings(WEBHOOK_SECRET="segredo-de-exemplo")
@mock.patch("whatsapp.views.agora", return_value=1794918701)
class VetorDeTesteDoContrato(TestCase):
def enviar(self, corpo=CORPO, ts=TS, assinatura=ASSINATURA):
return self.client.post(
URL, data=corpo, content_type="application/json",
HTTP_X_BOT_TIMESTAMP=ts, HTTP_X_BOT_SIGNATURE=assinatura,
)
def test_vetor_e_aceito(self, relogio):
resposta = self.enviar()
self.assertEqual(resposta.status_code, 200)
self.assertEqual(resposta.json(), {"status": "ok"})
def test_repetido_responde_200_e_guarda_uma_vez(self, relogio):
self.enviar()
self.assertEqual(self.enviar().status_code, 200)
self.assertEqual(EventoWhatsApp.objects.count(), 1)
def test_um_byte_diferente_da_401(self, relogio):
resposta = self.enviar(corpo=CORPO.replace(b"Justo", b"justo"))
self.assertEqual(resposta.status_code, 401)
def test_timestamp_fora_da_janela_da_401(self, relogio):
relogio.return_value = 1794918701 + 301
self.assertEqual(self.enviar().status_code, 401)
csrf_exempt: o serviço não tem sessão nem token CSRF do Django. A assinatura HMAC cumpre esse papel.
Deduplicação: as restrições UNIQUE no banco garantem que duas entregas simultâneas do mesmo evento não gravem duas linhas. Checar antes e inserir depois não garante isso.
Processamento: leia as linhas com processado_em vazio numa tarefa (Celery, RQ ou um comando agendado), em ordem de id. Assim a resposta sai rápido e um erro na regra de negócio não vira reentrega.
Django REST Framework: se usar, leia request.body antes de tocar em request.data (depois dele, request.body dá erro) e não use SessionAuthentication nesta rota. A view simples acima evita os dois problemas.
Tipo de evento desconhecido: o exemplo responde 200 e ignora, para não travar a entrega se uma versão futura trouxer um tipo novo. Esse comportamento é uma sugestão deste guia; o contrato não define o caso.
Extensão de áudio (v1.1): a view não muda. O campo transcription fica guardado no payload e é lido no processamento; veja ler_resposta.
Exemplo em Node.js (Express)
Se o receptor for em Node.js, use express.raw nesta rota: ele entrega o corpo como Buffer, com os bytes exatos. O express.json() descarta os bytes originais e a assinatura deixa de bater. O exemplo só lê os ids do evento, então funciona igual na v1.1.
webhook.mjs
import crypto from "node:crypto";
import express from "express";
const app = express();
const JANELA_SEGUNDOS = 300;
const vistos = new Set(); // só para o exemplo; em produção, UNIQUE no banco
function assinaturaValida(segredo, timestamp, corpo, recebida) {
const esperada = "sha256=" + crypto
.createHmac("sha256", segredo)
.update(`${timestamp}.`)
.update(corpo) // Buffer: os bytes exatos recebidos
.digest("hex");
const a = Buffer.from(esperada);
const b = Buffer.from(String(recebida));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw, não express.json: a assinatura é sobre os bytes originais
app.post("/api/v1/whatsapp/bot/webhook/", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Bot-Timestamp") ?? "";
const assinatura = req.get("X-Bot-Signature") ?? "";
const corpo = req.body;
const relogioOk = /^\d+$/.test(timestamp)
&& Math.abs(Date.now() / 1000 - Number(timestamp)) <= JANELA_SEGUNDOS;
if (!relogioOk || !Buffer.isBuffer(corpo)
|| !assinaturaValida(process.env.WEBHOOK_SECRET, timestamp, corpo, assinatura)) {
return res.status(401).json({ error: { code: "invalid_signature" } });
}
let evento;
try {
evento = JSON.parse(corpo.toString("utf8"));
} catch {
return res.status(400).json({ error: { code: "invalid_payload", message: "corpo: JSON inválido" } });
}
if (!evento?.event || !evento.event_id) {
return res.status(400).json({ error: { code: "invalid_payload", message: "event_id: obrigatório" } });
}
const chaves = [evento.event_id, evento.inbound_id].filter(Boolean);
if (!chaves.some((k) => vistos.has(k))) {
chaves.forEach((k) => vistos.add(k));
// guarde o evento para processar fora da requisição
}
res.json({ status: "ok" }); // repetido também recebe 200
});
app.listen(process.env.PORT ?? 8000);
Evento message.status
Chega quando o status de uma mensagem enviada a pedido do eAgenda muda.
UUID novo por evento. Igual nos reenvios do mesmo evento.
message_id
string
O message_id enviado no POST /messages.
status
string
sent, delivered, read ou failed.
error
objeto ou null
Presente quando status é failed, com code e message. null nos outros casos.
timestamp
string
Quando o status aconteceu, em UTC.
Os tiques do WhatsApp, em status
Cada status corresponde ao que se vê na bolha da mensagem, do lado de quem envia.
12:20
queued
Relógio. Na fila do serviço. É a resposta do POST /messages, não um evento.
12:20
sent
Um tique cinza. Saiu para o servidor do WhatsApp (ACK_SERVER, 1).
12:20
delivered
Dois tiques cinza. Chegou no aparelho do paciente (ACK_DEVICE, 2).
12:20
read
Dois tiques azuis. O paciente abriu a conversa (ACK_READ, 3) ou ouviu a mensagem de voz (ACK_PLAYED, 4).
12:20
failed
Erro (ACK_ERROR, -1), ou o serviço desistiu. Vem com error.code.
Regras
Cada status chega no máximo uma vez por mensagem. Os status de uma mesma mensagem chegam em ordem.
Se o WhatsApp pular etapas (por exemplo, a confirmação de leitura chega antes da de entrega), o serviço emite as que faltaram, em ordem (sent → delivered → read), todas com o mesmo timestamp.
Depois de failed, nada mais chega para aquele message_id.
Mensagem de voz (v1.1): read sai quando o paciente abre a conversa ou ouve o áudio, o que vier primeiro.
Falha passageira (sessão reconectando, timeout) não vira failed. O serviço mantém a mensagem na fila e tenta de novo. Só manda failed quando desiste.
read pode nunca chegar: se o paciente desligou as confirmações de leitura no WhatsApp, a leitura não é informada. delivered demora se o celular do paciente estiver desligado ou sem internet. Não trate a falta deles como erro.
O eAgenda pode receber status de um message_id que não conhece, como o de uma mensagem de teste que o operador mandou pelo painel. Responda 200 e ignore.
Códigos de erro
error.code
Quando
not_on_whatsapp
O número não tem WhatsApp. Nada foi enviado.
send_failed
O WhatsApp recusou a mensagem ou deu erro, e o serviço desistiu.
session_unavailable
A mensagem ficou mais de 30 minutos na fila com a sessão fora do ar.
tts_failed
Mensagem de voz (v1.1): não foi possível gerar o áudio depois de 3 tentativas, ou o envio em áudio foi desligado depois que a mensagem entrou na fila. Nada foi enviado.
Chega para cada mensagem que alguém mandar ao número do serviço numa conversa individual. O conteúdo vem como chegou, sem interpretação.
message.received
{
"event": "message.received",
"event_id": "e7b1d0a4-5c2f-4e8a-b3a9-6f1c2d4e5a70",
"inbound_id": "3EB0C431D5A2F98E6B11",
"from": "5538999991234",
"type": "text",
"text": "Sério? Justo agora, nossa vai ser difícil ir nesse dia, vou ter outra consulta",
"reply_to_message_id": null,
"timestamp": "2026-11-17T12:31:40Z",
"transcription": null
}
Campo
Tipo
Valor
event
string
"message.received"
event_id
string
UUID novo por evento. Igual nos reenvios do mesmo evento.
inbound_id
string
O id que o WhatsApp deu à mensagem.
from
string
Telefone de quem escreveu, só dígitos, com DDI, como o WhatsApp informa. Pode vir sem o nono dígito.
type
string
text, audio, image, video, sticker, document, location, contact ou other.
text
string ou null
O texto, se type for text. A legenda, se for uma mídia com legenda. Senão, null.
reply_to_message_id
string ou null
Se o paciente respondeu citando uma mensagem enviada a pedido do eAgenda, o message_id dela. Senão, null.
timestamp
string
Quando o paciente enviou, em UTC.
transcription
objeto ou null
v1.1. Sempre presente, no fim do corpo. A transcrição, se type for audio; senão null. Veja Transcrição de áudio.
Tipos
type
O que o paciente mandou
text
Mensagem de texto.
audio
Mensagem de voz ou arquivo de áudio. Na v1.1, vem com a transcrição.
image
Foto ou imagem.
video
Vídeo.
sticker
Figurinha.
document
Arquivo (PDF, por exemplo).
location
Localização.
contact
Um ou mais contatos compartilhados.
other
Qualquer outro tipo.
O serviço não repassa arquivos. De uma mídia, o eAgenda recebe só o tipo e a legenda, se houver. De um áudio, recebe a transcrição: veja Transcrição de áudio.
Respostas citadas
Quando o paciente toca e segura uma mensagem e escolhe Responder, a mensagem dele “cita” a original. Se a citada foi enviada a pedido do eAgenda, reply_to_message_id traz o message_id dela. É a ligação mais segura entre a resposta e o agendamento. Sem citação, ou citando outra mensagem, vem null.
O telefone em from e o nono dígito
O WhatsApp às vezes registra celulares brasileiros sem o 9 depois do DDD. Por isso from pode vir como 553899991234, mesmo que o eAgenda tenha enviado para 5538999991234. Compare telefones depois de normalizar os dois lados:
normalizar.py
def normalizar_celular(numero):
"""Põe um celular brasileiro na forma com 13 dígitos (com o nono dígito)."""
d = "".join(c for c in numero if c in "0123456789")
if len(d) == 12 and d.startswith("55") and d[4] in "6789":
return d[:4] + "9" + d[4:] # 553899991234 -> 5538999991234
return d
assert normalizar_celular("553899991234") == "5538999991234"
assert normalizar_celular("5538999991234") == "5538999991234"
Quando o WhatsApp entrega só um identificador interno no lugar do telefone, o serviço converte para o telefone. Se não conseguir, o evento não é enviado e o caso fica registrado no log do serviço.
Qualquer pessoa pode escrever para o número do serviço. Um from que não corresponde a nenhum paciente deve ser tratado (por exemplo, ignorado e registrado), nunca respondido com erro.
O que não gera evento
Mensagens enviadas pelo próprio serviço.
Grupos, status, canais e listas de transmissão.
Mensagens de sistema, chamadas de voz ou vídeo, reações, edições e mensagens apagadas.
Transcrição de áudio (v1.1)
Quando o paciente manda um áudio, o serviço baixa o arquivo para a memória, envia ao OpenAI Whisper e põe o resultado no campo transcription. O arquivo de áudio nunca vai para o eAgenda. Como no v1, type continua "audio" e text continua null; o que foi falado vem em transcription.text.
message.received · áudio transcrito
{
"event": "message.received",
"event_id": "4c3b2a19-8f7e-4d6c-9b5a-0e1f2d3c4b5a",
"inbound_id": "3EB01D2E3F4A5B6C7D80",
"from": "5538999991234",
"type": "audio",
"text": null,
"reply_to_message_id": null,
"timestamp": "2026-11-17T12:33:05Z",
"transcription": {
"status": "ok",
"text": "A outra consulta é de manhã, então consigo ir sim.",
"confidence": 0.91,
"language": "pt",
"duration_seconds": 6.4,
"provider": "openai/whisper-1"
}
}
Campo
Tipo
Valor
status
string
ok, failed, too_long ou disabled (tabela abaixo).
text
string ou null
O que foi falado. Em ok, vem "" se não houver fala. Nos outros status, null.
confidence
número ou null
De 0 a 1, com duas casas. null fora de ok.
language
string ou null
Idioma detectado, como "pt". Pode ser null fora de ok.
duration_seconds
número ou null
Duração do áudio, em segundos. Pode ser null fora de ok.
provider
string ou null
Quem transcreveu, como "openai/whisper-1". null em disabled.
transcription.status
Quando
text e confidence
ok
Transcreveu. Sem fala no áudio, vem text: "" com confidence: 0.
Preenchidos
failed
Erro ao baixar o áudio ou na API de transcrição.
null
too_long
Áudio maior que o limite do serviço (padrão: 300 s).
null
disabled
Transcrição desligada no serviço (sem chave da OpenAI).
null
Uma falha na transcrição não segura nada: o evento sai assim mesmo, com status: "failed". O evento de um áudio espera a transcrição antes de sair, o que leva alguns segundos. As mensagens seguintes do mesmo paciente esperam junto, para manter a ordem; as de outros pacientes, não.
Como a confiança é calculada
O Whisper divide o áudio em trechos e informa, para cada um, quão seguro estava. A confiança junta esses números num só, de 0 a 1:
confiança
para cada trecho:
p = (avg_logprob + 1,0) / 0,85, limitado entre 0 e 1
(−0,15 ou mais → 1; −1,0 ou menos → 0; linear entre os dois)
se no_speech_prob > 0,5: p = p × (1 − no_speech_prob)
se compression_ratio > 2,4: p = p × 0,5
confiança = média de p, ponderada pela duração de cada trecho
(duas casas decimais; sem nenhum trecho, 0)
avg_logprob: a média do logaritmo da probabilidade que o modelo deu às palavras do trecho. Quanto mais perto de 0, mais seguro. A escala leva −0,15 ou mais a 1 e −1,0 ou menos a 0; abaixo de −1,0, o próprio Whisper considera a transcrição ruim.
no_speech_prob: a chance de o trecho ser só silêncio ou ruído. Só desconta acima de 0,5, o mesmo critério que o Whisper usa para silêncio; abaixo disso, não pesa.
compression_ratio > 2,4: o texto do trecho se repete de forma anormal, sinal de que o modelo “alucinou”. A probabilidade cai pela metade.
Trechos mais longos pesam mais na média.
A escala existe porque o Whisper raramente passa de 0,8 de probabilidade média, mesmo acertando tudo, e porque a chance de “não ter fala” sai alta em áudios curtos de celular, mesmo com fala clara. Medido com voz real: fala limpa, transcrita sem erro (avg_logprob −0,26), deu 0,87, margem alta; a mesma voz sob ruído forte, transcrita errado (avg_logprob −0,66, no_speech_prob 0,28), deu 0,40, margem baixa.
A confiança mede a segurança do modelo e não garante o acerto. Um áudio claro pode ter confiança alta e ainda trocar um nome próprio.
Margem de confiança para o atendimento
Faixas sugeridas para decidir o que fazer com uma resposta em áudio. A decisão final é do eAgenda.
baixamédiaalta
exemplo: 0,91
00,600,801
Confiança de 0 a 1, em escala, com as três faixas. O áudio do exemplo acima (0,91) cai na faixa alta.
Confiança
Margem
Sugestão ao eAgenda
≥ 0,80
alta
Pode tratar a resposta normalmente.
0,60 a 0,79
média
Confirmar com o paciente o que foi entendido.
< 0,60
baixa
Pedir para repetir por escrito ou encaminhar para atendimento humano.
Sem transcrição útil (failed, too_long, disabled ou áudio sem fala), faça o mesmo que na margem baixa: peça a resposta por escrito ou encaminhe para atendimento humano.
No código do eAgenda
A view do webhook não muda: transcription fica guardado no payload. No processamento, esta função lê a resposta do paciente nos dois formatos, v1 e v1.1, e devolve a margem. Ela tem testes para as três faixas e para os casos sem transcrição.
whatsapp/respostas.py
"""Lê o que o paciente respondeu, com ou sem a extensão de áudio (v1.1)."""
ALTA = 0.80 # faixas da margem de confiança para o atendimento
MEDIA = 0.60
def ler_resposta(evento):
"""Devolve (texto, margem) de um message.received.
margem:
"escrito" o paciente digitou (texto, ou legenda de uma mídia)
"alta" / "media" / "baixa" áudio transcrito, conforme a confiança
None nada para ler (mídia sem legenda, áudio sem transcrição útil)
Funciona com eventos do v1 (sem o campo transcription) e do v1.1.
"""
if evento["type"] != "audio":
texto = evento.get("text")
return (texto, "escrito") if texto else (None, None)
t = evento.get("transcription") # ausente no v1
if not t or t.get("status") != "ok" or not (t.get("text") or "").strip():
return None, None # failed, too_long, disabled, ou áudio sem fala
if t["confidence"] >= ALTA:
return t["text"], "alta"
if t["confidence"] >= MEDIA:
return t["text"], "media"
return t["text"], "baixa"
Reentrega e ordem
O serviço insiste até o evento ser entregue. A fila de reenvio fica em disco: nada se perde se ele reiniciar.
Resultado da chamada ao webhook
O que o serviço faz
2xx
Entregue. Fim.
400
Desiste na hora e registra no log. Não reenvia.
Qualquer outro: 4014044295xx3xx, timeout de 10 s, erro de rede
Reenvia com o mesmoevent_id, com timestamp e assinatura novos.
Redirecionamentos (301, 302, 308) não são seguidos: contam como falha. O caso típico é a URL do Django sem a barra final.
Intervalos entre as tentativas
1ªna hora0 s
2ª+10 s10 s
3ª+1 min1 min 10 s
4ª+5 min6 min 10 s
5ª+15 min21 min 10 s
6ª, a última+1 h1 h 21 min 10 s
A linha de baixo soma os intervalos desde a primeira tentativa (sem contar o tempo de cada chamada). Se a 6ª tentativa falhar, o serviço desiste e registra no log. Um evento abandonado não volta a ser enviado. Se o webhook do eAgenda ficar fora do ar por mais tempo que isso, os eventos que esgotarem as 6 tentativas se perdem para o eAgenda.
Ordem
As mensagens de um mesmo paciente (message.received) chegam na ordem em que ele escreveu.
Os status de uma mesma mensagem chegam em ordem.
Um áudio do paciente espera a transcrição (alguns segundos) antes de sair. As mensagens seguintes do mesmo paciente esperam junto; as de outros pacientes, não.
Um evento aguardando reenvio segura os seguintes da mesma fila. Se o eAgenda responde 500 à primeira mensagem de um paciente, a segunda só é enviada depois que a primeira for entregue (ou abandonada).
Entre filas diferentes não há ordem garantida. Um message.received pode chegar antes do delivered da mensagem que ele cita. Não dependa disso.
Consequência: um erro persistente no processamento de um evento atrasa todos os seguintes daquele paciente por até cerca de 1 h 21 min. Por isso o exemplo em Django guarda primeiro e processa depois.
Idempotência do lado do eAgenda
Um evento pode chegar mais de uma vez. Isso acontece quando o eAgenda processou, mas a resposta 200 se perdeu ou demorou mais de 10 s.
Processe cada event_id uma vez e cada inbound_id uma vez. A um evento repetido, responda 200 de novo.
Garanta isso com restrições UNIQUE no banco, como no exemplo em Django.
Ao aplicar status, só avance: sent → delivered → read. failed é final.
POST /sandbox/webhook
Testar antes de ter o webhook pronto
O serviço tem um eAgenda simulado: a rota POST /sandbox/webhook. Ela verifica assinatura e timestamp como o eAgenda faria e responde exatamente como na tabela de respostas do webhook. Para usar, o operador aponta a URL do webhook para ela:
Com isso, o eAgenda já pode chamar POST /messages de verdade e o celular de teste já pode responder. No painel, o operador vê cada evento que o serviço gerou: o JSON completo, se a assinatura bateu, a diferença de relógio, se era repetido e o que foi respondido. Na v1.1, esse corpo inclui a transcrição dos áudios, que no sandbox fica só em memória. Peça ao operador cópias desses corpos para usar nos testes do lado do eAgenda.
Modos de falha
O operador escolhe no painel como o sandbox responde. Cada modo exercita um caminho da reentrega.
Modo
O sandbox responde
Serve para ver
ok
200{"status": "ok"}
A entrega normal.
erro_500
500
O reenvio com o mesmo event_id nos intervalos de 10 s, 1 min, 5 min… Volte para ok e veja o evento chegar.
erro_401
401
O mesmo reenvio, como se a assinatura fosse recusada (relógio ou segredo errado).
erro_400
400
O serviço desiste na hora e marca o evento como recusado.
timeout
demora 15 s
O serviço corta em 10 s e reenvia.
Testar o webhook do eAgenda sem o serviço
Para testar o lado do eAgenda sozinho, este script faz o papel do serviço. Ele assina um message.received com o seu WEBHOOK_SECRET e o envia duas vezes, com timestamp e assinatura novos, como num reenvio. O esperado: 200 nas duas e uma única linha no banco. Ele não segue redirecionamentos, como o serviço. Só usa a biblioteca padrão do Python. Com audio como segundo argumento, ele manda um áudio já transcrito, no formato da v1.1.
enviar_evento_teste.py
"""Faz o papel do serviço: assina um message.received e envia ao seu webhook.
Uso: WEBHOOK_SECRET='...' python3 enviar_evento_teste.py URL_DO_WEBHOOK [texto|audio]
"""
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
URL = sys.argv[1]
TIPO = sys.argv[2] if len(sys.argv) > 2 else "texto"
SEGREDO = os.environ["WEBHOOK_SECRET"]
class SemRedirecionar(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None # o serviço também não segue redirecionamentos
abrir = urllib.request.build_opener(SemRedirecionar).open
evento = {
"event": "message.received",
"event_id": str(uuid.uuid4()),
"inbound_id": "TESTE" + uuid.uuid4().hex[:15].upper(),
"from": "5538999991234",
"type": "text",
"text": "Sério? Justo agora, nossa vai ser difícil ir nesse dia",
"reply_to_message_id": None,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"transcription": None, # v1.1: sempre presente; null quando não é áudio
}
if TIPO == "audio":
evento.update(type="audio", text=None, transcription={
"status": "ok",
"text": "A outra consulta é de manhã, então consigo ir sim.",
"confidence": 0.91,
"language": "pt",
"duration_seconds": 6.4,
"provider": "openai/whisper-1",
})
corpo = json.dumps(evento, ensure_ascii=False, separators=(",", ":")).encode("utf-8") # UMA vez
for tentativa in (1, 2): # a 2ª simula um reenvio: mesmo corpo, timestamp e assinatura novos
ts = str(int(time.time()))
assinatura = "sha256=" + hmac.new(SEGREDO.encode("utf-8"), ts.encode() + b"." + corpo, hashlib.sha256).hexdigest()
pedido = urllib.request.Request(URL, data=corpo, method="POST", headers={
"Content-Type": "application/json",
"X-Bot-Timestamp": ts,
"X-Bot-Signature": assinatura,
})
try:
with abrir(pedido, timeout=10) as resposta:
print(f"tentativa {tentativa}: HTTP {resposta.status} {resposta.read().decode()}")
except urllib.error.HTTPError as e:
print(f"tentativa {tentativa}: HTTP {e.code} {e.read().decode()[:200]}")
time.sleep(1)
Os testes 1 a 15 são os do contrato v1; os testes 16 a 18 são da extensão de áudio (v1.1). A integração está pronta quando todos passam. Façam juntos, numa chamada: o eAgenda chama a API e acompanha o webhook, o operador acompanha o painel e alguém fica com o celular de teste na mão.
Antes de começar
GET /health mostra session: "connected".
Para os testes 16 e 17: capabilities mostra send_audio e transcription como true, e o eAgenda já aceitou a mudança de dados pessoais.
A URL do webhook no serviço aponta para o webhook do eAgenda.
O celular de teste está com as confirmações de leitura ligadas (WhatsApp › Configurações › Privacidade › Confirmações de leitura).
O operador separou um número válido (55 + DDD + número) que não tem WhatsApp, para o teste 5.
Quem faz:eAgendaOperadorCelular de teste
0 de 18 conferidos
As marcações ficam só neste navegador.
eAgenda
Chame sem o cabeçalho Authorization e depois com Bearer errado. As duas respostas: 401 com {"error": {"code": "unauthorized", "message": "token inválido"}}.
eAgenda
Envie "to": "3899991234". Resposta 400, error.code igual a invalid_payload e error.message começando com to:. Nada chega ao celular.
eAgendaCelular de teste
Envie um texto com acentos, emoji, uma quebra de linha e uma linha em branco, por exemplo "Confirmação às 14:30\nSegunda linha\n\nÚltima linha 😀". Quem está com o celular compara letra por letra.
eAgendaCelular de teste
Repita o mesmo pedido três vezes seguidas. Três respostas 202 idênticas. Uma mensagem no celular. Um único sent e um único delivered no webhook.
eAgendaOperador
Envie para o número sem WhatsApp separado pelo operador. A resposta é 202; logo depois chega status: "failed" com error.code: "not_on_whatsapp", sem sent antes.
eAgendaCelular de teste
Com o celular online e a conversa fechada, envie. Chegam sent e delivered em segundos. Abra a conversa: chega read. Os três com o mesmo message_id, nessa ordem.
Celular de testeeAgenda
Responda “SIM” sem citar. Chega type: "text", text: "SIM" e reply_to_message_id: null. Confira que from é o celular de teste (pode vir sem o nono dígito). O serviço não responde nada.
Celular de testeeAgenda
No celular, toque e segure a mensagem recebida, escolha Responder e escreva. O evento traz reply_to_message_id igual ao message_id usado no POST /messages daquela mensagem.
Celular de testeeAgenda
Grave e envie uma mensagem de voz. Chega type: "audio" e text: null. O serviço não responde nada ao paciente. Na v1.1, o mesmo evento traz transcription (teste 17).
OperadorCelular de testeeAgenda
Crie um grupo com o celular de teste e o número do serviço e mande uma mensagem no grupo: nenhum message.received. As mensagens que o serviço envia também não geram message.received, só message.status. O operador confirma no painel que nenhum evento foi criado.
eAgendaOperador
Do lado do eAgenda, o teste com o vetor passa: o corpo do vetor é aceito com 200 e o mesmo corpo com um byte trocado recebe 401. Do lado do serviço, o operador mostra o teste equivalente passando.
eAgendaOperadorCelular de teste
Desligue o webhook do eAgenda (ou faça-o responder 503). Mande uma mensagem do celular de teste. No painel, o operador vê o evento pendente e as tentativas. Religue em menos de 1 hora. O evento chega com o mesmo event_id e é processado uma vez só.
eAgendaOperadorCelular de teste
Envie 10 mensagens numeradas de uma vez, cada uma com seu message_id. Com 3 s entre envios, elas ficam na fila (queue_size no /health). O operador reinicia o serviço no meio. As 10 chegam ao celular, uma vez cada, e cada uma recebe seus status.
Operador
Depois dos testes, o operador procura nos logs do serviço (painel e saída do contêiner) um trecho de texto enviado e o telefone completo do celular de teste. Nenhum dos dois pode aparecer: só ids e telefones mascarados, como ****1234.
OperadoreAgenda
O operador desconecta a sessão do número do serviço. O eAgenda chama GET /health: session: "disconnected", e um POST /messages novo responde 503. Ao final, o operador reconecta lendo o QR code.
eAgendaCelular de teste
Envie com "type": "audio" (exemplo no início rápido). A resposta é 202 na hora. Em alguns segundos chega ao celular uma mensagem de voz, sem texto escrito, que fala o texto enviado. Chegam sent e delivered; ao abrir a conversa ou ouvir o áudio, chega read. Os três com o mesmo message_id, nessa ordem.
Celular de testeeAgenda
Grave uma mensagem de voz com uma frase conhecida, como “A outra consulta é de manhã, então consigo ir sim”. Chega type: "audio", text: null e transcription com status: "ok", text igual ou muito próximo do que foi dito, confidence entre 0 e 1, language: "pt" e provider: "openai/whisper-1". O evento não traz o arquivo de áudio.
eAgenda
Envie um pedido válido, mas com "type": "video". Resposta 400, error.code igual a invalid_payload e error.message igual a type: use text ou audio. Nada chega ao celular.
Problemas comuns
O webhook responde 401 a todos os eventos
Relógio fora de sincronia. Mais de 300 s de diferença entre os servidores. Compare date -u +%s nos dois e ligue a sincronização por NTP (chrony ou systemd-timesyncd). Use time.time(); não monte o timestamp a partir da hora local, porque um erro de fuso de 3 horas são 10 800 s.
Corpo reconvertido. A verificação usa json.dumps(...) do JSON já lido, em vez de request.body. Use os bytes brutos.
Segredo diferente. Espaço ou quebra de linha a mais no WEBHOOK_SECRET, ou o SERVICO_API_TOKEN usado no lugar dele.
Prefixo. O cabeçalho é sha256= seguido do hexadecimal. Compare com o prefixo nos dois lados.
Enquanto isso, o serviço reenvia cada evento até 6 vezes e desiste. Rode o vetor de teste para separar erro de cálculo de erro de configuração.
O serviço recebe 301 e não entrega
No Django, sem a barra final, o APPEND_SLASH redireciona …/webhook para …/webhook/ com 301. Com DEBUG=True, ele responde 500 com uma mensagem sobre APPEND_SLASH. O serviço não segue redirecionamentos: conta como falha e reenvia até desistir. O mesmo acontece com o redirecionamento de http para https (SECURE_SSL_REDIRECT).
A URL configurada no serviço precisa ser a final, com https e com a barra: https://<host-do-eagenda>/api/v1/whatsapp/bot/webhook/.
O paciente não é encontrado pelo from
O WhatsApp informou o número sem o nono dígito (553899991234 em vez de 5538999991234). Normalize os dois lados antes de comparar. Quando houver reply_to_message_id, prefira ele para achar o agendamento.
POST /messages responde 503
A sessão está em qr_required ou disconnected: confira com GET /health e avise o operador, que precisa ler o QR code. Nada foi enfileirado. Quando a sessão voltar, repita com o mesmo message_id.
A mensagem demora a chegar
Fila. O serviço envia uma mensagem por vez, com pelo menos 3 s entre elas. Com 40 mensagens na fila, a última sai depois de uns 2 minutos. Veja queue_size no /health.
Mensagem de voz. Gerar a fala leva alguns segundos por mensagem (cerca de 5 s por frase, em teste), além do intervalo da fila.
Sessão reconectando. Em connecting, as mensagens esperam na fila. Depois de 30 minutos, chega failed com session_unavailable.
Celular do paciente. Desligado ou sem internet, a mensagem fica em sent até ele voltar. Isso é do WhatsApp, não do serviço.
Recebi 202, mas nada chegou ao celular
O message_id já tinha sido usado (num teste anterior, por exemplo). O serviço respondeu 202 e não enviou de novo. Use um UUID novo por mensagem.
O número não tem WhatsApp: confira se chegou failed com not_on_whatsapp no webhook.
Mensagem de voz: confira se chegou failed com tts_failed.
A mensagem ainda está na fila. O webhook message.status sempre conta o que aconteceu.
Texto com emoji recusado perto do limite
O limite de 4096 caracteres conta pontos de código Unicode, como o len() do Python, que dá o mesmo resultado que o serviço. Em JavaScript, "😀".length é 2; conte com [...texto].length. Alguns emojis valem mais de um: len("👍🏽") é 2, por causa do tom de pele.
read nunca chega
O paciente desligou as confirmações de leitura no WhatsApp. Nesse caso a leitura não é informada a ninguém. Trate delivered como o último status garantido.
400 “type: envio de áudio não está habilitado neste serviço”
O serviço está sem chave de voz configurada: capabilities.send_audio é false no /health. Envie como texto, com um message_id novo, e combine com o operador se o envio em áudio deve ser ligado.
A transcrição chega sem texto
disabled: a transcrição está desligada no serviço. Veja capabilities.transcription.
too_long: o áudio passou do limite (padrão: 300 s).
failed: o serviço não conseguiu baixar o áudio do WhatsApp, ou a API de transcrição falhou. O evento chega assim mesmo.
ok com text: "" e confidence: 0: o áudio não tinha fala.
Em todos os casos, peça a resposta por escrito ou encaminhe para atendimento humano.
O webhook não recebe nada
A URL do webhook no serviço está vazia ou aponta para o sandbox. Confirme com o operador.
Um firewall bloqueia o endereço do serviço. Peça o IP de saída ao operador.
Certificado HTTPS inválido ou autoassinado: a entrega falha como erro de rede.
Um evento anterior do mesmo paciente está aguardando reenvio e segura os seguintes. Veja Ordem.
Limites e cuidados
Item
Valor
Observação
Intervalo entre envios
3 s
Uma mensagem por vez. No máximo cerca de 20 por minuto.
Tempo máximo na fila
30 min
Depois disso, failed com session_unavailable.
Tamanho do texto
4096
Pontos de código Unicode. Vale também para o texto falado (v1.1). Fora a voz gerada do texto, o serviço não envia mídia, botões nem listas.
Telefone
12–13 dígitos
Só números brasileiros, começando com 55.
Áudio do paciente transcrito (v1.1)
até 300 s
Acima disso, transcription.status vem too_long.
Geração da voz (v1.1)
~5 s
Por frase, medido em teste. Acontece na fila, depois do 202.
Resposta do POST /messages
< 2 s
O eAgenda corta em 10 s.
Resposta do webhook do eAgenda
< 10 s
Acima disso, o serviço considera falha e reenvia.
Guarda dos ids
7 dias
Depois disso, um message_id antigo seria aceito como novo. Mais um motivo para nunca reaproveitar.
Reações, edições e mensagens apagadas não chegam ao eAgenda. Se o paciente editar “SIM” para “NÃO”, o eAgenda só vê o “SIM”. Se ele reagir com um emoji em vez de escrever, o eAgenda não fica sabendo.
Dados pessoais (LGPD)
O serviço trafega telefone e conversa de pacientes do SUS. Ele segue estas regras:
Os logs têm só ids e os 4 últimos dígitos do telefone (****1234). Nunca o texto, a transcrição ou o telefone completo.
O texto de uma mensagem enviada é apagado do banco do serviço assim que o WhatsApp aceita o envio, ou quando ela falha.
Sem o texto, ficam só ids, telefone e status. Os ids são guardados por 7 dias.
Fora a extensão de áudio (abaixo), nada vai para terceiros: nem analytics, nem monitoramento externo.
Mudança na v1.1 · precisa do aceite do eAgenda
O v1 proíbe enviar dados a terceiros. A extensão de áudio exige isso:
o texto das mensagens de voz vai para o Google (Gemini), que gera a fala. Se o operador configurar a voz pela OpenAI, vai para a OpenAI;
o áudio do paciente vai para a OpenAI (Whisper), que transcreve.
Os recursos de áudio só devem ser usados depois que o time do eAgenda aceitar essa mudança.
O serviço não guarda o áudio: ele fica só em memória durante a operação. A transcrição sai no webhook e é apagada do banco do serviço depois da entrega, como os demais corpos. Os logs não têm texto nem transcrição. Na lista de eventos do painel, o operador vê só o status e a confiança da transcrição.
Recomendações: usar conta paga nos dois fornecedores (a API da OpenAI não usa os dados recebidos pela API para treinar modelos, por padrão; no Gemini, o nível gratuito pode usar os dados para melhorar produtos, o pago não) e registrar os dois como operadores no inventário de dados da LGPD.
Cada recurso pode ser desligado no serviço: sem chave de voz configurada, não há envio em áudio; sem a chave da OpenAI, a transcrição fica disabled. O GET /health mostra o que está ligado.
Do lado do eAgenda, o recomendado é o mesmo: não registrar em log o texto das mensagens nem o telefone completo. Neste ambiente, usem só celulares de teste e dados fictícios.
Risco de bloqueio do número
A automação do WhatsApp Web não é um canal oficial do WhatsApp. O WhatsApp pode restringir ou banir o número do serviço sem aviso. O risco cresce com muitas mensagens para quem não tem o número salvo, textos repetidos e denúncias de spam.
Mantenham o volume baixo e enviem só para celulares de teste.
O intervalo de 3 s entre envios existe para reduzir esse risco. Não tentem contorná-lo com vários pedidos em paralelo: a fila é uma só.
A sessão pode cair quando o WhatsApp Web muda de versão. O operador reconecta; enquanto isso, /health mostra o estado.
Para atender pacientes reais em produção, o caminho oficial é a API do WhatsApp Business, que tem regras e custos próprios.