Skip to content
WhatIsUp.dev

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:

FieldTypeNotes
api_versionstringAlways "v1" today. Match on it exactly if you gate on version.
event_idUUID v4Stable across retries of the same delivery. Use as your dedupe key.
eventstringThe event name. See list below.
customer_idUUIDYour account id. Always present.
channel_idUUIDThe channel that produced the event. Always present.
created_atISO-8601 UTCWall-clock time at the gateway when the event was emitted.
dataobjectEvent-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. Carries from, to, chat_id, timestamp, is_group, body, optional push_name, and optional quoted when the message replies to another one. See the LID resolution note below.
  • message.sent β€” Your outbound message reached WhatsApp. Carries message_id, to, chat_id, timestamp, body, your client_ref, and optional quoted. 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, or failed. See below.
  • message.reaction β€” Someone reacted in a conversation your channel is in. Carries message_key, reactor, emoji, timestamp (epoch ms), plus the flat aliases message_id (the reacted-to message), from, chat_id, is_group, from_me. An empty emoji means the reaction was cleared.
  • message.edited β€” The sender edited an earlier message. Carries message_id (the original message), chat_id, from, from_me, is_group, timestamp and text β€” the new text (or new caption).
  • message.deleted β€” The sender (or a group admin) deleted an earlier message for everyone. Same fields as message.edited, without text.

Channel lifecycle

  • channel.status β€” Every lifecycle transition, as one event. Carries status, previous_status, changed_at, simple (qr / connected / logged_out / stopped), live and can_send. If you are driving pairing headlessly, subscribe to this one and ignore the three below. simple is a pairing signal, not a health signal β€” a disconnected channel reports simple: "connected" on purpose, because its credentials are still good and the socket returns on its own. Read live for "is the socket up right now" and can_send for "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 one channel.status with needs_attention: true and attention_reason (offline_too_long / reconnect_loop). Nothing transitioned in that delivery (status equals previous_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. Carries phone_number, connected_at.
  • channel.disconnected β€” The session ended. Carries reason, will_reconnect, disconnected_at. reason is one of logged_out, connection_replaced, restart_required, timed_out, qr_expired, connection_lost, proxy_error β€” branch on it; new values may be added. proxy_error means the channel's own proxy failed; the channel keeps retrying through it (never directly) and GET /v1/channels/{id}/status β†’ last_error says why.
  • channel.health β€” The number's health level changed: level (good / warning / danger / restricted) and previous_level, with reasons, new_contacts_7d, reply_rate_7d, age_days and what auto_protect is doing β€” same shape as GET /v1/channels/{id}/health. The first score of a healthy number doesn't fire. Account users are also emailed when a number turns danger or restricted (once per number and level per 24 hours).
  • channel.limits β€” WhatsApp's own limits on the number changed: the new-chat quota status (none β†’ first_warning β†’ second_warning β†’ capped) or a temporary restriction on messaging new people (set, with ends_at, or lifted). Carries new_chats (status, used, quota, cycle_ends_at), restriction (active, ends_at, reason) and checked_at; same shape as GET /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 to none.
  • qr.updated β€” A new QR is available. Carries qr_png_base64 (bare base64) and expires_at (epoch ms). The dashboard uses this; webhook subscribers usually filter it out.

Groups

  • group.created β€” Carries group_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. Carries call_id, from (pass it back as from to 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. Carries call_id, ended_at (epoch ms) and status: accepted (answered, now over), rejected, missed (rang out) or terminated (caller hung up, never seen answered). Fires once per call.
  • presence.updated β€” A contact's presence changed. Carries chat_id, contact_id, presence (available / unavailable / composing / recording / paused) and last_seen (Unix seconds, or null when 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.

FieldNotes
statusOne of sent (reached WhatsApp), delivered (reached the device, βœ“βœ“), read (chat opened, blue βœ“βœ“), played (voice/video note played), failed (undeliverable).
participantIn group chats, the member whose ack this is β€” each member acks independently. Omitted for 1:1 chats.
reasonOnly 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.

kindFields
textbody. Optional interactive when the text came from a business-formatted message or a tap on one β€” see below.
image Β· audio Β· document Β· stickermime_type, media_url, optional size, caption, filename, sha256, media_error.
videoSame as media, plus ptv: true for a round video note.
contactscontacts[] β€” display_name, phones[] (phone, wa_jid), raw vcard.
locationlatitude, longitude, optional name, address, url. A live location sets live: true (and optional caption); only its first position is delivered.
pollname, 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.typeWhat happened
template Β· buttons Β· list Β· interactiveA business sent you a template / quick-reply / list / interactive message; body is its main text.
buttons_response Β· list_response Β· template_button_responseThe contact tapped a button or picked a list row; body is the label, selection_id the id your app gave it.
interactive_responseThe 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
  }
}
FieldNotes
quoted.message_idThe id of the quoted message. Feed it straight back as reply_to.message_id to answer in the same thread.
quoted.from_metrue 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.participantJID of the quoted message's author. Meaningful in groups; WhatsApp sometimes omits it.

Three cases all produce it, and all three matter:

  1. A contact replies to you β€” message.received, quoted.from_me: true.
  2. A contact quotes their own earlier message β€” message.received, quoted.from_me: false.
  3. A team member swipes-to-reply on the business phone β€” fires as message.sent (it is outbound), still carrying quoted. 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_errorMeaning
download_failedFetching the file from WhatsApp failed or timed out.
too_largeThe 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:

FieldNotes
fromBest-known recipient JID. <phone>@s.whatsapp.net when resolved, otherwise the raw <digits>@lid.
from_resolvedtrue when from is a deliverable phone JID. false when only the LID is known so far.
from_lidThe 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_phoneThe 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.