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:
| Campo | Tipo | Notas |
|---|---|---|
api_version | string | Hoje é sempre "v1". Compare exatamente com isso se você validar a versão. |
event_id | UUID v4 | Estável entre retentativas da mesma entrega. Use como sua chave de deduplicação. |
event | string | O nome do evento. Veja a lista abaixo. |
customer_id | UUID | O id da sua conta. Sempre presente. |
channel_id | UUID | O canal que produziu o evento. Sempre presente. |
created_at | ISO-8601 UTC | Horário do relógio no gateway quando o evento foi emitido. |
data | objeto | Payload 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. Carregafrom,to,chat_id,timestamp,is_group,body, e opcionalmentepush_nameequoted(quando responde a outra mensagem). Veja a nota sobre resolução de LID abaixo.message.sent— Sua mensagem de saída chegou ao WhatsApp. Carregamessage_id,to,chat_id,timestamp,body, o seuclient_refe opcionalmentequoted. 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, oufailed. Veja abaixo.message.reaction— Alguém reagiu numa conversa em que seu canal está. Carregamessage_key,reactor,emoji,timestamp(epoch ms), e os atalhosmessage_id(a mensagem reagida),from,chat_id,is_group,from_me. Umemojivazio significa reação removida.message.edited— Quem enviou editou uma mensagem anterior. Carregamessage_id(a mensagem original),chat_id,from,from_me,is_group,timestampetext— o texto novo (ou a legenda nova).message.deleted— Quem enviou (ou um admin do grupo) apagou uma mensagem anterior para todos. Mesmos campos demessage.edited, semtext.
Ciclo de vida do canal
channel.status— Toda transição de ciclo de vida, num evento só. Carregastatus,previous_status,changed_at,simple(qr/connected/logged_out/stopped),liveecan_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 canaldisconnectedreportasimple: "connected"de propósito, porque as credenciais seguem boas e o socket volta sozinho. Leialivepara "o socket está de pé agora" ecan_sendpara "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 umchannel.statuscomneeds_attention: trueeattention_reason(offline_too_long/reconnect_loop). Nada mudou de estado nessa entrega (statusigual aprevious_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. Carregaphone_number,connected_at.channel.disconnected— A sessão terminou. Carregareason,will_reconnect,disconnected_at.reasoné um delogged_out,connection_replaced,restart_required,timed_out,qr_expired,connection_lost,proxy_error— faça o branch por ele; valores novos podem aparecer.proxy_errorsignifica que o proxy do próprio canal falhou; o canal segue tentando por ele (nunca direto) eGET /v1/channels/{id}/status→last_errordiz o motivo.channel.health— O nível de saúde do número mudou:level(good/warning/danger/restricted) eprevious_level, comreasons,new_contacts_7d,reply_rate_7d,age_dayse o que oauto_protectestá fazendo — mesmo formato deGET /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 viradangerourestricted(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: ostatusda cota de conversas novas (none→first_warning→second_warning→capped) ou umarestrictiontemporária de mandar mensagem pra gente nova (aplicada, comends_at, ou retirada). Carreganew_chats(status,used,quota,cycle_ends_at),restriction(active,ends_at,reason) echecked_at; mesmo formato deGET /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 anone.qr.updated— Um novo QR está disponível. Carregaqr_png_base64(base64 pura) eexpires_at(epoch ms).
Grupos
group.created— Carregagroup_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. Carregacall_id,from(devolva comofrompara 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. Carregacall_id,ended_at(epoch ms) estatus:accepted(atendida, já encerrada),rejected,missed(tocou sem resposta) outerminated(quem ligou desligou, sem atendimento visto). Dispara uma vez por chamada.presence.updated— A presença de um contato mudou. Carregachat_id,contact_id,presence(available/unavailable/composing/recording/paused) elast_seen(segundos Unix, ounullquando 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
}| Campo | Notas |
|---|---|
status | Um de sent (chegou ao WhatsApp), delivered (chegou ao dispositivo, ✓✓), read (conversa aberta, ✓✓ azul), played (áudio/vídeo reproduzido), failed (não entregue). |
participant | Em grupos, o membro a quem este ack pertence — cada membro confirma de forma independente. Omitido em conversas 1:1. |
reason | Só 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.
kind | Campos |
|---|---|
text | body. interactive opcional quando o texto veio de uma mensagem formatada por empresa ou de um toque nela — veja abaixo. |
image · audio · document · sticker | mime_type, media_url, e opcionais size, caption, filename, sha256, media_error. |
video | Igual à mídia, mais ptv: true para uma nota de vídeo redonda. |
contacts | contacts[] — display_name, phones[] (phone, wa_jid), vcard cru. |
location | latitude, longitude, e opcionais name, address, url. Localização em tempo real vem com live: true (e caption opcional); só a primeira posição é entregue. |
poll | name, 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.type | O que aconteceu |
|---|---|
template · buttons · list · interactive | Uma empresa te mandou um template / botões / lista / mensagem interativa; body é o texto principal. |
buttons_response · list_response · template_button_response | O 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_response | O 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
}
}| Campo | Observações |
|---|---|
quoted.message_id | Id da mensagem citada. Devolva-o em reply_to.message_id para responder na mesma thread. |
quoted.from_me | true 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.participant | JID 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:
- O contato responde você —
message.received,quoted.from_me: true. - O contato cita a própria mensagem anterior —
message.received,quoted.from_me: false. - Alguém da equipe responde pelo celular do negócio — sai como
message.sent(é saída), ainda comquoted. É 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_error | Significado |
|---|---|
download_failed | Buscar o arquivo no WhatsApp falhou ou estourou o tempo. |
too_large | O 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:
| Campo | Notas |
|---|---|
from | Melhor JID conhecido do remetente. <phone>@s.whatsapp.net quando resolvido, caso contrário o <digits>@lid bruto. |
from_resolved | true quando from é um JID de telefone entregável. false quando só o LID é conhecido até agora. |
from_lid | O 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_phone | O 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.