Webhook outbound (cliente) — envio de eventos e troubleshooting
Este documento descreve como o Pilot Status dispara webhooks outbound (eventos para URLs configuradas pelos clientes), quais eventos existem, como cada um é gerado a partir da ingestão Evolution, e quais são os principais “gates” que podem impedir o disparo.
O envio outbound é feito pelo helper dispatchCustomerWebhook, usado pelos handlers do webhook interno:
- messages.upsert
- messages.update
- (Opt-in também é disparado dentro de
messages.upsert)
Visão geral
Entrada (Evolution → Pilot Status)
- Eventos chegam como
POST /api/internal/webhook(direto do provedor) ou via RabbitMQ (worker → internal webhook). - A rota é route.ts.
- A ingestão é protegida por dedup/locks em dual-ingestion.ts.
Saída (Pilot Status → Cliente)
- O sistema transforma eventos “técnicos” da Evolution em eventos “de domínio” do Pilot Status e faz
POSTpara a URL do cliente. - Cada tentativa (sucesso/falha) deveria gerar um registro em
webhook_logsvia WebhookService.logDelivery.
Onde ficam as configurações de webhooks
Modelo Prisma:
WebhookeWebhookLogestão em schema.prisma.
Relações:
- Um
Webhookpertence a umTenant. - Um
ApiKeypode terwebhookId(ligação de webhooks por API key) (ApiKey.webhookId).
Dois “modelos” de dispatch:
- Por API key: eventos relacionados a uma mensagem enviada via API (status e correlação de replies).
- Por tenant: eventos em nível de tenant (principalmente
message.groupemessage.receivedquando não há correlação com uma mensagem outbound).
Eventos outbound suportados
Lista de eventos (domínio):
message.sentmessage.deliveredmessage.readmessage.failedmessage.receivedmessage.replymessage.groupoptin.created
Função central: dispatchCustomerWebhook
Implementação: dispatchCustomerWebhook
O que ela faz:
- Aplica filtro de eventos permitidos (por
webhook.eventse/ou cache Redis). - Monta payload JSON e headers.
- Se
webhook.secretexistir, calcula HMAC SHA-256 e envia emx-pilot-status-signature. - Faz
fetch(POST)parawebhook.url. - Registra o resultado em
WebhookLog(sucesso/falha, statusCode, resposta truncada).
Filtro de eventos (gate silencioso)
Antes de enviar, existe um gate que pode “sumir” com o evento:
- Se
webhook.eventsestiver definido e não incluirparams.event, a função retorna sem fazerfetche sem logar nada (utils.ts:L586-L600). - Se
webhook.eventsvier vazio/undefined, a função tenta carregar do Redis emps:webhook:events:<webhookId>.
Implicações:
- Se o handler buscar webhooks “amplamente” (ex.: todos os ativos do tenant) e o webhook não tiver o evento habilitado, ele pode parecer “não dispara” mesmo quando o loop roda.
- Se o cache Redis estiver desatualizado (ou ausente), o sistema depende do
webhook.eventsdo banco.
Assinatura (autenticidade)
Quando webhook.secret existe:
signature = hmac_sha256(secret, JSON.stringify(payload))- Header:
x-pilot-status-signature: <hex>
O cliente pode validar recalculando o HMAC com o secret configurado.
Logging de entrega
Para cada envio (sucesso ou erro), o sistema chama:
Tabela:
webhook_logs(schema.prisma:WebhookLog)
Se não há registros novos em webhook_logs durante um período, isso geralmente indica:
- os handlers não estão chamando
dispatchCustomerWebhook, ou - o filtro de eventos está retornando antes do
fetch(gate silencioso), ou - o fluxo está retornando em algum “early return” antes de chegar ao dispatch.
Origem de cada evento
1) message.sent | message.delivered | message.read | message.failed
Origem: messages.update (Evolution)
Handler: handleMessagesUpdateEvent
Passos principais:
- Mapeia
data.statusda Evolution para um status interno (SENT/DELIVERED/READ/FAILED) viaSTATUS_MAP(utils.ts:STATUS_MAP). - Busca a mensagem no banco por:
data.messageId(ID interno) ouevolutionKeyId = data.keyId(+evolutionInstanceIdquando disponível), com fallback porevolutionKeyIdapenas (messages-update.ts:L37-L78).
- Atualiza status + timestamps.
- Dispara webhooks outbound se:
- existe
message.apiKey.webhook webhook.active === true- a transição de status atende as condições do evento.
- existe
Gates comuns que impedem:
Webhook: message not found→ não achou a mensagem para correlacionar (messages-update.ts:L79-L85).- Regressão de status bloqueada (
DELIVERED/READnão regride paraFAILED) (messages-update.ts:L87-L98). message.sentdepende dedata.fromMe === true(messages-update.ts:L179-L185).webhook.eventsnão contém o evento (gate silencioso no dispatch).
2) message.received | message.reply
Origem: messages.upsert (Evolution) — mensagens recebidas
Handler: handleMessagesUpsertEvent
Dois modos:
(A) Correlacionado (por API key)
- O handler tenta encontrar a “última mensagem outbound” para o mesmo destino, ou por
repliedToKeyId(reply) (messages-upsert.ts:L214-L277). - Exige
apiKey,apiKey.webhookewebhook.active(messages-upsert.ts:L542-L559). - Se for reply (
stanzaId/ quoted), emitemessage.reply; caso contrário,message.received(messages-upsert.ts:L561-L626).
(B) Não correlacionado (tenant-level)
- Quando não encontra correlação (
lastOutgoing), pode emitirmessage.receivedpara todos os webhooks ativos do tenant (com filtro no dispatch) (messages-upsert.ts:L480-L539). - Existe uma regra para reduzir ruído: se a instância for a “Pilot Status instance” e não for self-chat, pode pular
message.receivedem certos casos (messages-upsert.ts:L569-L583).
Timestamp:
message.receivedemessage.replyusamreceivedAtbaseado emdate_timedo payload da Evolution (fallback para horário atual apenas sedate_timevier ausente/vazio).
Gates comuns que impedem:
- Não resolve
tenantIdviawhatsAppInstance(instanceName)→ limita correlação/tenant-level. lastOutgoing.apiKeyinexistente, sem webhook, webhook inativo (messages-upsert.ts:L542-L559).webhook.eventsnão contém o evento (gate silencioso no dispatch).
3) message.group
Origem: messages.upsert (Evolution) — mensagens de grupo
Detecção:
- Considera “grupo” quando
data.key.remoteJidcontém@g.us(messages-upsert.ts:L32-L35).
Regras:
- Só dispara quando
data.messageType === "conversation". Caso contrário, loga e skipa (messages-upsert.ts:L60-L70). - Precisa resolver
tenantIdpelawhatsAppInstance(instanceName)(messages-upsert.ts:L72-L82). - Possui um gate específico: se
instanceNamefor igual aEVOLUTION_INSTANCE_NAME(“Pilot Status instance”), ele skipamessage.group(messages-upsert.ts:L83-L95). - Busca webhooks do tenant com
active=trueeevents has "message.group"(messages-upsert.ts:L140-L153). - Dispara um POST por webhook (payload inclui
groupId,fromNumber,fromName,content,happenedAt, etc.) (messages-upsert.ts:L139-L171).
Por que o log pode mostrar mensagens de grupo sem disparo:
- O log
Webhook: incoming messageacontece antes do bloco de grupo e não garante que houve dispatch. - Se
messageType !== "conversation"o sistema explicitamente skipa. - Se
tenantIdnão for resolvido para aquelainstanceName, sai sem loop de webhooks. - Se cair no gate “Pilot Status instance”, sai sem disparar.
4) optin.created
Origem: messages.upsert (Evolution) — comando optin <project_id> (palavra optin case-insensitive)
O handler identifica tentativa de opt-in e cria/atualiza projectWhatsAppOptIn, depois dispara optin.created para webhooks ativos do tenant do projeto resolvido pelo token, com events contendo optin.created, em TEST e LIVE — sem exigir que o webhook esteja ligado a uma API key. O payload inclui sempre o projectId do opt-in. Aplica-se o mesmo critério de assinatura por instância WhatsApp que em message.received (sem assinatura = todos os números do tenant; com assinatura = só instâncias listadas). O campo data.environment segue o ambiente do webhook (TEST / LIVE).
Para destinos BR (+55 móvel), o mesmo fluxo pode gerar vários dispatchCustomerWebhook com optin.created (um por variante com/sem o 9 após o DDD), cada um com destinationNumber/destinationHash correspondentes.
O payload inclui fromName (nome de exibição no WhatsApp) quando possível: pushName do evento Evolution ou, se ausente, resultado do endpoint whatsappNumbers na instância que recebeu o opt-in; com PII redatada por retenção, fromName é null.
Um trecho de disparo no arquivo mostra:
event = "optin.created"e chamada para dispatchCustomerWebhook.
Gates comuns:
- Não resolver projeto/slug/token.
- Webhook inativo ou sem evento permitido.
- Instância do evento fora da assinatura do webhook (quando o webhook restringe instâncias).
Dedup e impacto no dispatch
Dual ingestion (webhook direto + rabbitmq) evita processar o mesmo evento duas vezes:
- A deduplicação usa
extractEvolutionDedupFields(evolution-dedup.ts). - A tabela
evolution_ingestion_eventsguardaprocessedAteforwardedToInternalWebhookAt(schema.prisma:EvolutionIngestionEvent). - Redis usa:
- lock de processamento
ps:evolution:processing:<dedupKey>(fullstack) (dual-ingestion.ts) - done key
ps:evolution:done:<dedupKey>(fullstack e worker) (dual-ingestion.ts, evolution-consumer.ts).
- lock de processamento
Ponto importante:
- É possível um evento ser marcado como “processado” sem ter gerado dispatch se ele caiu em algum early return (ex.:
messageTypenão suportado). Nesses casos, o worker pode continuar acusando “already done”.
Observação de timestamp:
- No evento outbound
message.group, o campohappenedAtusadate_timedo payload da Evolution (quando presente), com fallback para o horário atual apenas sedate_timevier ausente/vazio.
Checklist de troubleshooting (rápido)
Quando “chega no internal webhook, mas não dispara outbound”:
- Confirmar que o webhook do cliente está ativo e com
eventsincluindo o evento esperado (message.group, etc.). - Verificar
webhook_logs:- Se não há logs, normalmente o dispatch não foi tentado ou foi filtrado antes do fetch.
- Para
message.group:- O evento da Evolution precisa ter
messageType = "conversation". - Precisa existir
whatsAppInstance.instanceNameigual aobody.instancepara resolvertenantId. - Conferir se não caiu no gate “Pilot Status instance” (comparação com
EVOLUTION_INSTANCE_NAME).
- O evento da Evolution precisa ter
- Para
message.sent/delivered/read/failed:- Se aparecem warnings
Webhook: message not found, o update não conseguiu correlacionar com a mensagem.
- Se aparecem warnings
- Conferir filtro silencioso:
dispatchCustomerWebhookretorna sem log sewebhook.eventsnão contiverparams.event(utils.ts:L598-L600).
Referências adicionais
- Documento do webhook interno (entrada Evolution): webhook-interno-evolution.md
- Arquitetura de ingestão dual: ingestao-dual-evolution-rabbitmq-webhook.md