Documentação / Webhook interno (Evolution) — `api/internal/webhook`

Webhook interno (Evolution) — `api/internal/webhook`

Entrar

Webhook interno (Evolution) — api/internal/webhook

Este documento descreve o webhook interno que integra o Pilot Status com a Evolution API (WhatsApp), implementado em route.ts.

Ele é o ponto central para:

  • Ingerir eventos do provedor (conexão, recebimento de mensagens e atualização de status).
  • Sincronizar estado no banco (instâncias, mensagens, opt-ins, logs).
  • Disparar webhooks de saída para clientes (por API key) e registrar entregas.
  • Acionar rotinas operacionais (fila de validação de instância, alertas e notificações de desconexão).

Onde fica e como é chamado

  • Rota: POST /api/internal/webhook (Next.js App Router)
  • Implementação: route.ts
  • Observação: por desenho, esse endpoint é acessível sem sessão de usuário (webhook inbound do provedor).

Formato esperado dos eventos (alto nível)

O webhook espera um JSON com campos como:

  • event: nome do evento da Evolution (ex.: messages.update, messages.upsert, connection.update, logout.instance).
  • instance: nome da instância na Evolution (ex.: "Pilot Status" ou instâncias de tenants).
  • data: payload específico do evento.
  • date_time: timestamp do provedor (usado para gravar sentAt, deliveredAt, readAt, etc.).
  • sender: remetente (usado em messages.upsert como to/destino do número conectado).

No próprio arquivo há um exemplo explícito de payload de messages.update em route.ts:L211-L231.

Para exemplos reais capturados, veja a pasta evolution-events.

Persistência de evento cru (auditoria e debug)

Logo no início do handler (POST), o sistema tenta gravar sempre o payload bruto em evolutionWebhookEvent:

  • Objetivo: reconciliação, auditoria e depuração quando houver divergência entre o estado esperado e o que chegou do provedor.
  • Implementação: route.ts:L595-L615

Falhas nessa gravação não interrompem o processamento do evento (o webhook segue).

Dependências e componentes envolvidos

Funções utilitárias importantes (parsing e correlação)

Normalização de números e comparação

O webhook normaliza telefones para:

  • comparar from/destino de maneira consistente;
  • gerar hashes estáveis (privacidade e correlação com opt-in).

Pontos-chave:

Extração de conteúdo da mensagem (WhatsApp payload)

O conteúdo inbound pode vir em formatos diferentes (texto, botão, interactive response, mídia com caption). As funções abaixo encapsulam essa diversidade:

Reconstrução do texto enviado a partir do payload

Em alguns pontos (ex.: ao emitir message.failed/delivered/read ou ao correlacionar replies), o sistema precisa obter um “texto” consistente da mensagem que foi enviada.

A função resolveMessageContent faz isso:

  • tenta extrair text direto do payload;
  • se o payload for um objeto de variáveis, carrega templateVersion.body e aplica substituição {{variavel}}.

Implementação: resolveMessageContent

Webhooks de saída para clientes (dispatch + logs)

O webhook interno não só atualiza o estado interno como também “traduz” eventos em eventos de domínio para os clientes (via URL configurada no dashboard).

O envio é feito por dispatchCustomerWebhook:

  • Envia POST JSON para a URL do cliente.
  • Se webhook.secret existir, assina o payload com HMAC SHA-256 e envia no header x-pilot-status-signature: route.ts:L532-L538
  • Registra a entrega (sucesso/falha, status code, duração, resposta truncada) via WebhookService.logDelivery: route.ts:L550-L558

Filtro de eventos permitidos por webhook

Antes de enviar, o sistema confere se o evento está habilitado:

  • usa webhook.events quando disponível;
  • se não houver, tenta carregar do Redis (ps:webhook:events:<webhookId>) e filtra por event.

Referência: route.ts:L507-L521

Fluxo 1 — connection.update e logout.instance (estado da instância)

Quando chega um evento de conexão/desconexão, o webhook:

  1. Descobre instanceName a partir do campo body.instance.
  2. Busca a instância no banco por instanceName (tabela whatsAppInstance).
  3. Calcula nextState:
    • logout.instance força "LOGOUT".
    • connection.update usa data.state normalizado (ex.: OPEN, CONNECTING, CLOSE).
    • Se a instância já está em LOGOUT, evita sobrescrever com connection.update.
  4. Atualiza o estado no banco quando houver mudança.

Implementação principal: route.ts:L616-L663

Recuperação automática (OPEN → CONNECTING)

Quando a instância estava OPEN e passa para CONNECTING, o sistema inicia um loop de recuperação:

  • consulta o estado atual na Evolution (/instance/connectionState/<instanceName>);
  • tenta reiniciar a instância (/instance/restart/<instanceName>);
  • reavalia periodicamente até 15 tentativas;
  • para a instância “Pilot Status” (nome em EVOLUTION_INSTANCE_NAME), manda alertas periódicos via Pushover se continuar conectando.

Referências:

“First connected” e validação assíncrona

Ao detectar uma transição para OPEN (conexão bem-sucedida), o webhook:

  • marca firstConnectedAt apenas uma vez (update condicional no banco);
  • agenda uma validação assíncrona da instância em uma fila (whatsapp-instance-validate).

Referências:

Alertas e notificação para usuários do tenant

Quando há desconexão/deslog (com algumas regras de filtro), o webhook:

  • envia alerta para devs (Pushover);
  • prepara uma lista de destinatários (usuários do tenant + número da instância, quando disponível);
  • enfileira jobs para notificar via WhatsApp (usando a instância “Pilot Status” configurada).

Referências:

O Redis marca o alerta como enviado e limpa o marcador quando volta a OPEN:

Fluxo 2 — messages.upsert (mensagens recebidas)

Esse fluxo processa mensagens inbound e pode gerar:

  • message.received (mensagem recebida sem correlação ou correlacionada)
  • message.reply (resposta a uma mensagem enviada anteriormente)
  • optin.created (registro de opt-in via comando)

Entrada do fluxo: route.ts:L924-L1398

2.1 Extração e normalização

O handler extrai:

  • instanceName (do body.instance)
  • from (do remoteJid/remoteJidAlt)
  • to (do body.sender)
  • incomingContent (texto/caption/interactive/button)
  • informações de contexto para detectar replies (stanzaId/quoted)

Referência: route.ts:L924-L945

2.2 Descoberta de tenant e correlação com a última mensagem enviada

Se possível, o webhook encontra o tenant via whatsAppInstance pelo instanceName (para restringir correlação ao tenant correto).

A correlação tenta, em ordem:

  1. Por reply: se houver repliedToKeyId, busca uma message com evolutionKeyId = repliedToKeyId (status SENT ou READ).
  2. Por último envio: busca a última message enviada ao mesmo destino (por destinationHash ou destinationNumber), também com status SENT ou READ.

Referência: route.ts:L959-L1027

2.3 Regras de privacidade (PII redaction)

Alguns webhooks de saída podem precisar omitir dados pessoais (telefone, conteúdo) dependendo da retenção:

  • o cálculo usa shouldRedactPII({ retentionDays, createdAt })
  • se retentionDays não existir, não redige (comportamento default)

Referência: route.ts:L1028-L1035

2.4 Opt-in via comando optin <token>

Se o inbound contiver um comando do tipo:

optin <slug-do-projeto> ou optin <projectId> (a palavra optin é case-insensitive: OPTIN, Optin, etc.)

o webhook tenta resolver o projeto e registra opt-in em projectWhatsAppOptIn:

  • faz “upsert” em lote por hashes do destino (incluindo variações Brasil com/sem 9º dígito);
  • atualiza lastSeenAt sempre que a pessoa repetir o opt-in;
  • detecta isFirstOptIn quando o opt-in é novo;
  • dispara optin.created para webhooks ativos do projeto (ambiente LIVE).

Referências:

Um detalhe importante: quando múltiplas API keys apontam para o mesmo webhook, o sistema escolhe a menor retenção para determinar se deve redigir PII.

2.5 Registro de inbound não correlacionado (opcional)

Quando não há redaction e não é opt-in, o webhook armazena o inbound em incomingMessage (tabela/model interno) para debug/observabilidade:

2.6 Emissão de message.received sem correlação (tenant-level)

Se não existir lastOutgoing (sem correlação), ainda assim pode haver webhooks no nível do tenant:

  • ele carrega webhooks ativos do tenant em TEST e LIVE;
  • dispara message.received com projectSlug: null e alguns campos de correlação nulos;
  • evita disparar esse evento quando a mensagem inbound veio para a instância “Pilot Status” (para não gerar ruído).

Referências:

2.7 Emissão de message.received / message.reply com correlação (API key webhook)

Quando há uma mensagem correlacionada (lastOutgoing) e existe webhook ativo na API key:

  • o evento vira message.reply quando há sinais de reply (stanzaId ou quoted content);
  • caso contrário, vira message.received correlacionado;
  • em message.reply, envia também quotedMessageId, messageRepliedId, buttonId (se tiver), e replyContent.

Referências:

Fluxo 3 — messages.update (atualização de status de envio)

Esse fluxo:

  1. Mapeia status da Evolution → status interno do Pilot Status.
  2. Encontra a mensagem no banco.
  3. Garante progressão (nunca regride status por eventos fora de ordem).
  4. Atualiza timestamps/erros na mensagem.
  5. Dispara webhooks de status para o cliente (message.failed, message.delivered, message.read) quando houver transição relevante.

Entrada do fluxo: route.ts:L1400-L1652

3.1 Mapeamento de status e progressão

Mapa Evolution → Pilot Status:

  • DELIVERY_ACKDELIVERED
  • SERVER_ACKSENT
  • READ/PLAYEDREAD
  • ERROR/FAILEDFAILED

Referências:

Um caso especial: FAILED não sobrescreve mensagens já DELIVERED ou READ (proteção contra “falha tardia”).

3.2 Como a mensagem é encontrada

O webhook tenta localizar a mensagem por:

  1. messageId (id interno do Pilot Status, quando o provedor devolve no webhook).
  2. keyId + instanceId (id da Evolution).
  3. keyId sozinho (fallback).

Referência: route.ts:L1428-L1472

3.3 Atualização de timestamps e erros

Dependendo do status mapeado:

Também grava chaves de rastreio do provedor quando disponíveis:

3.4 Disparo de webhooks de status para clientes

Se houver webhook ativo na API key da mensagem, e ocorrer mudança relevante:

Em todos os casos, o content é reconstruído por resolveMessageContent para que o cliente receba um texto consistente (e redigido quando aplicável).

Para os formatos públicos dos eventos e exemplos de payload, veja webhooks.md.

Variáveis de ambiente relevantes

Esse webhook depende de algumas variáveis para operar “full”:

  • EVOLUTION_API_URL: base URL da Evolution (usado no recovery e em outras rotas do sistema).
  • EVOLUTION_API_KEY: API key para chamadas internas (connectionState, restart).
  • EVOLUTION_INSTANCE_NAME: nome da instância “Pilot Status” (usada em skips, notificações e recovery).
  • NEXTAUTH_URL / APP_URL: usado para montar numbersUrl nos alertas operacionais.

Observações operacionais e de segurança

  • O handler é resiliente a falhas parciais: por exemplo, se gravar evolutionWebhookEvent falhar, o processamento segue.
  • Não há validação de assinatura inbound do provedor nesse endpoint. Na prática, isso costuma ser mitigado por rede (ex.: IP allowlist, secret no provedor, gateway, WAF) e/ou por uma camada externa. Se esse risco for relevante, vale revisar a estratégia de proteção desse endpoint.
  • Há proteção explícita contra eventos fora de ordem em messages.update (progressão de status) para evitar inconsistências em sistemas distribuídos.