WhatsApp Cloud API Webhook Inspector
Meta's webhook body is four levels deep before it tells you anything, and the parts that matter are numeric codes scattered across several doc pages. Paste one here and read it in English.
Webhook body
Webhook bodies contain customer phone numbers and message text. This page parses them in your browser and sends nothing anywhere.
What this payload says
The shape of a Cloud API webhook
Every payload nests the same way, and both arrays can genuinely contain more than one item — Meta batches:
{
"object": "whatsapp_business_account",
"entry": [{ ← can be more than one
"id": "<WABA id>",
"changes": [{ ← can be more than one
"field": "messages",
"value": {
"metadata": { phone_number_id, display_phone_number },
"contacts": [ ... ], ← only on inbound messages
"messages": [ ... ], ← inbound from a user
"statuses": [ ... ], ← sent / delivered / read / failed
"errors": [ ... ] ← account-level problems
}
}]
}]
}Reading entry[0].changes[0].value and moving on works right up until it does not. Iterate both.
Most of your webhooks are not messages
This is the first thing that bites a new integration. Each message you send generates sent, then delivered, then usually read — three status callbacks per outbound message, all arriving on the same messages field as genuine inbound traffic.
A handler written as value.messages[0].from will throw on the majority of its traffic. Branch on which key is present before you touch it.
The 24-hour window, and error 131047
You may send free-form messages only within 24 hours of the user's last message to you. After that, sends fail with 131047 and nothing you do to the request will change it — it is a policy limit, not a transient error, so retrying just burns quota.
To reach someone outside the window you must send a pre-approved template. That reopens the window and opens a new billable conversation. If you see 131047 in your logs at volume, the fix is a template and a re-engagement flow, not a retry policy.
Billing is per conversation, not per message
The conversation.origin.type and pricing objects on a status callback tell you which of the four categories applied: marketing, utility, authentication or service. The category is set when the conversation opens and every message in that 24-hour window rides on it.
Marketing is the most expensive and service the least, so a template categorised as marketing when it should have been utility is a straightforward and often invisible cost leak. The inspector surfaces the category on every billable status so you can check.
Ids you should key on, and ones you should not
- Use
interactive.button_reply.idandlist_reply.id— these are your own payloads. - Use
button.payloadfor quick replies on templates. - Do not match on the button title. It is display text; it changes with copy edits and translations, and the break is silent.
- Do not assume
wamidvalues are comparable or ordered. Treat them as opaque strings. - Do deduplicate on message id. Meta retries deliveries, and a webhook that returns anything other than a fast 200 will see the same payload again.
Frequently asked
What does WhatsApp error 131047 mean?
The 24-hour customer service window has closed. Once more than 24 hours have passed since the user last messaged you, free-form messages are rejected — you can only send an approved template. Sending one reopens the window and starts a new billable conversation. This is by far the most common error in a production Cloud API integration, and it is a policy limit, not a bug you can retry past.
Why is my WhatsApp webhook firing with no messages in it?
Because most webhook deliveries are status updates, not inbound messages. Every message you send produces sent, delivered and usually read callbacks, all on the same 'messages' field. A handler that assumes value.messages exists will throw on the majority of its traffic. Always branch on whether value.messages or value.statuses is present.
What is the structure of a WhatsApp Cloud API webhook?
Four levels before anything useful: the body has an `entry` array, each entry has a `changes` array, each change has a `field` and a `value`, and `value` is where `messages`, `statuses`, `contacts`, `metadata` and `errors` live. Meta can batch multiple entries and multiple changes into one POST, so treat both as arrays rather than reading index zero.
How do I tell which button a user tapped?
For interactive messages, read `interactive.button_reply.id` or `interactive.list_reply.id` — that id is the payload you set when sending. For quick-reply buttons on a template, it is `button.payload`. Use the id, never the title: the title is display text, it changes when someone edits copy or adds a translation, and matching on it breaks silently.
Why am I being billed for conversations I did not start?
Billing is per 24-hour conversation, not per message, and the category is fixed when the conversation opens. The `pricing` and `conversation.origin` objects on a status callback tell you which category applied — marketing, utility, authentication or service. Marketing costs the most and service the least, so a template sent under the wrong category is a real cost difference at volume.
Built by Himanshu Srivastava, who ships real-time WhatsApp calling and messaging infrastructure on the Cloud API for a living. The error explanations come from production, not from the docs. More in the tools collection.