Event shape
The fields in every gateway event, plus a copy-pasteable JSON example for each event type and state. The same shape arrives on the realtime WebSocket and on webhooks.
Every event the gateway emits is one JSON object with the same outer fields. Those
outer fields are the envelope; the part that changes per event type sits in one
of them, payload. The gateway sends this same object two ways: on the realtime WebSocket
and on webhooks (more on both below).
This page lists the envelope fields, then gives a complete, copy-pasteable example for every event type and every state it can be in. To set up a subscription, see Receive events.
Envelope fields
{
"schema": "v1",
"id": "evt_01J9XYZABCDEF0123456789",
"event": "message",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400000000,
"payload": {}
}| Field | Type | What it is |
|---|---|---|
schema | string | Envelope version. Always v1 today. If this ever changes, check it before reading the rest. |
id | string | The event id: a ULID (a sortable unique id) prefixed with evt_. Use it to skip duplicates and to resume the stream where you left off. |
event | string | The event type, one of the values in the catalog below. It tells you how to read payload. |
session | string | The session the event belongs to (sess_…). A session is one linked WhatsApp number. |
organization | string | The organization that owns the session. Every event belongs to exactly one organization. |
timestamp | integer | When the gateway emitted the event, in milliseconds since the Unix epoch. |
payload | object | The event-specific data. Its fields depend on event — see each entry below. |
You get the same object whether you receive realtime events or webhooks, so you only write your handler once. The two delivery methods differ only in how the bytes reach you:
| Realtime WebSocket | Webhook | |
|---|---|---|
| How it arrives | Open a WebSocket after minting a single-use ticket via POST /api/v1/realtime/ticket | The gateway sends an HTTP POST to your URL |
| Format | Discrete JSON text frames, one object per frame | The envelope as a JSON request body |
| Keepalive | A ping event arrives about every 20 seconds | n/a |
| Dropped connection | Mint a new ticket with since={lastEventId} to replay what you missed | The gateway retries the delivery |
| How to skip duplicates | Track the envelope id | The envelope id is also in the X-Webhook-Request-Id header |
| Authenticity | The ticket (single-use, short-lived) authorizes the socket | Each POST is signed: X-Webhook-Hmac, with X-Webhook-Hmac-Algorithm: sha512 |
Connect to realtime events
Realtime events use a WebSocket with a two-step handshake: mint a ticket, then open the socket.
Step 1: Mint a ticket
curl -X POST "https://your-gateway/api/v1/realtime/ticket" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scope": "organization",
"events": ["*"],
"session": "sess_01J8ABCDEF0123456789"
}'Response:
{
"ticket": "rt_...",
"expiresInSeconds": 30,
"url": "wss://your-gateway/api/v1/realtime?ticket=rt_..."
}Step 2: Open the WebSocket
Connect to the url from the response (or build it: take your base URL, swap http(s) for ws(s), and append /api/v1/realtime?ticket=rt_...).
The ticket is single-use and expires in ~30 seconds. Mint one ticket per connection, immediately before opening the socket. Use the ticket (not your bearer token) to authorize the connection.
Request body fields for the ticket
scope(string, optional, defaultorganization) —organization(all sessions your org owns),session(one session), orfirehose(all events).session(string, optional) — required whenscopeissession. Thesess_...id.events(string array, optional, default["*"]) — event types to receive. Examples:["*"]for all,["message", "poll.vote"]to narrow.since(string, optional) — theidof the last event you processed. Use this when reconnecting to resume from where you left off. The server replays everything after this id (up to 1000 events), then tails live. Deduplicate on theidfield.
Over the socket
The server sends discrete JSON text frames (not NDJSON lines):
- First frame:
{"event":"connected","heartbeatSeconds":20,"timestamp":...} - Heartbeat:
{"event":"ping","timestamp":...}about every 20 seconds. If pings stop, reconnect. - Error:
{"event":"error","error":"..."} - Data: the full event envelope with
schema,id,event,session,organization,timestamp,payload.
Event catalog
There are 17 event types, grouped below. Every example is a complete envelope you can
copy. A field that is empty is left out of the JSON entirely rather than sent as null
(except media, which is explained below). Each entry lists which of its fields are
optional or nullable.
| Event | Fires when |
|---|---|
session.status | A session changes connection state. |
auth.qr | A new QR code is ready to scan. |
auth.code | Pairing succeeded. |
message | An inbound message arrives. |
message.from_me | A message is sent from this number, including from another device linked to it. |
message.status | A receipt updates a sent message. |
message.reaction | Someone reacts to a message. |
message.edited | A message is edited. |
message.revoked | A message is deleted for everyone. |
poll.vote | Someone votes on a poll. |
presence.update | A contact goes online/offline or starts typing. |
group.update | A group's metadata changes. |
group.participant | Someone joins, leaves, or changes role. |
chat.update | A chat changes (currently profile-picture changes). |
contact.update | A contact's name or details change. |
call.incoming | An incoming call. |
newsletter.update | A followed channel updates. |
Attachments are never downloaded. When a message has an attachment, you get
hasMedia: true and media: null. So you know an attachment exists and what kind it is
(from type), but this release never downloads the file itself.
Session & Auth
Messages
The message and message.from_me events use the same payload fields; the type
field tells you what kind of content it holds. media is always null; use
hasMedia to tell whether an attachment exists.
message
An inbound message. Pick a tab per content type.
A plain text message. A reply also carries quotedMessageId; a message with mentions
carries a mentions JID list (both shown here).
{
"schema": "v1",
"id": "evt_01J9MSGTEXT0000000000001",
"event": "message",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400010000,
"payload": {
"waMessageId": "3EB0A1B2C3D4E5F6A7B8",
"chatJid": "120363012345678901@g.us",
"senderJid": "6281234567890@s.whatsapp.net",
"senderLid": "123456789012345@lid",
"fromMe": false,
"type": "text",
"body": "@628999 are we still on for tomorrow?",
"quotedMessageId": "3EB0FEDCBA9876543210",
"mentions": ["628999@s.whatsapp.net"],
"hasMedia": false,
"media": null,
"timestamp": 1719400010000,
"pushName": "Alex"
}
}An image, video, audio clip, document, or sticker. type says which one, hasMedia is
true, and media is still null (the file is not downloaded). If the message has a
caption, it is in body.
{
"schema": "v1",
"id": "evt_01J9MSGMEDIA000000000001",
"event": "message",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400011000,
"payload": {
"waMessageId": "3EB0A1B2C3D4E5F6A7B9",
"chatJid": "6281234567890@s.whatsapp.net",
"senderJid": "6281234567890@s.whatsapp.net",
"fromMe": false,
"type": "image",
"body": "here's the receipt",
"hasMedia": true,
"media": null,
"timestamp": 1719400011000,
"pushName": "Alex"
}
}type is one of image, video, audio, document, sticker. The file is never
downloaded — media stays null even though hasMedia is true.
A shared location. The coordinates ride in a location object.
{
"schema": "v1",
"id": "evt_01J9MSGLOC00000000000001",
"event": "message",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400012000,
"payload": {
"waMessageId": "3EB0A1B2C3D4E5F6A7BA",
"chatJid": "6281234567890@s.whatsapp.net",
"senderJid": "6281234567890@s.whatsapp.net",
"fromMe": false,
"type": "location",
"hasMedia": false,
"media": null,
"location": {
"latitude": -6.200000,
"longitude": 106.816666,
"name": "Monas",
"address": "Gambir, Jakarta Pusat"
},
"timestamp": 1719400012000,
"pushName": "Alex"
}
}location.name and location.address are optional; latitude/longitude are always present.
A shared contact card. The raw vCard is kept verbatim in contact.vcard.
{
"schema": "v1",
"id": "evt_01J9MSGCONTACT00000000001",
"event": "message",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400013000,
"payload": {
"waMessageId": "3EB0A1B2C3D4E5F6A7BB",
"chatJid": "6281234567890@s.whatsapp.net",
"senderJid": "6281234567890@s.whatsapp.net",
"fromMe": false,
"type": "contact",
"body": "Jamie Rivera",
"hasMedia": false,
"media": null,
"contact": {
"displayName": "Jamie Rivera",
"vcard": "BEGIN:VCARD\nVERSION:3.0\nFN:Jamie Rivera\nTEL;type=CELL;waid=628111222333:+62 811-222-333\nEND:VCARD"
},
"timestamp": 1719400013000,
"pushName": "Alex"
}
}contact.displayName and contact.vcard are both optional. body mirrors the display name.
A poll-creation message. The poll definition rides in a poll object. Votes arrive
separately as poll.vote.
{
"schema": "v1",
"id": "evt_01J9MSGPOLL0000000000001",
"event": "message",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400014000,
"payload": {
"waMessageId": "3EB0A1B2C3D4E5F6A7BC",
"chatJid": "120363012345678901@g.us",
"senderJid": "6281234567890@s.whatsapp.net",
"fromMe": false,
"type": "poll",
"body": "Lunch on Friday?",
"hasMedia": false,
"media": null,
"poll": {
"name": "Lunch on Friday?",
"options": ["Pizza", "Sushi", "Salad"],
"selectableCount": 1
},
"timestamp": 1719400014000,
"pushName": "Alex"
}
}poll.selectableCount is the max number of options a voter may pick. body mirrors poll.name.
Common fields (every message / message.from_me payload)
waMessageId(string, required) — the WhatsApp message id; the target other events reference.chatJid(string, required) — the chat (DM@s.whatsapp.net, group@g.us, etc.).senderJid(string, optional) — phone-number JID of the sender.senderLid(string, optional) — sender's LID identity, when the addressing uses it.fromMe(boolean, required) —falseformessage;trueformessage.from_me.type(string, required) —text,image,video,audio,document,sticker,location,contact,poll.body(string, optional) — text / caption / poll name / contact display name.quotedMessageId(string, optional) — set when the message is a reply.mentions(string[], optional) — mentioned JIDs.hasMedia(boolean, required) —truewhen an attachment exists;mediais stillnull.media(object|null) — alwaysnullin v1.timestamp(integer, required) — epoch-ms.pushName(string, optional) — the sender's display name.
message.from_me
Same fields as message. You get this when a message was sent from your own
number, including from another device linked to it. The only difference is that fromMe
is true.
{
"schema": "v1",
"id": "evt_01J9MSGFROMME00000000001",
"event": "message.from_me",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400015000,
"payload": {
"waMessageId": "3EB0A1B2C3D4E5F6A7BD",
"chatJid": "6281234567890@s.whatsapp.net",
"senderJid": "6289876543210@s.whatsapp.net",
"fromMe": true,
"type": "text",
"body": "yes, see you at noon",
"hasMedia": false,
"media": null,
"timestamp": 1719400015000,
"pushName": "You"
}
}message.status
The status of one or more sent messages changed. A single update can cover several
messages at once, so messageIds is a list.
The status value comes from one of two places:
| Status | Set by |
|---|---|
pending, sent, failed | The gateway, as it accepts and sends your outbound message. Not from WhatsApp. |
delivered, read, played | A receipt from WhatsApp. |
Queued by the gateway, not yet acknowledged by WhatsApp (ack level 0). Set on send, not from a receipt.
{
"schema": "v1",
"id": "evt_01J9STPENDING00000000001",
"event": "message.status",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400016000,
"payload": {
"chatJid": "6281234567890@s.whatsapp.net",
"messageIds": ["3EB0A1B2C3D4E5F6A7BD"],
"status": "pending",
"timestamp": 1719400016000
}
}Accepted by the WhatsApp server (ack level 1). Set on send, not from a receipt.
{
"schema": "v1",
"id": "evt_01J9STSENT000000000000001",
"event": "message.status",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400016500,
"payload": {
"chatJid": "6281234567890@s.whatsapp.net",
"messageIds": ["3EB0A1B2C3D4E5F6A7BD"],
"status": "sent",
"timestamp": 1719400016500
}
}Delivered to the recipient's device (ack level 2). Driven by a delivery receipt.
{
"schema": "v1",
"id": "evt_01J9STDELIVERED000000001",
"event": "message.status",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400017000,
"payload": {
"chatJid": "6281234567890@s.whatsapp.net",
"senderJid": "6281234567890@s.whatsapp.net",
"messageIds": ["3EB0A1B2C3D4E5F6A7BD"],
"status": "delivered",
"timestamp": 1719400017000
}
}Read by the recipient (ack level 3, the blue ticks). Driven by a read receipt.
{
"schema": "v1",
"id": "evt_01J9STREAD0000000000001",
"event": "message.status",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400018000,
"payload": {
"chatJid": "6281234567890@s.whatsapp.net",
"senderJid": "6281234567890@s.whatsapp.net",
"messageIds": ["3EB0A1B2C3D4E5F6A7BD", "3EB0A1B2C3D4E5F6A7BE"],
"status": "read",
"timestamp": 1719400018000
}
}A voice note / view-once media was played (ack level 4). Driven by a played receipt.
{
"schema": "v1",
"id": "evt_01J9STPLAYED000000000001",
"event": "message.status",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400019000,
"payload": {
"chatJid": "6281234567890@s.whatsapp.net",
"senderJid": "6281234567890@s.whatsapp.net",
"messageIds": ["3EB0A1B2C3D4E5F6A7BF"],
"status": "played",
"timestamp": 1719400019000
}
}The message failed to send. Set internally, not from a receipt.
{
"schema": "v1",
"id": "evt_01J9STFAILED00000000001",
"event": "message.status",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400019500,
"payload": {
"chatJid": "6281234567890@s.whatsapp.net",
"messageIds": ["3EB0A1B2C3D4E5F6A7C0"],
"status": "failed",
"timestamp": 1719400019500
}
}Fields
chatJid(string, required) — the chat the message belongs to.senderJid(string, optional) — the JID the receipt came from (set on receipt-driven updates).messageIds(string[], required) — thewaMessageIds this receipt updates.status(string, required) —pending,sent,delivered,read,played,failed.timestamp(integer, required) — epoch-ms.
Reactions, edits, and deletes
These three are their own event types, but they reuse the message payload fields. In all
three, targetId is the waMessageId of the message being acted on.
Polls
Poll creation
A poll being posted arrives as a regular message event (or message.from_me if this
account posted it) with type set to poll. The question and options are in the poll
field. The gateway records the options so it can resolve later votes back to readable
labels.
{
"schema": "v1",
"id": "evt_01J9POLLNEW0000000000001",
"event": "message",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400023500,
"payload": {
"waMessageId": "3EB0A1B2C3D4E5F6A7BC",
"chatJid": "120363012345678901@g.us",
"senderJid": "6281234567890@s.whatsapp.net",
"fromMe": false,
"type": "poll",
"body": "Lunch?",
"poll": {
"name": "Lunch?",
"options": ["Pizza", "Sushi"],
"selectableCount": 1
},
"hasMedia": false,
"media": null,
"timestamp": 1719400023500,
"pushName": "Alex"
}
}Fields
poll.name(string) — the poll question.poll.options(string[]) — the answer options, in creation order.poll.selectableCount(number) — how many options a voter may pick (1 = single choice).
poll.vote
Someone voted on a poll. targetId is the original poll message they voted on.
selectedOptions holds the chosen option labels: WhatsApp transmits a vote as
SHA-256 hashes of the options, and the gateway resolves them back to text using the
options it recorded when the poll was created. If a vote arrives for a poll the gateway
never saw (so it can't resolve a hash), that entry falls back to the raw hash string.
{
"schema": "v1",
"id": "evt_01J9POLLVOTE0000000000001",
"event": "poll.vote",
"session": "sess_01J8ABCDEF0123456789",
"organization": "org_abc",
"timestamp": 1719400024000,
"payload": {
"waMessageId": "3EB0A1B2C3D4E5F6A7C5",
"chatJid": "120363012345678901@g.us",
"senderJid": "6281234567890@s.whatsapp.net",
"fromMe": false,
"type": "poll_vote",
"targetId": "3EB0A1B2C3D4E5F6A7BC",
"selectedOptions": ["Sushi"],
"hasMedia": false,
"media": null,
"timestamp": 1719400024000,
"pushName": "Alex"
}
}Fields
targetId(string) — the poll-creation message'swaMessageId.selectedOptions(string[]) — the chosen option labels (falls back to the raw SHA-256 hash for any option the gateway can't resolve).- Plus the common message fields (
waMessageId,chatJid,senderJid,fromMe,timestamp,pushName).
Presence, chats & contacts
Groups
Calls & newsletters
For the endpoints that produce and receive these events, see the API reference.