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 25 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. See the LID resolution note below.message.sent— Your outbound message reached WhatsApp. Carriesmessage_id,to,timestamp,body, and yourclient_ref.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. An emptyemojimeans the reaction was cleared.
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".channel.connected— The channel finished pairing. Carriesphone_number,connected_at.channel.disconnected— The session ended. Carriesreason,will_reconnect,disconnected_at.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.
Chats
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)
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.call.offered/call.terminated— Incoming call lifecycle.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.
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. |
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.
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.