Webhooks
WhatsApp Webhooks
How WRNexus receives inbound WhatsApp messages and delivery statuses from Meta — the verification handshake, X-Hub-Signature-256 verification, the inbound-message and delivery-status payloads, STOP/START consent handling, and idempotency.
WhatsApp webhooks work differently from SMS and Email. They are inbound from Meta, not a self-service subscription: Meta delivers inbound customer messages and delivery statuses to a single WRNexus endpoint, configured once in the Meta App Dashboard.
You usually don’t host this endpoint. In v1, Meta delivers to WRNexus (
https://whatsapp.wrnexus.com/api/whatsapp/webhook), and your app reads the resulting inbound messages and statuses through the WhatsApp API —GET /api/whatsapp/messages. This page documents how that receiver behaves so you can reason about it (and host it yourself once per-customer WABAs land).
Event types
Meta posts a single JSON envelope to POST /api/whatsapp/webhook. Each entry
carries a changes[] array, and every change’s value may contain either or
both of these collections:
| Collection | Fired when |
|---|---|
value.messages[] |
A customer sends you a message (text, image, document, audio, video, sticker, interactive). Recorded as an inbound message. |
value.statuses[] → status: "sent" |
Meta accepted and submitted a message you sent. |
value.statuses[] → status: "delivered" |
The message reached the recipient’s device. |
value.statuses[] → status: "read" |
The recipient opened the message. |
value.statuses[] → status: "failed" |
The message could not be delivered (carries an errors[] array). |
WRNexus only acts on the four status values above (sent, delivered, read,
failed); any other status string in statuses[] is ignored. Keyword replies
of STOP / START (and their aliases) arrive as inbound messages[] and
additionally toggle consent — see
STOP / START consent below.
Verification handshake
GET /api/whatsapp/webhook
Meta’s callback-URL verification. WRNexus echoes the hub.challenge value as
text/plain when all of the following hold:
hub.modeequalssubscribe- a
VERIFY_TOKENis configured server-side (non-empty), and hub.verify_tokenmatches that configuredVERIFY_TOKEN.
This route is always live — no session auth and no WHATSAPP_ENABLED gate — so
Meta can verify the URL even before sending is switched on.
GET /api/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=<VERIFY_TOKEN>&hub.challenge=1158201444
→ 200 OK
Content-Type: text/plain; charset=utf-8
1158201444
If the mode is wrong, no token is configured, or the token does not match,
the endpoint responds 403 Forbidden:
{ "code": "FORBIDDEN", "message": "Webhook verification failed" }
Receiver
POST /api/whatsapp/webhook
The inbound-message + delivery-status receiver. Meta signs the raw request body with:
X-Hub-Signature-256: sha256=<hex-hmac>
The signature is HMAC-SHA256 keyed by the Meta app’s APP_SECRET, hex
encoded. WRNexus recomputes it over the exact raw bytes and compares in
constant time, rejecting any request whose signature does not match.
Dev note. When
APP_SECRETis unset (empty), the signature gate is skipped entirely. This is intended for local development only — production deployments always setAPP_SECRETso every delivery is verified.
The body is then parsed as JSON and each entry[].changes[].value is walked for
inbound messages and delivery statuses.
Responses
| Result | HTTP status | Body |
|---|---|---|
| Accepted and processed | 200 OK |
{ "ok": true } |
| Signature mismatch | 400 |
{ "code": "SIGNATURE_INVALID", "message": "Webhook signature verification failed" } |
| Body was not valid JSON | 400 |
{ "code": "INVALID_PAYLOAD", "message": "Webhook body was not valid JSON" } |
Payloads
Inbound message
entry[].changes[].value.messages[]. WRNexus correlates the account via
metadata.phone_number_id, stores the entire message object as the
message’s body_json, and records from, type, and the display number. The
fields WRNexus reads are annotated below; Meta includes additional fields
(e.g. timestamp, profile) that are persisted in body_json but not parsed
individually.
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "WABA_ID",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15550100", // stored as the message's to_number
"phone_number_id": "1029384756" // maps the payload to a whatsapp_accounts row
},
"contacts": [
{ "profile": { "name": "Asha" }, "wa_id": "919812345678" }
],
"messages": [
{
"id": "wamid.HBgM...", // wa_message_id — dedup key
"from": "919812345678", // stored as from_number
"timestamp": "1718712000",
"type": "text", // stored as msg_type
"text": { "body": "Hi!" } // text.body drives STOP/START consent
}
]
}
}
]
}
]
}
A non-text message (type: image, document, audio, video, sticker,
interactive, …) carries a type-specific object instead of text; the whole
object is still stored in body_json and surfaced through
GET /api/whatsapp/messages.
Delivery status
entry[].changes[].value.statuses[]. Each status updates the matching outbound
row (correlated by id → wa_message_id) to the new status, stamps the
corresponding timestamp column (sent_at / delivered_at / read_at /
failed_at), and copies errors[0].title into the row’s error when present.
{
"object": "whatsapp_business_account",
"entry": [
{
"id": "WABA_ID",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15550100",
"phone_number_id": "1029384756"
},
"statuses": [
{
"id": "wamid.HBgM...", // wa_message_id of the message you sent
"status": "delivered", // sent | delivered | read | failed
"timestamp": "1718712050",
"recipient_id": "919812345678"
}
]
}
}
]
}
]
}
A failed status carries an errors array. WRNexus stores the first
entry’s title as the row’s error:
{
"id": "wamid.HBgM...",
"status": "failed",
"timestamp": "1718712050",
"recipient_id": "919812345678",
"errors": [
{
"code": 131026,
"title": "Message undeliverable",
"error_data": { "details": "Message could not be delivered." }
}
]
}
STOP / START consent
When an inbound text message’s text.body is exactly a recognised keyword
(matched case-insensitively, after trimming surrounding whitespace), WRNexus
records a consent change in addition to
storing the message. On the shared WABA the opt-out/opt-in is attributed to the
org that most recently messaged that number.
| Keyword (whole message) | Consent result | Source |
|---|---|---|
stop, unsubscribe, cancel, end, quit |
opted_out |
keyword_stop |
start, unstop, subscribe |
opted_in |
keyword_start |
A keyword embedded in a longer sentence (e.g. “please don’t stop messaging
me”) is deliberately ignored, so consent never flips by accident. Once a number
is opted_out, POST /api/whatsapp/send to it is blocked until they opt back in
with START.
Try it with curl
The body must be signed with the app secret to pass verification. This computes
X-Hub-Signature-256 over the exact bytes you send:
APP_SECRET="your_app_secret"
BODY='{"object":"whatsapp_business_account","entry":[{"id":"WABA_ID","changes":[{"field":"messages","value":{"messaging_product":"whatsapp","metadata":{"display_phone_number":"15550100","phone_number_id":"1029384756"},"statuses":[{"id":"wamid.HBgM...","status":"delivered","recipient_id":"919812345678"}]}}]}]}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$APP_SECRET" | sed 's/^.* //')"
curl -sS -X POST https://whatsapp.wrnexus.com/api/whatsapp/webhook \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIG" \
--data-raw "$BODY"
# → {"ok":true}
Sign the raw bytes you transmit. If a proxy or client re-serialises the JSON (reordering keys or changing whitespace) the recomputed HMAC won’t match and the request is rejected with
SIGNATURE_INVALID.
Configure the webhook URL
The callback is registered once in the Meta App Dashboard, not from a WRNexus app screen:
- In Meta App Dashboard → WhatsApp → Configuration → Webhook, set the
Callback URL to
https://whatsapp.wrnexus.com/api/whatsapp/webhookand the Verify token to the value of the server’sVERIFY_TOKEN. - Meta calls
GET /api/whatsapp/webhookimmediately; the handshake above must echo the challenge for the save to succeed. - Subscribe the app to the
messagesfield so Meta posts both inbound messages and delivery statuses.
The relevant server environment variables (see .env.example):
| Variable | Purpose |
|---|---|
VERIFY_TOKEN |
Shared secret echoed during the GET handshake. |
APP_SECRET |
Meta app secret used to verify X-Hub-Signature-256 on the POST. |
PHONE_NUMBER_ID |
Maps incoming payloads to the WRNexus whatsapp_accounts row. |
WHATSAPP_ENABLED |
Gates outbound Graph calls only — the webhook receiver is always live. |
Idempotency
- Inbound messages deduplicate on Meta’s
wa_message_id(wamid.…): if a message with that id already exists it is silently skipped, so Meta’s own redeliveries never create duplicates. - Delivery statuses are an idempotent
UPDATEkeyed bywa_message_id, so re-posting the same status is harmless.
Responding & retries
- Acknowledge with
200 OK. WRNexus returns{ "ok": true }once the payload is parsed and persisted. - Retries are Meta’s. WRNexus runs no retry loop of its own for this endpoint — Meta retries on any non-2xx response per its Cloud API delivery policy. Because both paths above are idempotent, those retries are safe.
See also
- WhatsApp API — send messages, manage consent, and read the
message log (
GET /api/whatsapp/messages). - WhatsApp Business onboarding — getting a WABA, number, and templates approved.
- SMS Webhooks and Email Webhooks — the self-service, API-key webhook model for those products.