Skip to main content

Referência de eventos de webhook

Webhooks entregam eventos em tempo real para a URL do seu sistema. Esta página documenta cada evento, seu payload e como correlacionar eventos com suas chamadas de API. Para criar e gerenciar webhooks, consulte Configurar webhooks.

Quais eventos o seu número envia

O vocabulário de eventos depende de como o número foi conectado. Um número pareado por QR code fala o vocabulário canônico do Pilot Status, documentado nesta página; um número da Meta Cloud API encaminha o envelope nativo da própria Meta. São formatos diferentes, com nomes diferentes, e nenhuma configuração de assinatura converte um no outro.
Mudou em 17/09/2026 — números oficiais. Até essa data o message.sent e o message.failed chegavam a webhooks de número Meta Cloud API que tinham assinado o curinga "*", embora o envelope nativo já fosse o vocabulário documentado ali. Não chegam mais: nenhum evento canônico message.* é despachado em número oficial, qualquer que seja a causa.Leia o value.statuses[] do envelope nativo, ou consulte GET /v1/messages?status=FAILED — a linha da mensagem chega a FAILED nos dois tipos de conexão, inclusive para falha reportada pela própria Meta. Números não oficiais não mudam.
Três coisas são iguais em todos os vocabulários:
  • Ciclo de vida do número — number.created, number.connected, number.disconnected, number.removed e number.restored são assináveis em qualquer número, seja qual for o provedor. (O number.disconnected só dispara em número web nativo — veja number.disconnected.)
  • Eventos normalizados de chamada — call.ringing, call.connected, call.ended, call.missed em qualquer número com voz habilitada.
  • events nunca é opcional. Um webhook gravado com events vazio entrega nada, em qualquer vocabulário, e não avisa: é um 201 silencioso. Assine os nomes que você quer, ou o curinga "*".
Não sabe de que tipo é o seu número? O GET /v1/numbers devolve o provider de cada um: PILOT_STATUS para web nativo, META para Cloud API. Veja Oficial vs. não oficial.

Formato do payload

Todo evento chega como JSON:
Esquema v3 (camelCase). Todos os campos de data são camelCase. Números de telefone são sempre E.164 com + (sem sufixo de dispositivo, sem @s.whatsapp.net / @lid). Timestamps são sempre ISO 8601 no campo único createdAt (quando a mensagem aconteceu, segundo o provedor — não o horário em que a processamos).
Nomes de evento novos continuam surgindo — ignore os que você não conhece. Uma assinatura "*" recebe um evento novo assim que ele passa a existir (o number.restored é um deles). Responda com 2xx a um evento que o seu código não trata e siga em frente. A partir do @pilot-status/sdk 0.12.0 (Node) e do pilot-status 1.10.0 (Python), parseCustomerWebhook e parse_customer_webhook deixam de lançar erro para um nome de evento que não conhecem: devolvem { type: "unknown", event, data, raw } (em Python, um dict com as mesmas chaves), para que um evento novo nunca quebre a sua integração. Um payload que não é um objeto com event em texto, ou um evento conhecido com campos inválidos, continua lançando erro. O trigger do n8n já repassa qualquer evento quando All Events está selecionado. Veja SDKs e n8n.
Mensagem antiga não é replicada. Quando um número conecta, o WhatsApp entrega ao provedor o histórico do aparelho. Essas mensagens são importadas para o chat, mas não são entregues no seu webhook — senão cada reconexão re-entregaria até 30 dias de conversa e nada no payload permitiria distinguir isso do tráfego que acabou de chegar.Para receber mesmo assim, ligue settings.webhookHistoricalMessages por número em PATCH /v1/numbers/{id}. Para LER o histórico, use GET /v1/messages/history.

Eventos disponíveis

Saída (entrega/status): message.sent, message.delivered, message.read, message.failed Entrada (mensagens recebidas):
  • message.reply — resposta que cita outra mensagem
  • message.received — mensagem recebida no número conectado
message.reply é exclusivo — ele NÃO dispara também o message.received.Quando um contato responde citando uma mensagem anterior (deslizando para responder no WhatsApp), o evento é message.reply e somente ele. Se o seu webhook assina message.received e não assina message.reply, essas mensagens nunca chegam até você — e nada acusa erro, então parece que o cliente não escreveu.Assine os dois, ou use o curinga *, se quiser toda mensagem de entrada.
  • message.group — mensagem recebida em um grupo
  • message.newsletter — mensagem recebida em um canal (@newsletter)
  • message.stories — Status (story) publicado por um contato
Ciclo de vida do número:
  • number.created — número criado no Pilot Status
  • number.connected — conectado ao WhatsApp (estado OPEN)
  • number.disconnected — queda de conexão confirmada pela transição de saúde do número, em números web nativos (não oficiais) (um evento por queda confirmada, não um por oscilação de conectividade).
  • number.removed — número removido
  • number.restored — um número excluído foi restaurado dentro da janela de 30 dias; ele volta desconectado (veja number.restored)
  • number.recovered — número recuperado de um estado degradado/bloqueado
Eventos de saúde: os eventos de transição de saúde — number.health_blocked, number.health_degraded, number.health_shadowban —, number.recovered, number.inbound_stalled e number.inbound_recovered estão no seletor de eventos e são assináveis pelo nome em todos os provedores desde 15/09/2026; também chegam a webhooks assinados com o curinga "*". Como todo evento number.*, vão somente para os webhooks inscritos naquele número. Chamadas de voz (WhatsApp Business Calling, consulte Chamadas de Voz): Os eventos de chamada normalizados — call.ringing, call.connected, call.ended, call.missed — estão disponíveis em qualquer número com suporte a chamadas (web nativo (não oficial) — e Meta Cloud API). call.permission_updated e o envelope nativo calls são somente da Meta Cloud API.
  • call.ringing — chamada tocando (UIC de entrada ou transição BIC de saída)
  • call.connected — chamada atendida/conectada
  • call.ended — chamada finalizada (status: COMPLETED | FAILED | REJECTED; duration em segundos quando atendida)
  • call.missed — chamada de entrada encerrada sem ser atendida
  • call.permission_updated — (somente Meta Cloud API) resposta a uma solicitação de permissão de chamada (status: NO_PERMISSION | TEMPORARY | PERMANENT)
  • calls — (somente Meta Cloud API) envelope nativo calls da Meta (bruto entry[].changes[].value, carregando SDPs) para sinalização WebRTC personalizada
WhatsApp Flows (somente números Meta Cloud API, veja Flows):
  • flow.response_received — um cliente enviou um Flow, com as respostas dentro. Veja Payload de flow.*.

Identificadores e correlação

Todo evento de mensagem carrega dois IDs distintos:
  • messageId — ID da mensagem do WhatsApp/provedor (ex.: key.id). Exceção: num message.failed enviado pelo worker ou pelo limite do plano ele traz o id interno, e o corpo plano do reconciliador não tem messageId — veja message.failed abaixo.
  • id — ID interno da mensagem no Pilot Status. Mesmo valor do id no HTTP 202 de POST /v1/messages/send.
  • numberId — o ID público do número que tratou o evento. Em todo evento number.* ele agora é sempre o ID do número de WhatsApp (veja a mudança incompatível na família number.*).
  • correlationId — presente quando o evento se correlaciona a um envio anterior (mesmo valor do correlationId do 202).
  • quotedMessageId — em message.reply, o messageId da mensagem original citada (igual ao messageId do message.sent original).

Correlação com POST /v1/messages/send

Após um envio aceito, a API retorna HTTP 202 com id e correlationId: Em message.reply: quotedMessageId = o messageId do message.sent original; o próprio messageId da resposta é a nova mensagem de entrada. Use quotedMessageId (e correlationId quando presente) para associar a resposta ao seu envio anterior.

Payload de message.*

Campos comuns

⚠️ O message.failed é a exceção: duas das três formas de corpo dele não seguem esta tabela — veja message.failed abaixo. Semântica de direção de from/to:
  • Saída (sent/delivered/read/failed): to = número de destino (E.164); from = número próprio (presente quando resolvível de forma barata, caso contrário omitido); fromMe = true.
  • Entrada (received/reply): from = contato/remetente (E.164); to = número próprio (E.164); fromMe = false.
  • Grupo / canal (group/newsletter): from = participante (E.164); to = número próprio; mais groupId/groupName ou newsletterId/newsletterName.

message.sent

message.delivered

message.read

message.read só dispara quando o destinatário tem os recibos de leitura do WhatsApp ativados.

message.failed

O message.failed é enviado só para números não oficiais (web) — um número oficial reporta a falha no statuses[] nativo da Meta. O corpo dele não é uniforme: depende de onde a falha foi detectada, e fica como está de propósito, porque quem já assina faz o parsing dele. Chegam três formas. Nenhum dos códigos delas é o errorCode da API: para classificar uma falha, leia o errorCode de GET /v1/messages/{messageId} — Códigos de erro do webhook vs da API liga um ao outro. 1. Forma de status — error e errorCode. Enviada quando o provedor reporta a mensagem como falha (errorCode: "SEND_FAILED") e quando o limite de mensagens do plano barra um envio (errorCode: "PLAN_LIMIT_REACHED"). Segue os campos comuns acima; o messageId é o id do provedor.
A variante do limite do plano não tem data.event, internalMessageId nem whatsappNumberId, e o messageId dela é o id interno, porque nada chegou ao provedor. 2. Forma do worker — errorMessage, sem código. Enviada pelo worker de envio nas falhas dele: destino fora do WhatsApp, shadowban, conexão ausente, desconectada ou removida, card de pedido que falhou depois de o texto sair, a última tentativa com falha, ou um erro inesperado. O data traz messageId, internalMessageId, correlationId, environment, destinationNumber, content, status, failedAt e o motivo em errorMessage — sem error, errorCode, id, from, to, type, fromMe nem createdAt. Aqui o messageId é o id interno, o mesmo valor de internalMessageId. As duas falhas de conexão (nenhuma conexão conectada, nenhuma atribuída) não trazem internalMessageId nem correlationId; o caminho de erro inesperado acrescenta sourceNumber.
3. Forma plana — code no nível de cima. O reconciliador de mensagens, que falha uma mensagem cujo prazo de entrega venceu, e a checagem desse prazo que o worker faz antes de uma retentativa mandam um corpo sem event e sem data: os campos ficam no nível de cima. Nenhum cabeçalho nomeia o evento, então reconheça esta forma por status: "FAILED" com internalMessageId no nível de cima. O reconciliador manda code: NUMBER_BLOCKED quando o número estava bloqueado, DELIVER_UNTIL_EXPIRED nos outros casos. A checagem da retentativa não manda code e acrescenta messageId (o id interno).
As três formas também recebem o idempotencyKey de primeiro nível descrito em Deduplicar retentativas.

message.received

Exemplo de mídia. Em eventos de entrada (message.received / message.reply) com mídia, mediaLink é uma URL pública e durável para a mídia re-hospedada no armazenamento do Pilot Status — para todos os provedores (Evolution GO, Evolution v2 e Meta Cloud API), incluindo mídia criptografada de ponta a ponta do WhatsApp, que é descriptografada no servidor antes de re-hospedar. Os campos media* são omitidos em mensagens somente-texto.

message.reply

O novo texto do contato está em content; o texto da mensagem original citada está em contentReplied. Use quotedMessageId para corresponder ao messageId do seu message.sent de saída original.

message.group

message.newsletter

message.stories

Status (stories) do WhatsApp publicados por um contato que o telefone conectado enxerga.
Só é entregue enquanto alguém assina. O tráfego de Status vem desligado no provider por padrão (ignoreStatus); assinar um webhook em message.stories — ou no curinga "*" — é o que o liga para aquele número. Ao remover a última assinatura, o provider volta a não enviar Status.
Status não é persistido. Não cria conversa nem mensagem no seu histórico, e não aparece em GET /v1/messages. O WhatsApp expira stories em 24h; o mediaLink que re-hospedamos é durável, o story não.

Payload de number.*

Campos para number.created / number.connected / number.disconnected / number.removed / number.restored / number.recovered (e os eventos number.health_blocked / number.health_degraded / number.health_shadowban):
Mudança incompatível — data.numberId é sempre o ID do número. Em todo evento number.*, data.numberId agora é o ID do número de WhatsApp. Antes, number.created, number.connected e number.removed carregavam o ID interno da instância, enquanto os eventos de saúde já carregavam o ID do número: dois IDs opacos no mesmo campo, de modo que cruzar as duas famílias nunca casava — em silêncio. Se o seu consumidor casava os eventos de ciclo de vida pelo ID da instância, ele vai parar de casar — passe a usar o ID do número. É o mesmo id que o GET /v1/numbers devolve.

number.created

number.connected

number.disconnected

Emitido a partir da transição de saúde do número, que é quem controla a janela anti-oscilação e a deduplicação de um alerta por transição: você recebe um evento por queda confirmada, não um por oscilação do socket.
  • Números web nativos (não oficiais): o evento dispara em uma queda de conexão confirmada. Antes, um número não oficial que caía não produzia nenhum evento assinável — apenas os eventos de saúde, exclusivos do curinga "*".
  • Números da Meta Cloud API: nunca emitem number.disconnected. Uma transição de qualidade/status da Meta emite apenas o evento de saúde correspondente (number.health_blocked / number.health_degraded / number.health_shadowban), que chega aos webhooks que o assinam pelo nome ou com "*".
error e errorCode vêm incluídos quando a transição de saúde carrega um motivo.

number.removed

number.restored

O par do number.removed: um número excluído foi trazido de volta dentro da janela de 30 dias.
  • Dispara em todo caminho de restauração: o painel, o POST /v1/numbers/{id}/restore e a reconexão do mesmo telefone na Meta (Embedded Signup ou credenciais da Cloud API) enquanto o número excluído ainda pode ser restaurado. Essa reconexão traz o número antigo de volta, então o number.restored é seguido de number.created / number.connected.
  • Restaura o registro, não a conexão: o número volta desconectado e precisa ser reconectado antes de poder enviar.
  • Mesmo payload e mesmo escopo do number.removed: o mesmo numberId (o id que o GET /v1/numbers devolve), entregue só aos webhooks inscritos naquele número, inclusive assinaturas "*". Assinável pelo nome em qualquer provedor.
  • Se a sua integração deixa de aceitar um número ao receber number.removed, volte a aceitá-lo ao receber number.restored. Caso contrário, você continua recusando os eventos dele depois da restauração.

Payload de call.*

Diferente de message.* / number.*, os eventos normalizados call.* são planos (sem o wrapper data):
Consulte o fluxo completo de chamadas.

Payload de flow.*

O flow.response_received dispara quando um cliente envia um Flow. Usa o envelope canônico { event, data } mesmo em número Meta, e está disponível apenas em números Meta Cloud API, porque Flows só existem lá.
Flow simples — sem endpoint, respostas no response
Flow data_exchange — respostas no submitted, anexo ainda em transferência
Num Flow data_exchange, o data.response é o lugar vazio. Ele é o nfm_reply da Meta e, quando o Flow é servido por um endpoint, essa resposta carrega o token e mais nada — medido três vezes em produção como {"flow_token":"2kziUbM0UDzp4Dq2WKrI26PGxRSSodn7WNHa8FnisJM"}, enquanto a mesma submissão já tinha entregue ao endpoint do próprio cliente {"observacao":"Aobaaa","foto":[…]}. O que a pessoa digitou está em data.submitted; um handler que vá ao response atrás disso reporta formulário vazio em todo Flow que você ligar a uma API.Num Flow simples — sem endpoint — os componentes de texto livre chegam sim no response. Ali nada intercepta as telas, então não há o que capturar no caminho e o submitted vem null. Quem decide onde estão as respostas é o Flow que você enviou, não o payload: leia o submitted num Flow data_exchange, e o response num Flow simples. Veja Conectar a minha API a um Flow.
O submitted é a lista das trocas data_exchange, da mais antiga para a mais nova. Cada item tem screen (uma string, ou null), at (ISO 8601), data (o objeto já parseado, ou null) e omitted. O campo é null — nunca [] — tanto quando não há nada para mostrar quanto quando o conteúdo foi ocultado, porque [] se leria como “houve respostas e nós as escondemos”.Só as trocas data_exchange são capturadas. Um INIT devolve o estado que nós semeamos no envio do Flow, e um BACK é navegação; nenhum dos dois é algo que a pessoa respondeu, então nenhum dos dois é guardado. A lista é, portanto, legitimamente mais curta que os traces do seu endpoint para a mesma sessão — um INIT que não aparece nela não é troca perdida.Ele captura a requisição que a Meta manda para nós, nunca o que o seu endpoint respondeu. O extension_message_response que a sua API devolve para encerrar o Flow continua não aparecendo em lado nenhum da Pilot Status, e este campo não muda isso — veja Conectar a minha API a um Flow.É append-only, e não há deduplicação. Quem volta uma tela e envia de novo gera um segundo item, genuíno, em vez de sobrescrever o primeiro; é o at que separa duas passagens sob o mesmo token.
Nada é truncado em silêncio — um item que não pôde guardar o conteúdo diz isso, no lugar dele. Ou o item tem data com omitted: null, ou tem data: null e o motivo em omitted. Não existe um terceiro formato, e o omitted é exatamente um de três valores.O teto é contado em bytes, não em caracteres — uma resposta escrita com acentos, ou com emoji, chega nele antes do que o tamanho na tela sugere.
As trocas ficam guardadas por 30 dias + 1, e o dia a mais não é enfeite. Uma troca é capturada enquanto a pessoa ainda está preenchendo o formulário — minutos antes da submissão que cria a linha da resposta. Com 30 dias iguais dos dois lados, as trocas expirariam primeiro, e uma resposta ainda dentro da própria janela publicaria lista vazia nos seus últimos minutos.
O flowMedia já traz URL na primeira entrega, inclusive enquanto o arquivo ainda está a caminho de nós. Cada item tem field (o nome que o autor do Flow deu ao componente — não é palavra reservada nem tipo), fileName, mimeType, status e url. Os bytes ficam no CDN da Meta e são re-hospedados por uma fila: o evento é emitido assim que a submissão é gravada, e essa transferência roda depois dele, então a submissão normalmente é anunciada com o arquivo ainda a caminho. É essa ordem que justifica o link, e não uma ressalva para contornar: o url está lá também no PENDING.Busque o link e ele espera pelo arquivo. A rota segura a requisição enquanto a re-hospedagem termina, e então entrega o arquivo; toda busca posterior é um redirect. Uma espera que estoura responde 404 com o cabeçalho Retry-After, e o mesmo link continua funcionando depois disso. O valor não muda quando os bytes chegam, então dá para guardá-lo já na chegada — o status (PENDING / STORED / FAILED) continua sendo o que diz se eles chegaram. Trate o link como opaco: busque, nunca monte um. Ele vem null em três casos, e “ainda transferindo” não é nenhum deles — a instalação sem segredo de assinatura, que cai na URL direta do objeto assim que o status for STORED; a Meta sem media_id para assinarmos uma identidade em cima; ou um número RELAY_ONLY, onde nada foi guardado.null quer dizer quatro coisas diferentes, e só uma delas é “não houve anexo”. Ele também é o que você recebe quando a Meta não devolveu flow_token junto da submissão; quando a própria consulta dos anexos falhou, que é best-effort e custa um campo em vez do evento; e antes de o job de re-hospedagem começar, porque a linha que traria PENDING só é escrita quando a transferência de fato começa, pelo backend que a executa, e não no instante em que o arquivo entra na fila — até lá não há nada para listar. Ou seja: null não é prova de que nenhum arquivo foi enviado. Um Flow que coleta arquivos se relê em GET /v1/flows/{id}/responses, nunca se conclui deste campo.O campo é nunca []. E o evento não leva cdn_url nem encryption_metadata: a Meta deriva uma chave AES por arquivo, e repassá-la colocaria a chave que abre o documento do cliente dentro do seu webhook, e dos seus logs.
O lado message.* de uma submissão não carrega resposta nenhuma, e isso é deliberado. A Meta entrega um Flow enviado como resposta interativa do tipo nfm_reply, cujo body legível é um rótulo seco — Enviado / Sent. É esse rótulo que cai em content, em chat_messages.text e no GET /v1/messages; as respostas nunca vão para lá, porque content tem a promessa de ser texto de mensagem e um blob JSON ali quebraria todo consumidor que renderiza ou casa em cima dele.Assine o flow.response_received — um webhook cuja lista de events contém só messages recebe o rótulo e mais nada, sem erro em lugar nenhum. Veja Receber as respostas de um Flow.
Aqui o campo do número é whatsappNumberId; em todos os outros eventos ele é numberId. O valor é o mesmo — o id que GET /v1/numbers devolve — e só o nome muda, mas ele muda no fio: um handler que leia data.numberId neste evento recebe undefined, em silêncio, e manda as respostas para lugar nenhum. O nome vem copiado de quem emite, não escolhido nesta fronteira, e é por isso que ele não foi simplesmente renomeado para casar com os outros.
Ou response, ou responseRaw, nunca os dois. A Meta manda o corpo do nfm_reply como string JSON. Quando parseia, você recebe response; quando não parseia, recebe responseRaw com os bytes como chegaram. A submissão nunca é descartada por não parsear, então confira qual dos dois veio — e lembre que, num Flow data_exchange, o que está ali é o flow_token, não as respostas.
Uma opção escolhida chega como o id, não como o rótulo que a pessoa leu. RadioButtonsGroup, Dropdown e ChipsSelector declaram as opções no Flow JSON num data-source de pares id + title. Diante de [{"id":"5","title":"Otimo"}, {"id":"3","title":"Razoavel"}, {"id":"1","title":"Ruim"}], quem toca em Otimo envia {"nota":"5","comentario":"AOBA","flow_token":"..."}. A tela “Sua resposta” do WhatsApp mostra Otimo porque o aparelho ainda tem o Flow JSON; o seu endpoint recebe 5, e nada no payload diz o que 5 significava.Resolver o rótulo depois pode te devolver o rótulo errado: o id é estável, o title não é, então um Flow republicado com {"id":"5","title":"Excelente"} faz uma busca posterior nomear uma palavra que o cliente nunca viu. Capture o rótulo no momento em que a submissão chega e guarde-o ao lado do id. Veja Receber as respostas de um Flow.
Sem respostas nenhuma, não sai evento. Uma submissão que não carrega nada não dispara — um corpo vazio seria indistinguível, do seu lado, de um formulário enviado em branco.O evento também só dispara para uma submissão que foi gravada. Uma reentrega da Meta de algo já guardado é silenciosa, que é a deduplicação que você quer; e também é silenciosa uma segunda submissão, distinta, que colidiu no mesmo literal de flow_token — o único caso em que o silêncio custa uma resposta. Tokens criados pelo Pilot Status nunca colidem; um Flow montado no Flow Manager da Meta com flow_token fixo pode.
Num número configurado para não guardar conteúdo do cliente, a entrega é completa e a cópia guardada não é. O seu endpoint recebe o corpo inteiro; no payload guardado no log do webhook o response, o responseRaw, o submitted e o flowMedia viram null, e o from vira a string marcadora conteúdo indisponível — uma string, não um null, então não teste por um. Um registro que parece vazio ao lado de um 200 é essa ocultação, não uma entrega falhada.Essa cópia ocultada é o que uma entrega BEM-SUCEDIDA guarda. Uma entrega que falhou guarda o payload real, para o operador poder inspecionar e reenviar o que de fato saiu; uma varredura substitui o payload dessas linhas — inteiro, por um objeto marcador, não campo a campo — quando elas passam de 24 horas. Dentro dessa janela o GET /v1/webhooks/{id}/logs pode devolver a submissão por inteiro num número cuja configuração diz que ele não guarda nada.Num número desses os anexos nunca são re-hospedados, então flowMedia vem null também na entrega — não existe cópia guardada para apontar, e a referência fica onde estava. E nem uma troca é capturada: o submitted também vem null na entrega, e não como linha ocultada — linha nenhuma. É a configuração do número fazendo o que ela diz, não uma falha.

Eventos da Meta Cloud API (envelope nativo)

Um número conectado pela Meta Cloud API — Embedded Signup ou credenciais coladas — não envia os eventos canônicos acima. Ele encaminha o próprio envelope de webhook da Meta, literal, com a chave no nome do campo que a Meta põe em entry[].changes[].field.
message.received nunca dispara em número Meta, e events: ["*"] não converte o formato. O nome do evento que você assina é o nome do campo da Meta — messages — e o corpo que chega é o envelope da Meta. Um receptor escrito para o esquema canônico ({ event, data }) lê undefined num payload Meta e não reporta nada, o que parece exatamente com “o cliente nunca escreveu”. Escreva o parser para o formato nativo, ou ramifique pelo provider do número.
Envelope nativo (como é entregue)
O entry[].changes[].value é o payload da Meta para aquele campo, sem modificação — o mesmo formato que a referência da própria Meta documenta. Nada é acrescentado e nada é removido, então um receptor já escrito para os webhooks da Meta continua funcionando. Uma mudança por entrega. A Meta pode agrupar várias mudanças num mesmo entry; cada uma é casada com as assinaturas e entregue sozinha, então entry e entry[0].changes têm sempre exatamente um elemento. Mantenha os laços que o seu parser da Meta já tem — eles simplesmente sempre rodarão uma vez — e nunca assuma que duas mudanças na mesma requisição têm relação entre si.

Campos assináveis

Estes são os nomes de campo que você pode colocar em events no POST /v1/webhooks de um número Meta. São também os nomes que chegam como evento entregue. Mais as famílias que não são o envelope da Meta:
  • Respostas de Flow — flow.response_received, só em números Meta, no formato canônico { event, data }. Veja Payload de flow.*.
  • Eventos normalizados de chamada — call.ringing, call.connected, call.ended, call.missed e call.permission_updated (só Meta). Esses são eventos planos do Pilot Status, não o envelope da Meta — veja Payload de call.*.
  • Ciclo de vida do número — number.created, number.connected, number.removed, number.restored. O number.disconnected é assinável mas nunca dispara em número Meta: uma transição de qualidade/status da Meta emite um evento de saúde do número, e esses só chegam em assinaturas "*".
Nove campos da Meta nunca são entregues, nem para uma assinatura "*": account_alerts, automatic_events, history, messaging_handovers, partner_solutions, security, smb_app_state_sync, standby e tracking_events. São internos (backfill de histórico em massa, sincronia de estado da coexistência) ou tráfego administrativo/de segurança, e também não aparecem no seletor de eventos. Todo o resto que a Meta manda de um campo assinado é encaminhado.
Um nome de evento que o provedor do número não emite é descartado em silêncio, não rejeitado. POST/PATCH filtram events para o conjunto assinável do provedor antes de gravar — então assinar message.received num número Meta devolve 201, grava lista vazia e não entrega nada. Releia os events do webhook criado e compare com o que você mandou.

Saúde do número — assinável desde 15/09/2026

Um número Meta nunca emite number.disconnected. O que ele emite é a família de saúde do número, e esses nomes estão agora no seletor de eventos — assine-os pelo nome, como qualquer outro:
Até 15/09/2026 eles só chegavam por "*". O despachante sempre os casou, mas eles não apareciam no seletor — então um webhook assinado exatamente no que queria não recebia sinal de saúde nenhum, e nada dizia isso. Se você pegou "*" só por causa deles, já pode estreitar a assinatura.
Eles usam a forma canônica { event, data } (data: numberId, phone, displayName, createdAt, e error / errorCode quando existirem) — não o envelope da Meta. Ou seja: uma assinatura que misture estes com campos da Meta entrega dois formatos de corpo na mesma URL: { object, entry } da Meta para os campos assinados, e { event, data } para estes mais call.*, flow.response_received e number.*. Ramifique pela presença de entry antes de fazer o parse.
Se você quiser o tráfego de conversa e o de saúde em endpoints diferentes, crie dois webhooks no número: um com os campos Meta que precisa, outro com "*". Veja quantos webhooks por número.

Ecos: o que o negócio enviou por fora

message_echoes e smb_message_echoes carregam uma cópia de mensagem enviada pelo negócio — o primeiro para envios feitos por qualquer outra ferramenta na mesma WABA, o segundo para envios feitos pelo app WhatsApp Business no celular (coexistência). Num número em coexistência, smb_message_echoes é como a resposta digitada pelo atendente no aparelho chega ao seu sistema. A mensagem ecoada tem a mesma forma por tipo de uma mensagem recebida (text / image / video / audio / document / sticker / location / contacts / reaction), mais o destinatário:
A chave do array não é estável. A Meta põe as mensagens ecoadas em value.message_echoes em alguns payloads e reaproveita value.messages em outros. Leia as duas:
Um parser que lê só uma delas perde metade dos ecos sem erro em lugar nenhum.

statuses[] — é aqui que está a categoria cobrada

No campo messages, os status de entrega vêm em value.statuses, e cada um carrega o veredito de cobrança da própria Meta. Esta é a resposta autoritativa para “o que a Meta cobrou por esta mensagem” — não precisa ser inferida pelo template.
As combinações efetivamente observadas em produção (levantadas em 30 dias de entregas):
conversation.id e conversation.expiration_timestamp são também a forma honesta de responder “um segundo clique no anúncio abriu janela nova ou estendeu a antiga?” — compare os ids em vez de supor. O pricing.type então diz se a Meta cobrou.

Ligar um status ao seu envio — biz_opaque_callback_data

Uma mensagem enviada de um número oficial com POST /v1/messages/send vai para a Meta com o id do HTTP 202 em biz_opaque_callback_data, e a Meta o devolve em cada item de statuses[] dessa mensagem. Isso liga o seu pedido ao status da Meta diretamente: sem consulta e sem esperar o wamid.
  • É o id, nunca o correlationId. O correlationId é um valor seu e não precisa ser único. O id é único.
  • Observado em sent, delivered e read, tanto em template quanto em texto livre.
  • Só em mensagens enviadas pela Pilot Status a partir de 17/09/2026, 14h23 (horário de Brasília). A chave fica ausente, e não vazia, nos status de mensagens enviadas antes dessa data, direto pela Graph API ou pelo app WhatsApp Business (veja ecos).
  • Se o campo faltar, use o wamid. GET /v1/messages/{messageId} aceita o wamid (statuses[].id) e devolve o id e o correlationId da mensagem.

Referral de CTWA — onde cai o clique no anúncio

Não existe um campo referral separado para assinar. O bloco chega dentro do envelope de messages, em entry[0].changes[0].value.messages[0].referral, verbatim.
referral vem só na PRIMEIRA mensagem da conversa. A Meta não o repete nas seguintes, então quem lê por mensagem vê uma vez e tem de carregar o valor adiante por conta própria.
  • source_id é o id do anúncio (source_type: "ad") ou do post (source_type: "post"). Trate-o como chave opaca de agrupamento: resolvê-lo para nome de campanha/anúncio pela Graph exige ads_read na conta de anúncios do anunciante, o que um token de system user do WhatsApp não carrega (volta GraphMethodException code 100 / subcode 33).
  • Não existe UTM num referral da Meta. Se você precisa dos nomes de campanha, use GET /v1/referrals — devolve a linha na hora, com os nomes resolvidos de forma assíncrona (namesResolvedAt: null significa “ainda não resolvido, pergunte de novo”).
A Meta pode entregar o MESMO wamid duas vezes, com conteúdos diferentes. Medido em produção: primeiro chega um placeholder — type: "unsupported", errors[].code 131060 “This message is unavailable.”, sem texto e sem referral — e cerca de 425 ms depois vem a mensagem real, com o texto, a mídia e o referral.As duas são entregues ao seu webhook. Deduplicar só pelo wamid descarta a real e deixa você com um balão vazio e sem atribuição do anúncio. Chaveie por (wamid, type), e trate uma chegada unsupported + 131060 como placeholder que nunca pode sobrescrever conteúdo já gravado.

Eventos da camada de migração (Evolution GO e v2)

Um número que você trouxe por uma camada de compatibilidade mantém o vocabulário de eventos do próprio provedor — o objetivo da camada é justamente os seus handlers não mudarem:
  • Evolution GO — PascalCase nativo (Message, SendMessage, Receipt, Presence, …), com os apelidos UPPER_SNAKE também aceitos. Lista completa na página da camada Evolution GO.
  • Evolution v2 — nomes nativos com ponto (messages.upsert, messages.update, connection.update, …). Lista completa na página da camada Evolution V2.
Os dois também carregam as famílias normalizadas call.* e number.* descritas acima.
Eventos de grupo e canal nesses dois exigem plano pago: groups.upsert, groups.update, group-participants.update (v2) e GroupInfo, JoinedGroup, NewsletterJoin, NewsletterLeave (GO) ficam escondidos do seletor de eventos numa conta gratuita. Os canônicos message.group e message.newsletter não têm essa restrição.

Notas importantes

  • message.stories: from é o contato que publicou o story. Não há to — um Status é difundido para a lista de contatos de quem publica, não endereçado ao seu número.
  • message.newsletter: newsletterName é o nome de exibição do canal quando disponível; caso contrário, pode ser omitido. O identificador do canal é newsletterId (JID completo ...@newsletter). participantName é o autor da mensagem no canal.
  • Mídia: em eventos de entrada com mídia, mediaLink é uma URL pública e durável para a mídia re-hospedada — para todos os provedores (Evolution GO, Evolution v2, Meta Cloud API), incluindo mídia criptografada de ponta a ponta descriptografada no servidor. mediaType é a categoria (image/video/audio/document/sticker), mediaCaption o texto que acompanha, mediaFilename o nome do arquivo original (documentos). Os quatro são omitidos em mensagens somente-texto e quando a redação de PII do número apaga o conteúdo.
  • Eventos number.* não são correlacionados a POST /v1/messages/send; use-os para provisionamento/monitoramento. Em todos eles, data.numberId é sempre o ID do número de WhatsApp — veja a mudança incompatível.
  • number.disconnected é emitido a partir da transição de saúde do número (um evento por queda confirmada, não um por oscilação de conexão), e somente para números web nativos (não oficiais); números da Meta Cloud API sinalizam problemas pelos eventos de saúde.
  • A entrega de campos sensíveis pode depender da configuração de retenção. Com a retenção desligada, campos condicionais como content podem ficar vazios; IDs e timestamps continuam existindo.
  • O evento message.read (e o status Read na API/Logs) só ocorre quando o destinatário tem os recibos de leitura do WhatsApp ativados.