Message Types
All WhatsApp message types delivered over the WebSocket connection.
Every incoming WhatsApp message arrives as a whatsapp_message event. (Apps on API v2 receive event frames instead.) The data.type field determines the shape of the payload.
Which messages you receive depends on your connection scope:
- User-scoped connections receive all messages from all your activated pool numbers
- Pool-number-scoped connections receive only messages from the specified pool number
- Conversation-scoped connections receive only messages matching the specified conversationId
Envelope structure
All messages share this common structure:
{
"type": "whatsapp_message",
"data": {
"messageId": "msg_abc123",
"conversationId": "conv_xyz789",
"poolNumberId": "pool_123",
"poolNumberPhoneNumber": "+15551234567",
"fromNumber": "+15559876543",
"fromName": "Jane Doe",
"text": "Message text or caption",
"timestamp": "2025-01-15T10:30:00.000Z",
"label": "support",
"type": "text",
"body": "Message text or caption"
}
}Common fields
| Field | Type | Description |
|---|---|---|
messageId | string | Unique message identifier |
conversationId | string | Pipes.bot conversation ID (use this for replies) |
poolNumberId | string | Pool number that received the message |
poolNumberPhoneNumber | string | Pool number's phone number |
fromNumber | string | Sender's WhatsApp number, or their BSUID when WhatsApp doesn't share the number |
fromName | string? | Sender's WhatsApp profile name |
userId | string? | Sender's BSUID (CC.<id>), when WhatsApp provides one |
parentUserId | string? | Sender's parent BSUID (CC.ENT.<id>), if enabled |
username | string? | Sender's WhatsApp username, if they have set one, as Meta sends it (may include a leading @) |
text | string | Text content or caption |
timestamp | string | ISO 8601 timestamp |
label | string? | Activation label, if one was set |
type | string | Message type (see sections below) |
body | string | Same as text — the message body |
Additional fields appear depending on the message type.
Text
{
"type": "text",
"body": "Hello from WhatsApp!",
"text": "Hello from WhatsApp!"
}The simplest message type. text and body both contain the message content.
Image
{
"type": "image",
"body": "Check this out",
"text": "Check this out",
"media": {
"mediaId": "aBcDeFgHiJkLmNoPqRs1t",
"downloadUrl": "/v1/media/download/aBcDeFgHiJkLmNoPqRs1t",
"mimeType": "image/jpeg",
"byteSize": 245120
}
}Images include an optional caption in body/text. See Media Handling for download details.
Audio
{
"type": "audio",
"media": {
"mediaId": "aBcDeFgHiJkLmNoPqRs1t",
"downloadUrl": "/v1/media/download/aBcDeFgHiJkLmNoPqRs1t",
"mimeType": "audio/ogg",
"byteSize": 52480
}
}Includes voice notes and audio file attachments.
Video
{
"type": "video",
"body": "Watch this",
"text": "Watch this",
"media": {
"mediaId": "aBcDeFgHiJkLmNoPqRs1t",
"downloadUrl": "/v1/media/download/aBcDeFgHiJkLmNoPqRs1t",
"mimeType": "video/mp4",
"byteSize": 1048576
}
}Videos include an optional caption.
Document
{
"type": "document",
"body": "Here's the invoice",
"text": "Here's the invoice",
"media": {
"mediaId": "aBcDeFgHiJkLmNoPqRs1t",
"downloadUrl": "/v1/media/download/aBcDeFgHiJkLmNoPqRs1t",
"mimeType": "application/pdf",
"fileName": "invoice.pdf",
"byteSize": 102400
}
}Documents include the original fileName and an optional caption.
Sticker
{
"type": "sticker",
"media": {
"mediaId": "aBcDeFgHiJkLmNoPqRs1t",
"downloadUrl": "/v1/media/download/aBcDeFgHiJkLmNoPqRs1t",
"mimeType": "image/webp",
"byteSize": 25600
}
}Location
{
"type": "location",
"location": {
"latitude": 37.7749,
"longitude": -122.4194,
"name": "San Francisco",
"address": "San Francisco, CA, USA"
}
}| Field | Type | Description |
|---|---|---|
latitude | number | Latitude coordinate |
longitude | number | Longitude coordinate |
name | string? | Location name |
address | string? | Full address string |
Location messages do not include a media object.
Contacts
{
"type": "contacts",
"contacts": [
{
"name": { "formatted_name": "Jane Doe" },
"phones": [{ "phone": "+15559876543", "type": "CELL" }]
}
]
}The contacts array follows WhatsApp's native contact structure. Each contact may include name, phones, emails, addresses, org, and urls fields.
Reaction
{
"type": "reaction",
"reaction": {
"messageId": "msg_original123",
"emoji": "👍"
}
}| Field | Type | Description |
|---|---|---|
messageId | string | ID of the message being reacted to |
emoji | string? | The emoji. Absent when the reaction has been removed |
Unrecognized types
Messages that don't match a modeled type are delivered with data.type set to the raw WhatsApp type string (e.g. "order", "system") and empty text/body. The original WhatsApp payload is not forwarded over the WebSocket — only the type and empty text arrive. Switch on data.type and treat any unknown value as an unmodeled message.
Button
Template quick-reply taps arrive as type: "button" with an interactive object. The button's payload is delivered as interactive.id and its label as interactive.title (and body/text):
{
"type": "button",
"body": "Confirm",
"text": "Confirm",
"interactive": {
"type": "button_reply",
"id": "confirm_order",
"title": "Confirm"
}
}Interactive
Replies to interactive messages (button_reply, list_reply, nfm_reply) arrive as type: "interactive" with an interactive object:
{
"type": "interactive",
"interactive": {
"type": "button_reply",
"id": "confirm",
"title": "Confirm"
}
}| Field | Type | Description |
|---|---|---|
type | string | button_reply, list_reply, nfm_reply, or the raw subtype for unknown interactive messages |
id | string? | Selected option ID (or the button payload for type: "button" messages) |
title | string? | Selected option title |
description | string? | Selected list-row description |
payload | string? | Template button payload |
responseJson | string? | Raw JSON response for Flows (nfm_reply) |
Handling all types
ws.on("message", (raw) => {
const event = JSON.parse(raw.toString());
if (event.type !== "whatsapp_message") return;
const msg = event.data;
switch (msg.type) {
case "text":
console.log("Text:", msg.body);
break;
case "image":
case "audio":
case "video":
case "document":
case "sticker":
if (msg.media?.unavailable) {
console.log("Media unavailable");
} else {
console.log("Download:", msg.media.downloadUrl);
console.log("MIME:", msg.media.mimeType);
console.log("Size:", msg.media.byteSize, "bytes");
}
if (msg.body) console.log("Caption:", msg.body);
break;
case "location":
console.log("Location:", msg.location.latitude, msg.location.longitude);
break;
case "contacts":
console.log("Contacts:", msg.contacts.length);
break;
case "reaction":
if (msg.reaction.emoji) {
console.log("Reaction:", msg.reaction.emoji, "on", msg.reaction.messageId);
} else {
console.log("Reaction removed on", msg.reaction.messageId);
}
break;
case "button":
case "interactive":
console.log("Interactive:", msg.interactive.type, msg.interactive.title ?? msg.interactive.payload);
break;
default:
console.log("Unrecognized type:", msg.type); // raw WhatsApp type, empty text
}
});BSUIDs
When a user hasn't shared their phone number, fromNumber holds their BSUID (for example US.13491208655302741918) and userId carries the same value. See BSUIDs for the format and for how a phone number appearing or disappearing changes the conversationId. To reply, pass the BSUID as toNumber.
API v2 event
Apps on BYON API v2 receive event frames instead of whatsapp_message. data is the same envelope that v2 sends to your webhook (see API v2 envelope, and API v2 Example Payloads for every event type):
{
"type": "event",
"data": {
"id": "evt_3f9c1a7b2d4e4f6a8b0c1d2e3f4a5b6c",
"field": "messages",
"createdAt": "2025-01-15T10:30:01.123Z",
"pipes": {
"appId": "app_abc123",
"poolNumberId": "pn_abc123",
"media": {}
},
"whatsapp": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "15551234567", "phone_number_id": "1098765432" },
"contacts": [{ "profile": { "name": "Jane Doe" }, "wa_id": "15559876543" }],
"messages": [
{
"from": "15559876543",
"id": "wamid.abc123",
"timestamp": "1736937000",
"type": "text",
"text": { "body": "Hello" }
}
]
}
}
}On v2 connections:
- Message statuses (
sent,delivered,read,failed), identity changes and template status updates all arrive aseventframes. Readdata.fieldand the contents ofdata.whatsapp. - Events arrive over the WebSocket and the webhook (if you configured one). Deduplicate on
data.id, or on the messageid(wamid...) insidedata.whatsapp. - A
conversationIdquery parameter is rejected during the handshake with HTTP403. v2 has no conversations. - An event is queued only when no WebSocket connection received it and the webhook either is not configured or failed without being recorded. A webhook that fails all 3 delivery attempts counts as handled: the failure is recorded as a pending webhook delivery and the event is not queued. The queue holds up to 100 events per App for 24 hours, and the oldest event is dropped when it is full. Queued events are replayed as
eventframes, oldest first, when an App-scoped connection opens. A connection scoped withpoolNumberIddoes not drain the queue. - If your App is upgraded to v2 while connected, the server closes your socket with code
4000and reasonapi_version_changed. Reconnect to continue on v2.
ws.on("message", (raw) => {
const frame = JSON.parse(raw.toString());
if (frame.type !== "event") return;
const envelope = frame.data;
if (seen.has(envelope.id)) return;
seen.add(envelope.id);
if (envelope.field === "messages") {
const value = envelope.whatsapp;
for (const message of value.messages ?? []) {
console.log(message.type, message.from ?? message.from_user_id);
}
for (const status of value.statuses ?? []) {
console.log("status", status.id, status.status);
}
}
});