Webhook Payload
Structure of the webhook payload delivered to your endpoint.
Pipes.bot delivers a Meta-compatible webhook payload extended with a pipes object for Pipes.bot-specific metadata including media download information.
Payload structure
Every webhook delivery follows the same envelope structure. The messages[].type field determines the shape of the type-specific payload, and the pipes object carries media metadata when applicable.
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "pool_number_id",
"changes": [
{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "+15551234567",
"phone_number_id": "pool_number_id"
},
"messages": [
{
"id": "msg_abc123",
"from": "15559876543",
"timestamp": "2025-01-15T10:30:00.000Z",
"type": "text",
"text": {
"body": "Hello from WhatsApp!"
}
}
],
"contacts": [
{
"profile": {
"name": "Jane Doe"
},
"wa_id": "15559876543"
}
]
},
"field": "messages"
}
]
}
],
"pipes": {
"conversationId": "conv_xyz789",
"poolNumberId": "pool_number_id",
"label": "support"
}
}Field reference
Root fields
| Field | Type | Description |
|---|---|---|
object | string | Always "whatsapp_business_account" |
entry | array | Array containing one entry object |
pipes | object | Pipes.bot extension fields (see below) |
entry[].changes[].value
| Field | Type | Description |
|---|---|---|
messaging_product | string | Always "whatsapp" |
metadata.display_phone_number | string | Your pool number's phone number |
metadata.phone_number_id | string | Your pool number ID |
messages | array | Array containing one message object |
contacts | array | Array containing one contact object |
messages[]
| Field | Type | Description |
|---|---|---|
id | string | Unique message identifier |
from | string | Sender's WhatsApp number |
timestamp | string | ISO 8601 timestamp |
type | string | Message type: text, image, audio, document, video, sticker, location, contacts, reaction, button, interactive, or a passthrough type (see Button, Interactive, Other types) |
The remaining fields depend on the message type. See Message types below.
contacts[]
| Field | Type | Description |
|---|---|---|
profile.name | string | Sender's WhatsApp profile name |
wa_id | string | Sender's WhatsApp ID |
pipes
| Field | Type | Description |
|---|---|---|
conversationId | string | Pipes.bot conversation ID |
poolNumberId | string | Pool number this message was received on |
label | string? | Activation label, if one was set |
media | object? | Media metadata, present for media message types |
pipes.media
Present when the message type is image, audio, document, video, or sticker.
| Field | Type | Description |
|---|---|---|
mediaId | string? | Obfuscated media ID for downloading |
downloadUrl | string? | Relative URL path: /v1/media/download/{mediaId} |
mimeType | string | MIME type of the file (e.g. image/jpeg, audio/ogg) |
fileName | string? | Original file name, when available (mainly for documents) |
byteSize | number | File size in bytes |
unavailable | boolean? | true if the media could not be downloaded from WhatsApp |
When unavailable is true, mediaId and downloadUrl will be absent. The message is still delivered so you can notify the user, but the file cannot be retrieved.
Message types
Text
{
"type": "text",
"text": {
"body": "Hello from WhatsApp!"
}
}Image
Images include an optional caption. The file can be downloaded via pipes.media.
{
"type": "image",
"image": {
"id": "media_id",
"mime_type": "image/jpeg",
"caption": "Check this out"
}
}{
"pipes": {
"conversationId": "conv_xyz789",
"poolNumberId": "pool_number_id",
"media": {
"mediaId": "aBcDeFgHiJkLmNoPqRs1t",
"downloadUrl": "/v1/media/download/aBcDeFgHiJkLmNoPqRs1t",
"mimeType": "image/jpeg",
"byteSize": 245120
}
}
}Audio
Audio messages include voice notes and audio file attachments.
{
"type": "audio",
"audio": {
"id": "media_id",
"mime_type": "audio/ogg"
}
}Video
Videos include an optional caption.
{
"type": "video",
"video": {
"id": "media_id",
"mime_type": "video/mp4",
"caption": "Watch this"
}
}Document
Documents include the original file name and an optional caption.
{
"type": "document",
"document": {
"id": "media_id",
"mime_type": "application/pdf",
"filename": "invoice.pdf",
"caption": "Here's the invoice"
}
}Sticker
{
"type": "sticker",
"sticker": {
"id": "media_id",
"mime_type": "image/webp"
}
}Location
{
"type": "location",
"location": {
"latitude": 37.7749,
"longitude": -122.4194,
"name": "San Francisco",
"address": "San Francisco, CA, USA"
}
}Location messages do not include pipes.media.
Contacts
{
"type": "contacts",
"contacts": [
{
"name": { "formatted_name": "Jane Doe" },
"phones": [{ "phone": "+15559876543", "type": "CELL" }]
}
]
}Reaction
Reactions reference the original message ID. An absent emoji means the reaction was removed.
{
"type": "reaction",
"reaction": {
"message_id": "msg_original123",
"emoji": "👍"
}
}Button
Template quick-reply buttons the customer tapped arrive as type: "button".
{
"type": "button",
"button": {
"text": "Confirm",
"payload": "confirm_order"
}
}Interactive
Replies to interactive messages arrive as type: "interactive". The nested object is one of button_reply, list_reply, or nfm_reply (Flows).
{
"type": "interactive",
"interactive": {
"type": "button_reply",
"button_reply": { "id": "confirm", "title": "Confirm" }
}
}Other types
Any inbound type Pipes.bot does not specifically model (e.g. order, system, or a future Meta type) is passed through with its original type string and an empty text block:
{
"type": "order",
"text": { "body": "" }
}Note: Non-text passthrough types always carry a
text: { body: "" }block even though there is no text. Switch ontypeand read the type-specific object rather than relying ontext.
Test payloads
The Test Connection button sends a small JSON body to your endpoint (signed with X-Pipes-Signature when a secret is configured):
{
"test": true,
"source": "pipes.bot"
}This is not a Meta-shaped message envelope — it has no entry / messages array. Detect it by the top-level test flag and acknowledge with 200 without parsing message fields:
app.post("/webhook", (req, res) => {
const payload = req.body;
if (payload.test === true) {
// Test Connection ping — acknowledge without processing
return res.sendStatus(200);
}
// Process real message...
});Handling all message types
Here's a complete example that routes by message type:
app.post("/webhook", (req, res) => {
const payload = req.body;
if (payload.test === true) {
return res.sendStatus(200);
}
const message = payload.entry[0].changes[0].value.messages[0];
const pipes = payload.pipes;
switch (message.type) {
case "text":
console.log("Text:", message.text.body);
break;
case "image":
case "audio":
case "video":
case "document":
case "sticker":
if (pipes.media?.unavailable) {
console.log("Media unavailable for this message");
} else {
console.log("Download from:", pipes.media.downloadUrl);
console.log("MIME type:", pipes.media.mimeType);
console.log("Size:", pipes.media.byteSize, "bytes");
}
break;
case "location":
console.log("Location:", message.location.latitude, message.location.longitude);
break;
case "contacts":
console.log("Contacts:", message.contacts);
break;
case "reaction":
console.log("Reaction:", message.reaction.emoji, "on", message.reaction.message_id);
break;
case "button":
console.log("Button tapped:", message.button.text);
break;
case "interactive":
console.log("Interactive reply:", message.interactive.type);
break;
default:
// order, system, unknown, future Meta types — type string preserved
console.log("Other type:", message.type);
}
res.sendStatus(200);
});