Event payloads
Every webhook body has the same 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": { /* event-specific shape, see below */ }
}The envelope fields:
| Field | Type | Notes |
|---|---|---|
api_version | string | Always "v1" today. Match on it exactly if you gate on version. |
event_id | UUID v4 | Stable across retries of the same delivery. Use as your dedupe key. |
event | string | The event name. See list below. |
customer_id | UUID | Your account id. Always present. |
channel_id | UUID | The channel that produced the event. Always present. |
created_at | ISO-8601 UTC | Wall-clock time at the gateway when the event was emitted. |
data | object | Event-specific payload, schemas below. |
All five scalar fields are required — none are nullable. data is required
and always an object; its shape is decided by event.
There is no occurred_at field, and ids are UUIDs, not ULIDs — earlier
revisions of this page documented both. A receiver written from that will read
undefined for the timestamp.
Event types
All 27 events. Subscribing to a name not on this list is rejected at
endpoint-create time with 400 invalid_request.
Messages
message.received— Inbound message from a contact. Carriesfrom,to,chat_id,timestamp,is_group,body, optionalpush_name, and optionalquotedwhen the message replies to another one. See the LID resolution note below.message.sent— Your outbound message reached WhatsApp. Carriesmessage_id,to,chat_id,timestamp,body, yourclient_ref, and optionalquoted. Also fires for messages typed on the business phone, so an agent's swipe-to-reply shows up here.message.status— A delivery tick for a message you sent:sent→delivered→read/played, orfailed. See below.message.reaction— Someone reacted in a conversation your channel is in. Carriesmessage_key,reactor,emoji,timestamp(epoch ms), plus the flat aliasesmessage_id(the reacted-to message),from,chat_id,is_group,from_me. An emptyemojimeans the reaction was cleared.message.edited— The sender edited an earlier message. Carriesmessage_id(the original message),chat_id,from,from_me,is_group,timestampandtext— the new text (or new caption).message.deleted— The sender (or a group admin) deleted an earlier message for everyone. Same fields asmessage.edited, withouttext.
Channel lifecycle
channel.status— Every lifecycle transition, as one event. Carriesstatus,previous_status,changed_at,simple(qr/connected/logged_out/stopped),liveandcan_send. If you are driving pairing headlessly, subscribe to this one and ignore the three below.simpleis a pairing signal, not a health signal — adisconnectedchannel reportssimple: "connected"on purpose, because its credentials are still good and the socket returns on its own. Readlivefor "is the socket up right now" andcan_sendfor "will a send be accepted". Attention alert: when a channel has been offline for more than 15 minutes or keeps failing to reconnect, you also get onechannel.statuswithneeds_attention: trueandattention_reason(offline_too_long/reconnect_loop). Nothing transitioned in that delivery (statusequalsprevious_status); it comes at most once per channel every 6 hours, and the account's users get the same alert by email. Both fields are absent on ordinary transitions.channel.connected— The channel finished pairing. Carriesphone_number,connected_at.channel.disconnected— The session ended. Carriesreason,will_reconnect,disconnected_at.reasonis one oflogged_out,connection_replaced,restart_required,timed_out,qr_expired,connection_lost,proxy_error— branch on it; new values may be added.proxy_errormeans the channel's own proxy failed; the channel keeps retrying through it (never directly) andGET /v1/channels/{id}/status→last_errorsays why.channel.health— The number's health level changed:level(good/warning/danger/restricted) andprevious_level, withreasons,new_contacts_7d,reply_rate_7d,age_daysand whatauto_protectis doing — same shape asGET /v1/channels/{id}/health. The first score of a healthy number doesn't fire. Account users are also emailed when a number turnsdangerorrestricted(once per number and level per 24 hours).channel.limits— WhatsApp's own limits on the number changed: the new-chat quotastatus(none→first_warning→second_warning→capped) or a temporaryrestrictionon messaging new people (set, withends_at, or lifted). Carriesnew_chats(status,used,quota,cycle_ends_at),restriction(active,ends_at,reason) andchecked_at; same shape asGET /v1/channels/{id}/limits. Fires on changes only, not on every quota tick. Treat a warning as "slow this number down now": route its new conversations to another number until the status is back tonone.qr.updated— A new QR is available. Carriesqr_png_base64(bare base64) andexpires_at(epoch ms). The dashboard uses this; webhook subscribers usually filter it out.
Groups
group.created— Carriesgroup_id,by,timestamp,subject,participants.group.updated— Group metadata changed.group.participant_added/group.participant_removed— Membership changed.group.admin_promoted/group.admin_demoted— Admin rights changed.
All six carry group_id, the actor by (nullable), and timestamp (epoch ms).
group.updated adds field (subject / description / announce /
restrict / ephemeral) and its new value; the other five add
participants[] (phone JIDs when known). group.created fires when a group
is created with your number in it.
Chats — not yet available (reserved)
chat.updated— Chat state changed (mute, unread count, …).chat.archived— Chat archived or unarchived.chat.pinned— Chat pinned or unpinned.
Commerce (WhatsApp Business accounts only) — not yet available (reserved)
order.placed/order.cancelled— An inbound order changed state.cart.updated— A customer's cart changed.
Other
story.viewed— Someone viewed a story you posted. Not yet available (reserved).call.offered— An incoming call is ringing. Carriescall_id,from(pass it back asfromto reject the call),chat_id,is_group,kind(audio/video),started_at(epoch ms) and, when the caller is a LID,from_phone.call.terminated— The call ended. Carriescall_id,ended_at(epoch ms) andstatus:accepted(answered, now over),rejected,missed(rang out) orterminated(caller hung up, never seen answered). Fires once per call.presence.updated— A contact's presence changed. Carrieschat_id,contact_id,presence(available/unavailable/composing/recording/paused) andlast_seen(Unix seconds, ornullwhen privacy hides it).contact.resolved— Fires the first time we learn the phone JID behind a previously-LID-only contact. See below.
Reserved events never fire yet: chat.updated, chat.archived,
chat.pinned, order.placed, order.cancelled, cart.updated,
story.viewed. Subscribing to them is accepted (so existing endpoints keep
validating), but nothing will be delivered until they ship. presence.updated
only fires for chats you subscribed to via the presence endpoint.
There is no message.delivered and no message.failed event. Both are
message.status frames discriminated by the status field. Older revisions of
these docs listed them as separate events — subscribing to those names fails
validation.
message.status payload
Tracks the checkmarks for a message you sent — use it to build a delivered/read indicator or an "awaiting reply" view.
{
"message_id": "3EB0C9A17F2B4D8E1A05F3D77C10B4E2A991",
"to": "558585218491@s.whatsapp.net",
"status": "read",
"is_group": false,
"timestamp": 1779425257573
}message_id is the id we returned from your send — WhatsApp format, 3EB0 plus
36 uppercase hex characters. It is not a wamid.… handle; that is the Meta
Cloud API's format, not ours. timestamp is epoch milliseconds.
| Field | Notes |
|---|---|
status | One of sent (reached WhatsApp), delivered (reached the device, ✓✓), read (chat opened, blue ✓✓), played (voice/video note played), failed (undeliverable). |
participant | In group chats, the member whose ack this is — each member acks independently. Omitted for 1:1 chats. |
reason | Only on failed frames the gateway raised itself — a stable machine code: undelivered_after_retries (handed to WhatsApp, never acknowledged after repeated resends), expired_in_queue (the channel stayed offline too long; never sent), channel_unavailable (the channel was logged out, stopped, disabled or deleted while the message waited), recipient_not_on_whatsapp (the number can't receive WhatsApp messages), and for a queued media send whose media_url couldn't be used — the same codes a direct send answers with: media_fetch_blocked (private or internal address), media_too_large (over the size cap), media_fetch_failed (couldn't be downloaded). Absent when WhatsApp itself reported the failure. New codes may be added — keep a default branch. |
The same message_id fires multiple times as the status advances. read only arrives when the recipient has read-receipts enabled — its absence is not proof the message is unread.
Message body kinds
message.received and message.sent carry a body discriminated on kind.
New kinds may be added within v1 — ignore kinds you don't handle instead
of failing.
kind | Fields |
|---|---|
text | body. Optional interactive when the text came from a business-formatted message or a tap on one — see below. |
image · audio · document · sticker | mime_type, media_url, optional size, caption, filename, sha256, media_error. |
video | Same as media, plus ptv: true for a round video note. |
contacts | contacts[] — display_name, phones[] (phone, wa_jid), raw vcard. |
location | latitude, longitude, optional name, address, url. A live location sets live: true (and optional caption); only its first position is delivered. |
poll | name, options[] (strings), optional selectable_count (0 = any number). Votes are end-to-end encrypted to the poll's creator and are not delivered. |
Buttons, lists and templates. They arrive as kind: "text" with the text a
person would read, plus an interactive object:
{
"kind": "text",
"body": "Yes, confirm",
"interactive": { "type": "buttons_response", "selection_id": "btn_confirm" }
}interactive.type | What happened |
|---|---|
template · buttons · list · interactive | A business sent you a template / quick-reply / list / interactive message; body is its main text. |
buttons_response · list_response · template_button_response | The contact tapped a button or picked a list row; body is the label, selection_id the id your app gave it. |
interactive_response | The contact answered an interactive (flow) message. params_json is the raw JSON it returned; selection_id is its id field when present. |
Route on selection_id, not on the label — labels are translated and editable.
Reactions, edits and deletes
These point at an earlier message by message_id; they never create a
message of their own and never fire 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 says whether the reacted-to message was yours;
top-level from_me says whether the reaction came from your own number.
Around reconnects the same reaction, edit or delete can be delivered twice —
all three are idempotent (last emoji / last text / deleted), so apply them as
"set", not "toggle".
Quoted replies (quoted)
Both message.received and message.sent carry an optional quoted object,
present only when that message answers another one (WhatsApp's
swipe-to-reply). No reply, no field — branch on its presence:
{
"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
}
}| Field | Notes |
|---|---|
quoted.message_id | The id of the quoted message. Feed it straight back as reply_to.message_id to answer in the same thread. |
quoted.from_me | true when your own number wrote the quoted message (an earlier bot reply, or something an agent typed on the business phone); false when the contact wrote it. |
quoted.participant | JID of the quoted message's author. Meaningful in groups; WhatsApp sometimes omits it. |
Three cases all produce it, and all three matter:
- A contact replies to you —
message.received,quoted.from_me: true. - A contact quotes their own earlier message —
message.received,quoted.from_me: false. - A team member swipes-to-reply on the business phone — fires as
message.sent(it is outbound), still carryingquoted. This is how an integration notices a thread was answered by a human out-of-band.
The quoted message itself is not inlined — only its id. Look it up in your
own store, or via GET /v1/channels/:id/messages.
Inbound media (media_url, media_error)
Image, audio, video, document and sticker messages carry the file in body:
"body": {
"kind": "image",
"mime_type": "image/jpeg",
"size": 48213,
"media_url": "https://api.whatisup.dev/v1/media/eyJ…",
"caption": "look"
}media_url is a signed, public link — the token in it is the authorization, so
fetch it without a Bearer key. It expires with the gateway's media retention
window (past that it answers 410 media_gone); within the window you can
re-fetch the bytes with GET /v1/channels/:id/media/by-message/:messageId.
Media we couldn't fetch. The message is still delivered — sender, caption,
filename and size intact — but media_url is the inert placeholder
"about:blank" and an extra media_error field says why:
media_error | Meaning |
|---|---|
download_failed | Fetching the file from WhatsApp failed or timed out. |
too_large | The file is bigger than the gateway's inbound media cap (64 MB), so it was never downloaded. |
media_error is absent when the file is available. media_url stays a string
for backward compatibility — branch on media_error, never fetch
about:blank. The message history API (GET /v1/channels/:id/messages)
reports media_url: null for these messages.
LID (Linked-Device ID) resolution
WhatsApp's multi-device protocol exposes contacts to linked sessions as Linked-Device IDs (<digits>@lid) instead of phone JIDs (<digits>@s.whatsapp.net). Replies addressed to an @lid JID are silently dropped on WhatsApp's server, so we resolve them gateway-side before emitting the webhook.
Each message.received payload carries three traceability fields:
| Field | Notes |
|---|---|
from | Best-known recipient JID. <phone>@s.whatsapp.net when resolved, otherwise the raw <digits>@lid. |
from_resolved | true when from is a deliverable phone JID. false when only the LID is known so far. |
from_lid | The original @lid JID, present whenever WhatsApp delivered the message as a LID — even after resolution. Lets you correlate prior LID-keyed records with the resolved phone. |
from_phone | The resolved <phone>@s.whatsapp.net JID, present when from_resolved is true. Always equals from in that case — exposed separately so you can grab it unconditionally without checking from's suffix. |
Recommended persistence key: from_phone when present, falling back to from_lid. When from_resolved flips to true for a contact you previously saw as LID-only, we also emit a contact.resolved event so you can rewrite the key deterministically.
Reply addressing: POST /v1/channels/:id/messages accepts phone JIDs
only. Any to ending in @lid is rejected up front with
400 undeliverable_recipient — known or unknown, cached or not. WhatsApp
silently drops messages addressed to a LID, so we refuse them rather than
report a delivery that never happens.
So: when from_resolved is false you cannot reply yet. Store the message
against from_lid, wait for contact.resolved, then send to the phone JID.
contact.resolved payload
{
"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"
}You can also list every (LID → phone JID) pair we've observed since the session opened:
GET /v1/channels/{id}/contacts/lids
→ { "data": [{ "lid": "...@lid", "phone_jid": "...@s.whatsapp.net" }] }Headers your endpoint will see
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 and X-WhatIsUp-Event are conveniences — they mirror what's in the body, but let you route or short-circuit before you parse the JSON.
X-WhatIsUp-Correlation-Id traces the event back to whatever triggered it. For an outbound message.sent it's the request id of the POST /v1/channels/{channel_id}/messages call (the same value returned as the x-request-id response header). For a message.received it's a generated ID stable across retries. Useful in logs.
Compatibility
We version the schemas via api_version, currently v1. Within a version we'll only add optional fields — never rename, never remove. New incompatible shapes get a new version string; you opt in by upgrading your endpoint at your pace. (We don't have multiple versions live yet — just one.)
The SSE stream at /v1/events carries the same data payloads,
but it is not the same envelope and not the same event vocabulary — SSE
frames are unsigned, unwrapped, and use a few different event names. Read that
page before you write one handler for both.