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 gravarsentAt,deliveredAt,readAt, etc.).sender: remetente (usado emmessages.upsertcomoto/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
- Prisma (
@pilot-status/database): leitura/gravação de instâncias, mensagens, opt-in, webhooks e eventos crus. - Redis: cache de eventos permitidos por webhook e marcadores operacionais. Ex.: route.ts:L500-L521, route.ts:L774-L776, route.ts:L919
- Filas:
- Validação da instância após conectar: route.ts:L777-L799
- Notificação WhatsApp para usuários/tenant quando instância desconecta: route.ts:L886-L910
- Pushover (alertas para devs/operação):
- Conectou pela primeira vez: route.ts:L734-L772
- Desconectou/deslogou: route.ts:L815-L856
- Webhook de saída para clientes:
- Envio + assinatura opcional + logging de entrega: dispatchCustomerWebhook
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:
normalizePhoneForCompareusanormalizeToE164Digits: route.ts:L19-L21- para Brasil, gera variações com e sem o nono dígito: route.ts:L23-L39
- hashes de destino (deduplicados) via
hashPhone: route.ts:L41-L47
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:
- Conteúdo principal: extractIncomingMessageContent
- Conteúdo citado/quoted: extractQuotedMessageContent
- Identificação de reply via
stanzaId: extractRepliedToKeyId - Botão clicado (quando aplicável): extractButtonId
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
textdireto do payload; - se o payload for um objeto de variáveis, carrega
templateVersion.bodye 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
POSTJSON para a URL do cliente. - Se
webhook.secretexistir, assina o payload com HMAC SHA-256 e envia no headerx-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.eventsquando disponível; - se não houver, tenta carregar do Redis (
ps:webhook:events:<webhookId>) e filtra porevent.
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:
- Descobre
instanceNamea partir do campobody.instance. - Busca a instância no banco por
instanceName(tabelawhatsAppInstance). - Calcula
nextState:logout.instanceforça"LOGOUT".connection.updateusadata.statenormalizado (ex.:OPEN,CONNECTING,CLOSE).- Se a instância já está em
LOGOUT, evita sobrescrever comconnection.update.
- 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:
- Detecção OPEN→CONNECTING e início: route.ts:L654-L671
- Loop de recovery: startInstanceRecoveryLoop
“First connected” e validação assíncrona
Ao detectar uma transição para OPEN (conexão bem-sucedida), o webhook:
- marca
firstConnectedAtapenas uma vez (update condicional no banco); - agenda uma validação assíncrona da instância em uma fila (
whatsapp-instance-validate).
Referências:
- Marcação de
firstConnectedAt: route.ts:L690-L702 - Enfileirar validação: route.ts:L777-L799
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:
- Determinação de “deve notificar”: route.ts:L680-L685
- Pushover desconectou/deslogou: route.ts:L815-L856
- Enfileirar notificações WhatsApp: route.ts:L886-L910
O Redis marca o alerta como enviado e limpa o marcador quando volta a OPEN:
delao conectar: route.ts:L774-L776setapós notificar: route.ts:L919
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(dobody.instance)from(doremoteJid/remoteJidAlt)to(dobody.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:
- Por reply: se houver
repliedToKeyId, busca umamessagecomevolutionKeyId = repliedToKeyId(statusSENTouREAD). - Por último envio: busca a última
messageenviada ao mesmo destino (pordestinationHashoudestinationNumber), também com statusSENTouREAD.
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
retentionDaysnã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
lastSeenAtsempre que a pessoa repetir o opt-in; - detecta
isFirstOptInquando o opt-in é novo; - dispara
optin.createdpara webhooks ativos do projeto (ambiente LIVE).
Referências:
- Regex e resolução do token: route.ts:L1037-L1066
- Gravação/atualização de opt-in: route.ts:L1074-L1148
- Seleção de webhooks e disparo
optin.created: route.ts:L1150-L1227
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:
- Referência: route.ts:L1231-L1245
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
TESTeLIVE; - dispara
message.receivedcomprojectSlug: nulle 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:
- Montagem do payload base e dispatch tenant-level: route.ts:L1260-L1310
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.replyquando há sinais de reply (stanzaId ou quoted content); - caso contrário, vira
message.receivedcorrelacionado; - em
message.reply, envia tambémquotedMessageId,messageRepliedId,buttonId(se tiver), ereplyContent.
Referências:
- Escolha entre
message.replyemessage.received: route.ts:L1335-L1395 - Skip de
message.receivedquando inbound vem da instância “Pilot Status” e não é reply: route.ts:L1341-L1351
Fluxo 3 — messages.update (atualização de status de envio)
Esse fluxo:
- Mapeia status da Evolution → status interno do Pilot Status.
- Encontra a mensagem no banco.
- Garante progressão (nunca regride status por eventos fora de ordem).
- Atualiza timestamps/erros na mensagem.
- 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_ACK→DELIVEREDSERVER_ACK→SENTREAD/PLAYED→READERROR/FAILED→FAILED
Referências:
STATUS_MAP: route.ts:L233-L241- Regra de progressão (evita regressão): shouldUpdateStatus
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:
messageId(id interno do Pilot Status, quando o provedor devolve no webhook).keyId+instanceId(id da Evolution).keyIdsozinho (fallback).
Referência: route.ts:L1428-L1472
3.3 Atualização de timestamps e erros
Dependendo do status mapeado:
SENT: preenchesentAt(se ainda vazio).DELIVERED: preenchedeliveredAt.READ: preenchereadAt.FAILED: preencheerrorMessageeinternalErrorMessage(motivo vindo do provedor).
Também grava chaves de rastreio do provedor quando disponíveis:
evolutionKeyId: route.ts:L1508-L1510evolutionInstanceId: route.ts:L1511-L1513
3.4 Disparo de webhooks de status para clientes
Se houver webhook ativo na API key da mensagem, e ocorrer mudança relevante:
message.failed: apenas quando transiciona para FAILED e antes não era FAILED.message.delivered: quando transiciona para DELIVERED.message.read: quando transiciona para READ.
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 montarnumbersUrlnos alertas operacionais.
Observações operacionais e de segurança
- O handler é resiliente a falhas parciais: por exemplo, se gravar
evolutionWebhookEventfalhar, 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.