WhatsApp Gateway
Guides

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": {}
}
FieldTypeWhat it is
schemastringEnvelope version. Always v1 today. If this ever changes, check it before reading the rest.
idstringThe 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.
eventstringThe event type, one of the values in the catalog below. It tells you how to read payload.
sessionstringThe session the event belongs to (sess_…). A session is one linked WhatsApp number.
organizationstringThe organization that owns the session. Every event belongs to exactly one organization.
timestampintegerWhen the gateway emitted the event, in milliseconds since the Unix epoch.
payloadobjectThe 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 WebSocketWebhook
How it arrivesOpen a WebSocket after minting a single-use ticket via POST /api/v1/realtime/ticketThe gateway sends an HTTP POST to your URL
FormatDiscrete JSON text frames, one object per frameThe envelope as a JSON request body
KeepaliveA ping event arrives about every 20 secondsn/a
Dropped connectionMint a new ticket with since={lastEventId} to replay what you missedThe gateway retries the delivery
How to skip duplicatesTrack the envelope idThe envelope id is also in the X-Webhook-Request-Id header
AuthenticityThe ticket (single-use, short-lived) authorizes the socketEach 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, default organization) — organization (all sessions your org owns), session (one session), or firehose (all events).
  • session (string, optional) — required when scope is session. The sess_... id.
  • events (string array, optional, default ["*"]) — event types to receive. Examples: ["*"] for all, ["message", "poll.vote"] to narrow.
  • since (string, optional) — the id of 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 the id field.

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.

EventFires when
session.statusA session changes connection state.
auth.qrA new QR code is ready to scan.
auth.codePairing succeeded.
messageAn inbound message arrives.
message.from_meA message is sent from this number, including from another device linked to it.
message.statusA receipt updates a sent message.
message.reactionSomeone reacts to a message.
message.editedA message is edited.
message.revokedA message is deleted for everyone.
poll.voteSomeone votes on a poll.
presence.updateA contact goes online/offline or starts typing.
group.updateA group's metadata changes.
group.participantSomeone joins, leaves, or changes role.
chat.updateA chat changes (currently profile-picture changes).
contact.updateA contact's name or details change.
call.incomingAn incoming call.
newsletter.updateA 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) — false for message; true for message.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) — true when an attachment exists; media is still null.
  • media (object|null) — always null in 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:

StatusSet by
pending, sent, failedThe gateway, as it accepts and sends your outbound message. Not from WhatsApp.
delivered, read, playedA 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) — the waMessageIds 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's waMessageId.
  • 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.

On this page