← All guides
WhatsApp BusinessJuly 12, 20265 min read

Test WhatsApp Business (Cloud API) Webhooks on Localhost

Receive WhatsApp Cloud API webhooks on your whatsapp webhook localhost setup, handle the verify challenge, and filter and transform messages with Relayers.

Testing WhatsApp Cloud API webhooks against a local server is annoying for two reasons. First, Meta only talks to a public HTTPS URL, so localhost:3000 is invisible to it. Second, before Meta sends you a single message, it fires a GET verification request carrying hub.mode, hub.verify_token, and hub.challenge — and your endpoint has to echo the challenge back or the webhook is never saved. If that handshake happens against a URL you can't reach from your laptop, you're stuck editing code, redeploying, and reading server logs in the cloud just to iterate on a message handler.

This guide wires WhatsApp Cloud API to your local machine with Relayers, including the verification handshake, so both the GET challenge and real POST payloads land on your local handler.

What you'll build

Meta calls a stable public Relayers URL. Relayers forwards every request — the GET verification and the POST events — down a tunnel to your machine, with the body and headers untouched.

Prerequisites

1Create an endpoint in Relayers

Open the desktop app, go to Endpoints, and create one. Copy its ingest URL — it looks like:

https://api.relayers.app/v1/webhooks/wep_9f2c1a7b

That's the public URL you'll give Meta. Use Send test now if you want to confirm the endpoint is live before touching the Meta dashboard.

Then start the tunnel so events reach your machine. Log in once, then listen:

relayers login
relayers listen --forward localhost:3000

Leave that running. Anything hitting your endpoint is now forwarded to localhost:3000.

2Configure the webhook in the Meta App Dashboard

In the Meta App Dashboard, open your app and go to WhatsApp → Configuration → Webhooks. Set:

When you save, Meta immediately sends a GET to the callback URL:

GET /v1/webhooks/wep_9f2c1a7b?hub.mode=subscribe&hub.verify_token=my-local-verify-token&hub.challenge=1158201444

Relayers forwards that GET through the tunnel to localhost:3000. Your local handler must verify the token and echo the challenge as the raw response body:

// GET handler
if (req.query["hub.verify_token"] === "my-local-verify-token") {
  res.status(200).send(req.query["hub.challenge"]);
} else {
  res.sendStatus(403);
}

If the echoed challenge matches, Meta marks the webhook verified. Now Subscribe to the messages field so message and status events start flowing.

3Forward events to localhost

Forwarding is driven by a Rule on the endpoint. In Rules, add a rule with:

Rules run top-to-bottom, first match wins, and they're reorderable. With an empty filter and a tunnel destination, every WhatsApp event — verification and real payloads — reaches your handler. Send yourself a WhatsApp message and watch it arrive. Inspect the raw payload any time in Events with the payload inspector, and use retry to replay a delivery while you iterate.

4Route and filter with JQ

WhatsApp payloads are noisy: you get incoming messages under value.messages[] and delivery/read receipts under value.statuses[], across types like text, image, button, and interactive. Often you only care about incoming text.

Tighten the rule's filter so it only forwards incoming text messages:

.entry[].changes[].value.messages[] | select(.type == "text")

Now status callbacks and non-text messages won't hit that destination. Add more rules below it for other cases — for example a separate rule matching .statuses[] that forwards to a different port or URL.

5Transform the payload

The raw WhatsApp shape is deeply nested. Use a Transformation (jq, jsonata, javascript, or go_template) to flatten it before delivery. Here's a jq transform that reduces each incoming text message to the fields you actually use:

.entry[].changes[].value.messages[]
| { from: .from, text: .text.body, timestamp: .timestamp }

Given a real WhatsApp payload, that yields:

{
  "from": "15551234567",
  "text": "hello there",
  "timestamp": "1721000000"
}

Build and check it in the transformation test playground before attaching it to the rule.

A note on signatures

WhatsApp signs every POST with an X-Hub-Signature-256 header (an HMAC-SHA256 of the raw body using your app secret). Relayers forwards the original request body and headers untouched, so that header — and the exact bytes it was computed over — arrive intact. Your local signature verification runs exactly as it will in production. Just remember: verify against the raw body, not a re-serialized JSON object, or the HMAC won't match. (For the GET challenge there's no signature; you gate that with the verify token.)

Going to production

When your handler is ready to deploy, you don't touch the Meta dashboard again — the callback URL stays the same. Just change where the rule points:

To keep dev, staging, and prod in sync, use Export to save your endpoints, rules, and transformations as a JSON bundle, then Import it into another workspace. Same routing logic, different destinations.

Troubleshooting

Wrap-up

That's the whole loop: one public endpoint, the verify handshake handled through the tunnel, JQ rules to keep only the events you want, and a transform to flatten them — all against real Meta traffic on your own machine. When you ship, flip the destination and keep the same URL.

Download the app and create your first endpoint at relayers.app.