Purpose
Messaging webhooks are the real-time event stream for WhatsApp traffic. They include inbound user messages (messages), outbound delivery/read status changes (statuses), and messaging errors (errors).
In Dualhook, message-path events are delivered by Meta directly to your endpoint through Webhook Override. Dualhook handles management-event processing separately.
Starting March 31, 2026, these webhooks include new BSUID fields and some existing fields become conditional. See the BSUID transition guide for payload changes and migration guidance.
Webhook Setup in Dualhook
When a connection is subscribed, Dualhook configures callback override through Meta Graph API:
curl -X POST "https://graph.facebook.com/<GRAPH_VERSION>/<WABA_ID>/subscribed_apps" \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"override_callback_uri":"https://api.yourdomain.com/webhooks/meta/messages",
"verify_token":"<YOUR_VERIFY_TOKEN>"
}'
Callback URL Requirements
Use a publicly reachable HTTPS endpoint:
- Use standard TLS endpoint URLs.
- Avoid underscores in hostnames.
- Avoid custom port patterns — use standard HTTPS serving.
Verification Handshake (GET)
Meta verifies callback ownership with a challenge request:
GET /webhooks/meta/messages?hub.mode=subscribe&hub.verify_token=<TOKEN>&hub.challenge=1903260781
Return HTTP 200 with the challenge value as the response body.
Inbound Webhook POST Validation
This is the most common source of confusion in the Dualhook flow, so read this carefully before wiring up signature verification on your endpoint.
In this section, POST means the inbound webhook POST that Meta sends to your callback URL. It does not mean outbound Graph API calls such as POST /{PHONE_NUMBER_ID}/messages; outbound sends use your WhatsApp Cloud API access token in the Authorization: Bearer ... header.
When you onboard a number through Dualhook's Embedded Signup (the standard path, including Coexistence), the WhatsApp Business Account is subscribed to Dualhook's Meta app, not your own. Meta therefore signs X-Hub-Signature-256 using Dualhook's Meta App Secret, which is shared at the Meta-app level across all our customers and cannot be exposed.
So in the Dualhook-managed flow:
- Your own
META_APP_SECRETwill not match the signature — your HMAC computation will be correct, but the secret is wrong. - The webhook verify token you configured in Dualhook is for the GET verification handshake only. It is not the POST signing secret and cannot be used to validate
X-Hub-Signature-256. - Dualhook does not re-sign or add an
X-Dualhook-Signatureto message-path webhooks, because those payloads go from Meta directly to your endpoint — Dualhook is not in the message path. (X-Dualhook-Signatureonly applies to Dualhook Platform lifecycle events such asonboarding.completed— see Webhook Signature Verification.)
The recommended validation stack for message-path webhooks in the Dualhook-managed flow is therefore:
- Verify the payload shape matches the WhatsApp webhook envelope (
object: "whatsapp_business_account",entry[].changes[].field === "messages"). Inbound messages, statuses, and failures appear insideentry[].changes[].value. - Validate
value.metadata.phone_number_idagainst thephone_number_idyou stored for this tenant/connection. Reject missing or unknown IDs for message-path events. - Validate the
entry[].id(WABA ID) against the WABA you expect for this tenant. - Use a high-entropy, per-tenant webhook URL path (random segment, not guessable) and treat that path as a secret. Rotate it if it leaks.
- Serve over HTTPS only, return
200quickly, and reject unexpected methods. - For stronger sender authentication at the transport layer, enable Meta webhook mTLS where your infrastructure supports client certificate verification. Still keep the WABA and phone-number checks above, because mTLS proves the request came from Meta, not which tenant it belongs to.
If you bring your own Meta app and subscribe it to the WABA yourself (a non-default flow that Dualhook does not manage for you), then X-Hub-Signature-256 is signed with your Meta App Secret and standard HMAC verification on the raw request body applies. This is rare and is not what Embedded Signup configures.
If cryptographic signing of message-path webhooks with a secret you control is a hard requirement for your compliance posture, contact support — it requires a different routing architecture than the standard Webhook Override flow.
Webhook Envelope
All messaging webhook payloads use this outer structure:
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "102290129340398",
"changes": [
{
"field": "messages",
"value": {}
}
]
}
]
}
Inside entry[].changes[].value, expect messages for inbound events, statuses for outbound lifecycle updates, and errors for messaging failures.
Inbound Text Message
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "102290129340398",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15550001234",
"phone_number_id": "123456789012345"
},
"contacts": [
{
"profile": { "name": "Alex Example" },
"wa_id": "12015550123"
}
],
"messages": [
{
"from": "12015550123",
"id": "wamid.HBgLMTIwMTU1NTAxMjMVAgARGBI...",
"timestamp": "1735939200",
"type": "text",
"text": { "body": "Hello from customer" }
}
]
}
}
]
}
]
}
Inbound Button Reply
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "102290129340398",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15550001234",
"phone_number_id": "123456789012345"
},
"messages": [
{
"from": "12015550123",
"id": "wamid.HBgLMTIwMTU1NTAxMjMVAgARGBJ...",
"timestamp": "1735939300",
"type": "button",
"button": {
"text": "Track order",
"payload": "track_order"
},
"context": {
"id": "wamid.HBgLMTIwMTU1NTAxMjMVAgARGBI..."
}
}
]
}
}
]
}
]
}
Outbound Status Update
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "102290129340398",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"statuses": [
{
"id": "wamid.HBgLMTIwMTU1NTAxMjMVAgARGBI...",
"status": "delivered",
"timestamp": "1735939400",
"recipient_id": "12015550123",
"conversation": {
"id": "d9f6f2f6d17f4b20b0",
"origin": { "type": "utility" }
},
"pricing": {
"billable": true,
"pricing_model": "CBP",
"category": "utility"
}
}
]
}
}
]
}
]
}
Error Notification
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "102290129340398",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"errors": [
{
"code": 131026,
"title": "Message Undeliverable",
"message": "Recipient is not reachable at this time.",
"error_data": {
"details": "The message could not be delivered to the user."
}
}
]
}
}
]
}
]
}
Acknowledgment and Latency
- Return
HTTP 200 OKas soon as payload authenticity and basic shape are verified. - Do heavy processing asynchronously after ack.
- Keep median callback response time around sub-250 ms.
- Keep slow responses (>1s) to a very small fraction of requests.
Throughput Planning
Outbound sends produce multiple status callbacks (sent, delivered, read). Size your webhook capacity for roughly 3x outbound message rate + inbound message rate during busy windows.
If your endpoint does not return 200, Meta retries delivery with exponential backoff. Retries can continue for multiple days, so idempotent handling is required.
Processing Best Practices
- Persist raw webhook payloads with an internal trace ID.
- Deduplicate by
wamid(and status tuple where relevant). - Separate inbound message consumers from status/error consumers.
- Monitor webhook ack latency and non-
200rates independently of business logic. - Send
status=readvia the messages endpoint after your app accepts an inbound message.