WhatsApp Cloud API Webhooks: Setup, Verify Token and Payloads

Set up WhatsApp Cloud API webhooks step by step: the callback URL, the hub.challenge verify token handshake, message payload examples, X-Hub-Signature-256 verification and local testing.

WhatsApp Cloud API webhooks

Every incoming WhatsApp message, status update and delivery receipt reaches your code the same way: a webhook from the WhatsApp Cloud API. Setting one up trips people in a specific spot — the verify token handshake — and then again when the payloads arrive nested four levels deep. This guide walks the whole path: callback URL, verification, payload shapes, signatures, and a sane local dev loop.

How Cloud API webhooks work

You configure a single callback URL for your Meta app. Meta first verifies it (a GET handshake), then delivers events (POSTs) for every webhook field you subscribe to — for WhatsApp, the one you almost always want is messages.

Requirements: public HTTPS URL with a valid certificate, answering within a few seconds. There is no way to point Meta directly at localhost — see the last section for the clean workaround.

Step 1: The verify token handshake

In the Meta App Dashboard open your app → WhatsApp → Configuration → Webhook. You enter two things: the callback URL and a verify token — any string you choose.

When you hit save, Meta sends:

GET /your/webhook?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=1158201444

Your endpoint must check the token and echo the challenge:

// Express
app.get('/webhook', (req, res) => {
  const { 'hub.mode': mode, 'hub.verify_token': token, 'hub.challenge': challenge } = req.query;
  if (mode === 'subscribe' && token === process.env.VERIFY_TOKEN) {
    return res.status(200).send(challenge);   // raw value, no JSON
  }
  res.sendStatus(403);
});

The two classic mistakes: responding with JSON ({"hub.challenge": …} fails — Meta wants the raw value) and confusing the verify token with the app secret (they are unrelated; the token is only used in this handshake).

After verification succeeds, click Manage and subscribe to the messages field — without this second step you stay silent.

Step 2: Read the payload

Deliveries are POSTs with a deeply nested envelope. An inbound text message:

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "WABA_ID",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "display_phone_number": "15550001111", "phone_number_id": "PHONE_ID" },
        "contacts": [{ "profile": { "name": "Alex Doe" }, "wa_id": "15557770000" }],
        "messages": [{
          "from": "15557770000",
          "id": "wamid.HBgL...",
          "timestamp": "1768472000",
          "type": "text",
          "text": { "body": "hello!" }
        }]
      }
    }]
  }]
}

Status updates (sent/delivered/read) arrive on the same URL with a statuses array instead of messages. Your handler should switch on what is present — and remember entry and changes are arrays; batches happen.

The fastest way to learn the shapes is to look at real traffic: point the callback URL at a free Webhook Bin for ten minutes of testing (it answers the GET handshake's hub.challenge echo requirement if you configure the bin response, or verify against your real endpoint first and then switch) and message your test number.

Step 3: Verify the signature

Every POST carries X-Hub-Signature-256: sha256=<hex> — HMAC-SHA256 of the raw body keyed with your app secret (Dashboard → App settings → Basic). Recompute and compare in constant time; the mechanics and language snippets are identical to GitHub's scheme, covered in Verify a webhook signature, and you can sanity-check values in the free HMAC verifier.

Self-hosted gateways: WAHA, Evolution API and friends

Not every WhatsApp integration uses Meta's hosted Cloud API. Self-hosted gateways — WAHA, Evolution API, uazapi and similar — run a WhatsApp Web session in a container and forward incoming messages to your webhook URL with much simpler payloads (we see plenty of WAHA traffic in our own webhook bin). The receiving patterns in this guide apply unchanged: inspect the gateway's payload in a bin first, then point it at your stable endpoint. No verify-token handshake, but also no Meta signature — check what auth headers your gateway can send and require them.

Local development without re-verifying

Meta's URL verification makes tunnels painful: every time your tunnel URL changes, you re-run the handshake. The fix is a stable public endpoint that never changes, relayed to wherever your code currently runs:

  1. Create a Webhook Relay bucket — its input URL is permanent.
  2. Verify that URL with Meta once.
  3. Forward to your machine: relay forward --bucket whatsapp http://localhost:3000/webhook.

Your laptop connects outbound, so no ports open, and restarting or moving your dev environment never touches the Meta configuration. The full walkthrough is in Test WhatsApp webhooks locally. Building the automation in n8n instead of code? See the n8n WhatsApp Cloud API webhook tutorial.