WhatsApp Cloud API webhooks: verify, test and read every payload
To test a WhatsApp Cloud API webhook, paste a free webhooks.sh bin URL into the App Dashboard as the callback URL: the bin answers Meta's hub.challenge check and shows every message and status payload live. No signup, 48 hours, 100 requests a day.
WhatsApp Cloud API webhooks need a public HTTPS callback URL that passes Meta's verification request before a single event will send. The quickest way to see real payloads is to paste a free bin URL into the App Dashboard, because the bin answers the hub.challenge check by itself. Below we walk through setup, the verification request, message and status payloads, the signature check, and retries.
Point WhatsApp at a free test URL
- Create a free bin in your browser at view.webhooks.page or run
curl -X POST https://webhooks.sh/v1/binsto get aningest_urland aview_url. - Open the App Dashboard, go to WhatsApp > Configuration, paste the ingest URL as the callback URL (any path under it works too, like
/whatsapp), and type any string you want as the verify token. - Save the callback URL, and the bin answers Meta's
hub.challengerequest for you. - Subscribe to the
messageswebhook field on that same page. - Send a WhatsApp message to your business number and watch the payload show up live in the viewer.
The bin replies 200 to every POST, which Meta counts as delivered.
# 1. Create a free bin (or open view.webhooks.page)
curl -X POST https://webhooks.sh/v1/bins
# 2. Send the verification request Meta sends: the bin replies 1158201444
curl "https://<bin>.webhooks.page/whatsapp?hub.mode=subscribe&hub.challenge=1158201444&hub.verify_token=my_verify_token"
What the verification request checks
When you save the callback URL, Meta sends a GET request to your callback URL with three query parameters: hub.mode set to subscribe, hub.challenge set to a random value, and hub.verify_token set to the string you typed in the dashboard. Your own handler has to check that hub.verify_token matches and reply 200 with the hub.challenge value in the body; any other reply leaves the URL unverified and Meta sends nothing.
A free bin echoes hub.challenge automatically without checking your hub.verify_token, so passing verification with a bin proves Meta can reach the URL, not that your token check works. The GET request is still captured, so you'll see it in the viewer with your token sitting in the query string, and once the URL is verified you can also send a test payload straight from the Configuration page in the App Dashboard.
What a WhatsApp message payload looks like
Every event notification is a POST with Content-Type: application/json and top-level object set to whatsapp_business_account. Inside that, entry[].id is the WhatsApp Business Account ID and entry[].changes[].field is messages, while changes[].value holds the actual data: metadata.phone_number_id (along with metadata.display_phone_number) tells you which of your numbers received the message, contacts[] has the sender's profile.name and wa_id, and messages[] has from, the message id starting with wamid., timestamp, type, and text.body when type is a text message.
Always loop over every item in entry[] and changes[], because Meta can batch updates and one request can carry several.
{
"object": "whatsapp_business_account",
"entry": [{
"id": "102290129340398",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
"contacts": [{ "profile": { "name": "Sheena Nelson" }, "wa_id": "16505551234" }],
"messages": [{
"from": "16505551234",
"id": "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQTRBNjU5OUFFRTAzODEwMTQ0RgA=",
"timestamp": "1749416383",
"type": "text",
"text": { "body": "Does it come in another color?" }
}]
}
}]
}]
}
Status webhooks for messages you send
When you send a message out, the webhook still arrives on the messages field, but changes[].value carries a statuses[] array instead of messages[]. Each item in statuses[] gives you id (the wamid. value you got back when sending), status (sent, delivered, read, failed, or played for voice messages), timestamp, recipient_id, plus the conversation and pricing objects (pricing.billable, pricing.pricing_model, pricing.category).
A single outgoing message can trigger up to three status webhooks (sent, delivered, read), and read implies delivered, so the delivered webhook can be skipped and your code shouldn't wait on it. When something goes wrong, failed sends put the details in statuses[].errors, unsupported incoming messages (where type is unsupported) put them in messages[].errors, and system, app, or account problems show up in value.errors.
{
"object": "whatsapp_business_account",
"entry": [{
"id": "102290129340398",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
"statuses": [{
"id": "wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQUFERjg0NDEzNDdFODU3MUMxMAA=",
"status": "sent",
"timestamp": "1750030073",
"recipient_id": "16505551234",
"conversation": { "id": "72b14d6bd5407799e66f64d1b338e567", "origin": { "type": "marketing" } },
"pricing": { "billable": true, "pricing_model": "PMP", "category": "marketing" }
}]
}
}]
}]
}
Verify X-Hub-Signature-256
Every POST from Meta carries an X-Hub-Signature-256 header formatted as sha256= followed by an HMAC-SHA256 hash of the raw request body, keyed with your app secret. Compute your own HMAC over the exact bytes you received before any JSON parsing, strip the sha256= prefix, and compare the two digests with a constant-time function.
Free bins don't verify signatures for you, but X-Hub-Signature-256 is not redacted in the viewer. Copy a real body and header value from your bin into the HMAC signature verifier to test your code against a request Meta actually sent.
import { createHmac, timingSafeEqual } from "node:crypto";
export function isFromMeta(rawBody, signatureHeader, appSecret) {
const expected = "sha256=" + createHmac("sha256", appSecret).update(rawBody).digest("hex");
const got = Buffer.from(signatureHeader ?? "");
const want = Buffer.from(expected);
return got.length === want.length && timingSafeEqual(got, want);
}
Retries, duplicates and size limits
Meta expects an HTTP 200 response for every POST.
- Reply
200as soon as you receive the request, then do your work asynchronously. - Deduplicate incoming events using the
wamid.messageid, because retries go to all subscribed apps and duplicates can arrive. - Store every payload you care about when it lands, since Meta's WhatsApp webhooks have no API for fetching past notifications.
- Switch the callback URL in the App Dashboard to your own server once you finish testing, because an expired bin stops accepting requests and Meta will keep retrying for days.
- If nothing shows up when you message your number, check that you subscribed to the
messagesfield, that the app has thewhatsapp_business_messagingpermission, and that the app is in Live mode, or walk through the webhook not received guide.
| Rule | Value | What it means for testing |
|---|---|---|
| Payload size | Up to 3 MB | A free bin takes bodies up to 100KB and answers 413 above that |
| Batching | Up to 1000 updates per request, not guaranteed | Loop over every entry and change |
| Retries | Decreasing frequency for up to 7 days | Deduplicate on the message id |
| Status webhooks | Up to 3 per message you send | Each one counts toward 100 requests a day on a free bin |
| Verification | GET with hub.challenge | A free bin answers it automatically |
Keep the URL or relay to localhost
A free bin lasts 48 hours and accepts 100 requests per day with bodies up to 100KB: enough to build and test, not to run a busy number. To keep the same URL, so you don't have to verify a new callback URL, claim the bin with a free Google sign-in before it expires; the timer stops and you keep 30 days of rolling history with the same limits.
When you need more headroom or local delivery, Solo ($12 a month, on pricing) gives you a persistent named endpoint on webhooks.host with 250,000 requests per billing period and request bodies up to 5MB. It also includes the wsh CLI to relay live requests straight to your machine (wsh listen <endpoint> --to localhost:3000) and automatic forwarding to up to 3 HTTPS URLs.
FAQ
How do I verify a WhatsApp webhook?
Meta sends a GET request with hub.mode=subscribe, hub.challenge and hub.verify_token. Check that the token matches yours and reply 200 with the hub.challenge value. A free webhooks.sh bin answers this check automatically, without checking your token.
Can I test a WhatsApp webhook without a server?
Yes. Paste a free bin URL into the App Dashboard as your callback URL with no signup required, and incoming message and status payloads will show up live in your browser for 48 hours.
Why is my WhatsApp webhook not receiving messages?
Check under WhatsApp > Configuration that you're subscribed to the messages field, and make sure your app has the whatsapp_business_messaging permission and is switched to Live mode, since some webhooks aren't sent in development. Also check your TLS setup: the callback URL must use a valid certificate, because self-signed certificates don't work.
How long does WhatsApp retry a failed webhook?
If your endpoint returns a non-200 status or can't be reached, Meta retries the delivery with decreasing frequency for up to 7 days before dropping the notification. Because retries go to all subscribed apps, your handler can see duplicate requests.
How big can a WhatsApp webhook payload be?
WhatsApp webhook payloads can be up to 3 MB. A free webhooks.sh bin accepts request bodies up to 100KB and replies 413 (body_too_large) above that, while Solo endpoints accept bodies up to 5MB.
What is in the X-Hub-Signature-256 header?
It holds sha256= followed by the HMAC-SHA256 hex digest of the raw request body, signed with your app secret.
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.