---
title: "WhatsApp Cloud API Webhooks: Setup, Verify Token and Payloads | WebhookRelay"
meta:
  "og:description": "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."
  "og:title": "WhatsApp Cloud API Webhooks: Setup, Verify Token and Payloads"
  description: "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."
---

![Stripes](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/images/stripes.svg)

# **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](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/images/blog/heroes/route.jpg)

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](#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](#step-1-the-verify-token-handshake)

In the [Meta App Dashboard](https://developers.facebook.com/apps/) 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](#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](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/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](#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](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/blog/verify-webhook-signature/), and you can sanity-check values in the free [HMAC verifier](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/hmac-verification/).

## [Self-hosted gateways: WAHA, Evolution API and friends](#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](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/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](#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](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/blog/receive-whatsapp-webhooks-locally/). Building the automation in n8n instead of code? See the [n8n WhatsApp Cloud API webhook tutorial](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/docs/tutorials/n8n/whatsapp-cloud-api-webhook/).

## [Related reading](#related-reading)

- [Test WhatsApp webhooks locally](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/blog/receive-whatsapp-webhooks-locally/)
- [n8n WhatsApp Cloud API webhook trigger](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/docs/tutorials/n8n/whatsapp-cloud-api-webhook/)
- [Verify a webhook signature](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/blog/verify-webhook-signature/)
- [How to test webhooks](https://webhookrelay.com/blog/whatsapp-cloud-api-webhooks/blog/how-to-test-webhooks/)