Skip to main content
Every inbound message Meta sends to your webhook has the same outer wrapper. Inside that wrapper, the content differs by message type. Parsing means digging into the JSON to extract what matters — who sent it, what type it is, and what the content is.

The outer wrapper — always the same

body.entry[0].changes[0].value.messages[0]

                          the actual message object
Your server always digs to messages[0] first, then checks message.type:
const changes = body.entry?.[0]?.changes?.[0]?.value;
if (!changes?.messages) return;   // delivery status, not a message

const message = changes.messages[0];
const from    = message.from;     // sender's phone number e.g. "919959623255"
const type    = message.type;     // "text", "image", "audio", etc.

Per-type parsing

Text message
// { "type": "text", "text": { "body": "Hello" } }
const text = message.text.body;   // → "Hello"
Image message
// { "type": "image", "image": { "id": "1234567890", "caption": "..." } }
const mediaId = message.image.id;        // use Media API to download
const caption = message.image.caption;   // optional, may be undefined
Audio / voice note
const mediaId = message.audio.id;
Video
const mediaId = message.video.id;
const caption = message.video.caption;
Document / file
// { "type": "document", "document": { "id": "111222333", "filename": "invoice.pdf" } }
const mediaId  = message.document.id;
const filename = message.document.filename;
Interactive — button tap
const id    = message.interactive.button_reply.id;     // → "yes_button"
const title = message.interactive.button_reply.title;  // → "Yes"
Interactive — list row selection
const id    = message.interactive.list_reply.id;
const title = message.interactive.list_reply.title;

Why media gives you an ID, not the file

Meta never sends the actual file in the webhook — only the id. To get the file, call the Media API separately:
GET https://graph.facebook.com/v21.0/{media-id}
→ returns a download URL
→ download the file from that URL
See Media API for the full download flow.

Testing with Postman

You can simulate any inbound message type without a real phone by sending a POST to your webhook URL:
POST http://localhost:3003/webhook
Content-Type: application/json

{
  "object": "whatsapp_business_account",
  "entry": [{ "changes": [{ "value": {
    "messages": [{
      "from": "919959623255",
      "type": "text",
      "text": { "body": "Hello from Postman" }
    }]
  }}]}]
}
Your server processes it exactly as if it came from a real customer.

Frequently asked

Check message.type === 'text', then read message.text.body.
Read message.image.id from the payload, then call the Media API with that ID to get a download URL for the actual file. See Media API.
Check message.type === 'interactive'. For a button tap, read message.interactive.button_reply.id — this matches the id you set when you sent the button message.
The webhook also fires for delivery/read status updates, which have statuses instead of messages. Always guard with if (!changes?.messages) return before parsing.

Gotchas & common mistakes

  • Forgetting the messages guard — delivery receipts arrive at the same endpoint without messages. If you don’t check, your code crashes on a missing property.
  • Media ID is not the file — calling message.image.id gives you a string ID, not the photo. You must make a second API call to download the actual file.
  • Interactive has two sub-typesbutton_reply and list_reply sit in different keys. Always check message.interactive.type first.