Start typing to search the documentation.

to navigate·to open

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 APIGET /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.mode equals subscribe
  • a VERIFY_TOKEN is configured server-side (non-empty), and
  • hub.verify_token matches that configured VERIFY_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_SECRET is unset (empty), the signature gate is skipped entirely. This is intended for local development only — production deployments always set APP_SECRET so 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 idwa_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." }
    }
  ]
}

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:

  1. In Meta App Dashboard → WhatsApp → Configuration → Webhook, set the Callback URL to https://whatsapp.wrnexus.com/api/whatsapp/webhook and the Verify token to the value of the server’s VERIFY_TOKEN.
  2. Meta calls GET /api/whatsapp/webhook immediately; the handshake above must echo the challenge for the save to succeed.
  3. Subscribe the app to the messages field 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 UPDATE keyed by wa_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