Documentação / Webhook outbound (cliente) — envio de eventos e troubleshooting

Webhook outbound (cliente) — envio de eventos e troubleshooting

Entrar

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:

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 POST para a URL do cliente.
  • Cada tentativa (sucesso/falha) deveria gerar um registro em webhook_logs via WebhookService.logDelivery.

Onde ficam as configurações de webhooks

Modelo Prisma:

Relações:

  • Um Webhook pertence a um Tenant.
  • Um ApiKey pode ter webhookId (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.group e message.received quando não há correlação com uma mensagem outbound).

Eventos outbound suportados

Lista de eventos (domínio):

  • message.sent
  • message.delivered
  • message.read
  • message.failed
  • message.received
  • message.reply
  • message.group
  • optin.created

Função central: dispatchCustomerWebhook

Implementação: dispatchCustomerWebhook

O que ela faz:

  • Aplica filtro de eventos permitidos (por webhook.events e/ou cache Redis).
  • Monta payload JSON e headers.
  • Se webhook.secret existir, calcula HMAC SHA-256 e envia em x-pilot-status-signature.
  • Faz fetch(POST) para webhook.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.events estiver definido e não incluir params.event, a função retorna sem fazer fetch e sem logar nada (utils.ts:L586-L600).
  • Se webhook.events vier vazio/undefined, a função tenta carregar do Redis em ps: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.events do 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:

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.status da Evolution para um status interno (SENT/DELIVERED/READ/FAILED) via STATUS_MAP (utils.ts:STATUS_MAP).
  • Busca a mensagem no banco por:
    • data.messageId (ID interno) ou
    • evolutionKeyId = data.keyId (+ evolutionInstanceId quando disponível), com fallback por evolutionKeyId apenas (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.

Gates comuns que impedem:

2) message.received | message.reply

Origem: messages.upsert (Evolution) — mensagens recebidas

Handler: handleMessagesUpsertEvent

Dois modos:

(A) Correlacionado (por API key)

(B) Não correlacionado (tenant-level)

  • Quando não encontra correlação (lastOutgoing), pode emitir message.received para 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.received em certos casos (messages-upsert.ts:L569-L583).

Timestamp:

  • message.received e message.reply usam receivedAt baseado em date_time do payload da Evolution (fallback para horário atual apenas se date_time vier ausente/vazio).

Gates comuns que impedem:

  • Não resolve tenantId via whatsAppInstance(instanceName) → limita correlação/tenant-level.
  • lastOutgoing.apiKey inexistente, sem webhook, webhook inativo (messages-upsert.ts:L542-L559).
  • webhook.events não contém o evento (gate silencioso no dispatch).

3) message.group

Origem: messages.upsert (Evolution) — mensagens de grupo

Detecção:

Regras:

Por que o log pode mostrar mensagens de grupo sem disparo:

  • O log Webhook: incoming message acontece antes do bloco de grupo e não garante que houve dispatch.
  • Se messageType !== "conversation" o sistema explicitamente skipa.
  • Se tenantId não for resolvido para aquela instanceName, 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 LIVEsem 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:

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:

Ponto importante:

  • É possível um evento ser marcado como “processado” sem ter gerado dispatch se ele caiu em algum early return (ex.: messageType não suportado). Nesses casos, o worker pode continuar acusando “already done”.

Observação de timestamp:

  • No evento outbound message.group, o campo happenedAt usa date_time do payload da Evolution (quando presente), com fallback para o horário atual apenas se date_time vier ausente/vazio.

Checklist de troubleshooting (rápido)

Quando “chega no internal webhook, mas não dispara outbound”:

  1. Confirmar que o webhook do cliente está ativo e com events incluindo o evento esperado (message.group, etc.).
  2. Verificar webhook_logs:
    • Se não há logs, normalmente o dispatch não foi tentado ou foi filtrado antes do fetch.
  3. Para message.group:
    • O evento da Evolution precisa ter messageType = "conversation".
    • Precisa existir whatsAppInstance.instanceName igual ao body.instance para resolver tenantId.
    • Conferir se não caiu no gate “Pilot Status instance” (comparação com EVOLUTION_INSTANCE_NAME).
  4. Para message.sent/delivered/read/failed:
    • Se aparecem warnings Webhook: message not found, o update não conseguiu correlacionar com a mensagem.
  5. Conferir filtro silencioso:
    • dispatchCustomerWebhook retorna sem log se webhook.events não contiver params.event (utils.ts:L598-L600).

Referências adicionais