Skip to content
WhatIsUp.dev

Payloads de eventos

Todo corpo de webhook tem o mesmo envelope:

{
  "api_version": "v1",
  "event_id": "9bc38acc-2edc-482e-93d4-0482b432ffa6",
  "event": "message.received",
  "customer_id": "2696bc9b-2e37-43d9-a33b-5a79f81daeba",
  "channel_id": "8d653c66-e4ff-43ee-97da-3de5ad5680d4",
  "created_at": "2026-05-01T12:34:56.000Z",
  "data": { /* formato específico do evento, veja abaixo */ }
}

Os campos do envelope:

CampoTipoNotas
api_versionstringHoje é sempre "v1". Compare exatamente com isso se você validar a versão.
event_idUUID v4Estável entre retentativas da mesma entrega. Use como sua chave de deduplicação.
eventstringO nome do evento. Veja a lista abaixo.
customer_idUUIDO id da sua conta. Sempre presente.
channel_idUUIDO canal que produziu o evento. Sempre presente.
created_atISO-8601 UTCHorário do relógio no gateway quando o evento foi emitido.
dataobjetoPayload específico do evento, schemas abaixo.

Os cinco campos escalares são obrigatórios — nenhum é nulo. data é obrigatório e sempre um objeto; o formato dele é decidido por event.

Não existe campo occurred_at, e os ids são UUID, não ULID — revisões anteriores desta página documentavam os dois. Um receptor escrito a partir daquilo lê undefined no timestamp.

Tipos de evento

Todos os 27 eventos. Assinar um nome fora desta lista é rejeitado na criação do endpoint com 400 invalid_request.

Mensagens

  • message.received — Mensagem recebida de um contato. Carrega from, to, chat_id, timestamp, is_group, body, e opcionalmente push_name e quoted (quando responde a outra mensagem). Veja a nota sobre resolução de LID abaixo.
  • message.sent — Sua mensagem de saída chegou ao WhatsApp. Carrega message_id, to, chat_id, timestamp, body, o seu client_ref e opcionalmente quoted. Também dispara para mensagens digitadas no celular do negócio, então a resposta citada de um atendente aparece aqui.
  • message.status — Um tique de entrega para uma mensagem que você enviou: sent → delivered → read/played, ou failed. Veja abaixo.
  • message.reaction — Alguém reagiu numa conversa em que seu canal está. Carrega message_key, reactor, emoji, timestamp (epoch ms), e os atalhos message_id (a mensagem reagida), from, chat_id, is_group, from_me. Um emoji vazio significa reação removida.
  • message.edited — Quem enviou editou uma mensagem anterior. Carrega message_id (a mensagem original), chat_id, from, from_me, is_group, timestamp e text — o texto novo (ou a legenda nova).
  • message.deleted — Quem enviou (ou um admin do grupo) apagou uma mensagem anterior para todos. Mesmos campos de message.edited, sem text.

Ciclo de vida do canal

  • channel.status — Toda transição de ciclo de vida, num evento só. Carrega status, previous_status, changed_at, simple (qr / connected / logged_out / stopped), live e can_send. Se você conduz o pareamento sem interface, assine este e ignore os três abaixo. simple é sinal de pareamento, não de saúde — um canal disconnected reporta simple: "connected" de propósito, porque as credenciais seguem boas e o socket volta sozinho. Leia live para "o socket está de pé agora" e can_send para "um envio vai ser aceito". Alerta de atenção: quando um canal fica offline por mais de 15 minutos ou não consegue se reconectar, você também recebe um channel.status com needs_attention: true e attention_reason (offline_too_long / reconnect_loop). Nada mudou de estado nessa entrega (status igual a previous_status); ela chega no máximo uma vez por canal a cada 6 horas, e os usuários da conta recebem o mesmo alerta por email. Os dois campos não aparecem nas transições normais.
  • channel.connected — O canal terminou o pareamento. Carrega phone_number, connected_at.
  • channel.disconnected — A sessão terminou. Carrega reason, will_reconnect, disconnected_at. reason é um de logged_out, connection_replaced, restart_required, timed_out, qr_expired, connection_lost, proxy_error — faça o branch por ele; valores novos podem aparecer. proxy_error significa que o proxy do próprio canal falhou; o canal segue tentando por ele (nunca direto) e GET /v1/channels/{id}/status → last_error diz o motivo.
  • channel.health — O nível de saúde do número mudou: level (good / warning / danger / restricted) e previous_level, com reasons, new_contacts_7d, reply_rate_7d, age_days e o que o auto_protect está fazendo — mesmo formato de GET /v1/channels/{id}/health. A primeira nota de um número saudável não dispara. Os usuários da conta também recebem email quando um número vira danger ou restricted (uma vez por número e nível a cada 24 horas).
  • channel.limits — Os limites do próprio WhatsApp para o número mudaram: o status da cota de conversas novas (none → first_warning → second_warning → capped) ou uma restriction temporária de mandar mensagem pra gente nova (aplicada, com ends_at, ou retirada). Carrega new_chats (status, used, quota, cycle_ends_at), restriction (active, ends_at, reason) e checked_at; mesmo formato de GET /v1/channels/{id}/limits. Dispara só em mudanças, não a cada conversa contada. Trate um aviso como "desacelera este número agora": mande as conversas novas dele pra outro número até o status voltar a none.
  • qr.updated — Um novo QR está disponível. Carrega qr_png_base64 (base64 pura) e expires_at (epoch ms).

Grupos

  • group.created — Carrega group_id, by, timestamp, subject, participants.
  • group.updated — Metadados do grupo mudaram.
  • group.participant_added / group.participant_removed — Composição mudou.
  • group.admin_promoted / group.admin_demoted — Direitos de admin mudaram.

Os seis carregam group_id, o ator by (pode ser nulo), e timestamp (epoch ms). group.updated acrescenta field (subject / description / announce / restrict / ephemeral) e o novo value; os outros cinco acrescentam participants[] (JIDs de telefone quando conhecidos). group.created dispara quando um grupo é criado com o seu número dentro.

Chats — ainda não disponível (reservado)

  • chat.updated — Estado do chat mudou (mute, não-lidas, …).
  • chat.archived — Chat arquivado ou desarquivado.
  • chat.pinned — Chat fixado ou desafixado.

Comércio (apenas contas WhatsApp Business) — ainda não disponível (reservado)

  • order.placed / order.cancelled — Um pedido de entrada mudou de estado.
  • cart.updated — O carrinho de um cliente mudou.

Outros

  • story.viewed — Alguém viu um story que você publicou. Ainda não disponível (reservado).
  • call.offered — Uma chamada está tocando. Carrega call_id, from (devolva como from para recusar a chamada), chat_id, is_group, kind (audio / video), started_at (epoch ms) e, quando quem liga é um LID, from_phone.
  • call.terminated — A chamada terminou. Carrega call_id, ended_at (epoch ms) e status: accepted (atendida, já encerrada), rejected, missed (tocou sem resposta) ou terminated (quem ligou desligou, sem atendimento visto). Dispara uma vez por chamada.
  • presence.updated — A presença de um contato mudou. Carrega chat_id, contact_id, presence (available / unavailable / composing / recording / paused) e last_seen (segundos Unix, ou null quando a privacidade esconde).
  • contact.resolved — Dispara na primeira vez que descobrimos o JID de telefone por trás de um contato antes conhecido apenas por LID. Veja abaixo.

Eventos reservados ainda não disparam: chat.updated, chat.archived, chat.pinned, order.placed, order.cancelled, cart.updated, story.viewed. Assinar é aceito (endpoints existentes continuam válidos), mas nada é entregue até eles serem lançados. presence.updated só dispara para chats que você assinou pelo endpoint de presença.

Não existe evento message.delivered nem message.failed. Os dois são frames message.status discriminados pelo campo status. Revisões anteriores desta documentação listavam os dois como eventos separados — assinar aqueles nomes falha na validação.

Payload de message.status

Acompanha os tiques de uma mensagem que você enviou — use para montar um indicador de entregue/lido ou uma visão de "aguardando resposta".

{
  "message_id": "3EB0C9A17F2B4D8E1A05F3D77C10B4E2A991",
  "to": "558585218491@s.whatsapp.net",
  "status": "read",
  "is_group": false,
  "timestamp": 1779425257573
}
CampoNotas
statusUm de sent (chegou ao WhatsApp), delivered (chegou ao dispositivo, ✓✓), read (conversa aberta, ✓✓ azul), played (áudio/vídeo reproduzido), failed (não entregue).
participantEm grupos, o membro a quem este ack pertence — cada membro confirma de forma independente. Omitido em conversas 1:1.
reasonSó em frames failed que o próprio gateway gerou — um código estável: undelivered_after_retries (entregue ao WhatsApp, mas sem confirmação depois de vários reenvios), expired_in_queue (o canal ficou offline tempo demais; nunca foi enviada), channel_unavailable (o canal foi desconectado, parado, desativado ou apagado enquanto a mensagem esperava), recipient_not_on_whatsapp (o número não recebe mensagens de WhatsApp) e, num envio de mídia que ficou na fila e cujo media_url não deu pra usar, os mesmos códigos que o envio direto devolve: media_fetch_blocked (endereço privado ou interno), media_too_large (passou do limite de tamanho), media_fetch_failed (não deu pra baixar). Ausente quando a falha veio do próprio WhatsApp. Novos códigos podem aparecer — mantenha um caso padrão.

O mesmo message_id dispara várias vezes conforme o status avança. read só chega quando o destinatário tem as confirmações de leitura ativadas — sua ausência não prova que a mensagem está não lida.

Tipos de body da mensagem

message.received e message.sent trazem um body discriminado por kind. Novos tipos podem aparecer dentro da v1 — ignore os tipos que você não trata em vez de falhar.

kindCampos
textbody. interactive opcional quando o texto veio de uma mensagem formatada por empresa ou de um toque nela — veja abaixo.
image · audio · document · stickermime_type, media_url, e opcionais size, caption, filename, sha256, media_error.
videoIgual à mídia, mais ptv: true para uma nota de vídeo redonda.
contactscontacts[] — display_name, phones[] (phone, wa_jid), vcard cru.
locationlatitude, longitude, e opcionais name, address, url. Localização em tempo real vem com live: true (e caption opcional); só a primeira posição é entregue.
pollname, options[] (strings), selectable_count opcional (0 = quantas quiser). Os votos são criptografados de ponta a ponta para quem criou a enquete e não são entregues.

Botões, listas e templates. Chegam como kind: "text" com o texto que uma pessoa leria, mais um objeto interactive:

{
  "kind": "text",
  "body": "Sim, confirmar",
  "interactive": { "type": "buttons_response", "selection_id": "btn_confirm" }
}
interactive.typeO que aconteceu
template · buttons · list · interactiveUma empresa te mandou um template / botões / lista / mensagem interativa; body é o texto principal.
buttons_response · list_response · template_button_responseO contato tocou num botão ou escolheu uma linha da lista; body é o rótulo, selection_id o id que o seu app deu a ele.
interactive_responseO contato respondeu uma mensagem interativa (flow). params_json é o JSON cru devolvido; selection_id é o campo id dele, quando existe.

Roteie por selection_id, não pelo rótulo — rótulos são traduzidos e editáveis.

Reações, edições e exclusões

Apontam para uma mensagem anterior pelo message_id; nunca criam uma mensagem própria e nunca disparam message.received.

{
  "message_key": { "id": "3EB0AA11BB22CC33DD44", "from_me": true, "remote_jid": "558585218491@s.whatsapp.net" },
  "reactor": "558585218491@s.whatsapp.net",
  "emoji": "👍",
  "timestamp": 1779425257573,
  "message_id": "3EB0AA11BB22CC33DD44",
  "from": "558585218491@s.whatsapp.net",
  "chat_id": "558585218491@s.whatsapp.net",
  "is_group": false,
  "from_me": false
}

message_key.from_me diz se a mensagem reagida era sua; o from_me do topo diz se a reação veio do seu próprio número. Perto de reconexões a mesma reação, edição ou exclusão pode chegar duas vezes — as três são idempotentes (último emoji / último texto / apagada), então aplique como "definir", não "alternar".

Respostas citadas (quoted)

Tanto message.received quanto message.sent carregam um objeto quoted opcional, presente somente quando aquela mensagem responde a outra (o "arrastar para responder" do WhatsApp). Sem resposta, sem campo — teste a presença dele:

{
  "message_id": "3EB0C9A17F2B4D8E1A05F3D77C10B4E2A991",
  "from": "558585218491@s.whatsapp.net",
  "to": "5585988887777@s.whatsapp.net",
  "chat_id": "558585218491@s.whatsapp.net",
  "timestamp": 1779425257573,
  "is_group": false,
  "body": { "kind": "text", "body": "e no sábado?" },
  "quoted": {
    "message_id": "3EB0AA11BB22CC33DD44EE55FF66007788AA",
    "from_me": true
  }
}
CampoObservações
quoted.message_idId da mensagem citada. Devolva-o em reply_to.message_id para responder na mesma thread.
quoted.from_metrue quando o número do próprio canal escreveu a mensagem citada (uma resposta anterior do bot, ou algo digitado no celular do negócio); false quando foi o contato.
quoted.participantJID de quem escreveu a mensagem citada. Faz sentido em grupos; o WhatsApp às vezes omite.

Três casos produzem o campo, e os três importam:

  1. O contato responde você — message.received, quoted.from_me: true.
  2. O contato cita a própria mensagem anterior — message.received, quoted.from_me: false.
  3. Alguém da equipe responde pelo celular do negócio — sai como message.sent (é saída), ainda com quoted. É assim que a integração percebe que a thread foi respondida por um humano fora do bot.

A mensagem citada não vem embutida — só o id dela. Busque no seu próprio armazenamento, ou em GET /v1/channels/:id/messages.

Mídia recebida (media_url, media_error)

Mensagens de imagem, áudio, vídeo, documento e sticker trazem o arquivo em body:

"body": {
  "kind": "image",
  "mime_type": "image/jpeg",
  "size": 48213,
  "media_url": "https://api.whatisup.dev/v1/media/eyJ…",
  "caption": "olha"
}

media_url é um link público assinado — o token nele é a autorização, então busque sem Bearer key. Ele expira junto com a janela de retenção de mídia do gateway (depois disso responde 410 media_gone); dentro da janela dá pra buscar os bytes de novo com GET /v1/channels/:id/media/by-message/:messageId.

Mídia que não conseguimos baixar. A mensagem é entregue mesmo assim — remetente, legenda, nome do arquivo e tamanho intactos — mas media_url é o placeholder inerte "about:blank" e um campo extra media_error diz o porquê:

media_errorSignificado
download_failedBuscar o arquivo no WhatsApp falhou ou estourou o tempo.
too_largeO arquivo é maior que o limite de mídia recebida do gateway (64 MB), então nunca foi baixado.

media_error não aparece quando o arquivo está disponível. media_url continua sendo uma string por compatibilidade — decida pelo media_error, nunca busque about:blank. A API de histórico (GET /v1/channels/:id/messages) devolve media_url: null para essas mensagens.

Resolução de LID (Linked-Device ID)

O protocolo multi-device do WhatsApp expõe contatos a sessões linkadas como Linked-Device IDs (<digits>@lid) em vez de JIDs de telefone (<digits>@s.whatsapp.net). Respostas endereçadas a um JID @lid são silenciosamente descartadas no servidor do WhatsApp, então as resolvemos no lado do gateway antes de emitir o webhook.

Cada payload de message.received carrega três campos de rastreabilidade:

CampoNotas
fromMelhor JID conhecido do remetente. <phone>@s.whatsapp.net quando resolvido, caso contrário o <digits>@lid bruto.
from_resolvedtrue quando from é um JID de telefone entregável. false quando só o LID é conhecido até agora.
from_lidO JID @lid original, presente sempre que o WhatsApp entregou a mensagem como um LID — mesmo após a resolução. Permite correlacionar registros anteriores indexados por LID com o telefone resolvido.
from_phoneO JID <phone>@s.whatsapp.net resolvido, presente quando from_resolved é true. Sempre igual a from nesse caso — exposto separadamente para você pegá-lo incondicionalmente sem checar o sufixo de from.

Chave de persistência recomendada: from_phone quando presente, com fallback para from_lid. Quando from_resolved muda para true para um contato que você antes via apenas como LID, também emitimos um evento contact.resolved para você reescrever a chave de forma determinística.

Endereçamento de resposta: POST /v1/channels/:id/messages aceita apenas JIDs de telefone. Qualquer to terminado em @lid é rejeitado de cara com 400 undeliverable_recipient — conhecido ou não, em cache ou não. O WhatsApp descarta em silêncio mensagens endereçadas a um LID, então recusamos em vez de reportar uma entrega que nunca acontece.

Ou seja: quando from_resolved é false você não consegue responder ainda. Guarde a mensagem sob from_lid, espere o contact.resolved, e então envie para o JID de telefone.

Payload de contact.resolved

{
  "channel_id": "8d653c66-e4ff-43ee-97da-3de5ad5680d4",
  "lid": "47064251658474@lid",
  "phone_jid": "558585218491@s.whatsapp.net",
  "first_seen_at": "2026-05-22T21:17:11.576Z"
}

Você também pode listar todos os pares (LID → JID de telefone) que observamos desde a abertura da sessão:

GET /v1/channels/{id}/contacts/lids
→ { "data": [{ "lid": "...@lid", "phone_jid": "...@s.whatsapp.net" }] }

Headers que seu endpoint vai ver

POST /your/endpoint HTTP/1.1
Host: api.acme.dev
Content-Type: application/json
User-Agent: whatisup-webhooks/0.0.1
X-WhatIsUp-Signature: t=1700000000,v1=...
X-WhatIsUp-Event: message.received
X-WhatIsUp-Event-Id: 9bc38acc-2edc-482e-93d4-0482b432ffa6
X-WhatIsUp-Correlation-Id: req-3f7a1c02

X-WhatIsUp-Event-Id e X-WhatIsUp-Event são conveniências — espelham o que está no corpo, mas deixam você rotear ou curto-circuitar antes de fazer o parse do JSON.

X-WhatIsUp-Correlation-Id rastreia o evento de volta ao que quer que o tenha disparado. Para um message.sent de saída, é o id da requisição da chamada POST /v1/channels/{channel_id}/messages (o mesmo valor devolvido no header x-request-id). Para um message.received, é um ID gerado estável entre retentativas. Útil em logs.

Compatibilidade

Versionamos os schemas via api_version, hoje v1. Dentro de uma versão, vamos apenas adicionar campos opcionais — nunca renomear, nunca remover. Novos formatos incompatíveis ganham uma nova string de versão; você opta por eles atualizando seu endpoint no seu ritmo. (Ainda não temos múltiplas versões no ar — só uma.)

O stream SSE em /v1/events carrega os mesmos payloads data, mas não é o mesmo envelope e não é o mesmo vocabulário de eventos — frames SSE não são assinados, não vêm embrulhados e usam alguns nomes de evento diferentes. Leia aquela página antes de escrever um handler só para os dois.