Test WhatsApp Cloud API webhooks locally
Receive WhatsApp Business Cloud API webhooks, such as incoming messages and status updates, on localhost with a Horizon tunnel.
Receive WhatsApp messages and message status updates on your laptop while you build, with an HTTPS URL Meta can reach.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Next.js app that uses the App Router and runs on port 3000
- A Meta developer app with the WhatsApp product and a WhatsApp Business Account
- Permission
whatsapp_business_messagingfor themessagesfield, andwhatsapp_business_managementfor the other fields
Start your app
WhatsApp payloads have object set to whatsapp_business_account. Each event sits in entry[].changes[], with the webhook field in changes[].field. Incoming messages arrive in value.messages. Delivery updates for messages you send arrive in value.statuses.
Meta checks your endpoint in two ways. First it sends a GET request with the query parameters hub.mode, hub.verify_token and hub.challenge. Your handler compares hub.verify_token with a token you chose and answers with the hub.challenge value. After that, Meta sends event notifications as POST requests. Each one carries an X-Hub-Signature-256 header: sha256= followed by an HMAC SHA-256 (a keyed hash) of the request body, made with your app's App Secret. Read the raw body with request.text() before you parse it, and compare with crypto.timingSafeEqual.
import { createHmac, timingSafeEqual } from "node:crypto";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const mode = searchParams.get("hub.mode");
const token = searchParams.get("hub.verify_token");
const challenge = searchParams.get("hub.challenge");
const isValid =
mode === "subscribe" && token === process.env.WHATSAPP_VERIFY_TOKEN && challenge;
if (!isValid) {
return new Response("Forbidden", { status: 403 });
}
return new Response(challenge, { status: 200 });
}
export async function POST(request: Request) {
const appSecret = process.env.WHATSAPP_APP_SECRET;
if (!appSecret) {
return new Response("Missing WHATSAPP_APP_SECRET", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-hub-signature-256") ?? "";
const expected = `sha256=${createHmac("sha256", appSecret).update(body).digest("hex")}`;
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
const payload = JSON.parse(body);
for (const entry of payload.entry ?? []) {
for (const change of entry.changes ?? []) {
const messageCount = change.value?.messages?.length ?? 0;
console.log(`Received WhatsApp ${change.field} event with ${messageCount} message(s)`);
}
}
return new Response("ok", { status: 200 });
}Choose a verify token yourself. It can be any string. Find the App Secret in the App Dashboard under App settings, Basic.
WHATSAPP_VERIFY_TOKEN=replace-with-a-long-random-string
WHATSAPP_APP_SECRET=replace-with-your-app-secretStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so the callback URL you saved in Meta would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Meta requires HTTPS, and every Horizon tunnel has it. Keep this terminal open.
Add the webhook in the App Dashboard
- Open your app in the App Dashboard.
- Go to WhatsApp, Configuration.
- In the webhook section, set Callback URL to
https://my-app.hrzn.run/api/webhooks/whatsapp. - Set Verify token to the same value as
WHATSAPP_VERIFY_TOKEN. - Select Verify and save. Meta sends the verification
GETrequest at this point. - Subscribe to the webhook fields you need, starting with
messages.
The Configuration panel is where Meta documents WhatsApp webhooks. Some app setups show it under Use cases, Customize, Configuration instead.
Send a test event
In WhatsApp, Configuration, send a test payload to your endpoint from the webhook fields table.
To send a real event, message your WhatsApp Business number from another phone. Your handler receives a messages event. Messages you send from the API produce statuses updates such as sent, delivered and read.
Check it works
When you select Verify and save, the Horizon terminal prints a GET line with status 200.
When an event arrives, it prints one POST line:
POST 200 /api/webhooks/whatsappYour app terminal prints a line like:
Received WhatsApp messages event with 1 message(s)A text message looks like this. Fields can differ for other message types.
{
"object": "whatsapp_business_account",
"entry": [
{
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
"messages": [{ "from": "16505551234", "id": "wamid.…", "timestamp": "1749416383", "type": "text", "text": { "body": "Does it come in another color?" } }]
}
}
]
}
]
}Troubleshooting
Meta can't validate the callback URL or verify token
The Horizon terminal shows a GET line with [403], or no line at all.
- Check that
WHATSAPP_VERIFY_TOKENis identical to the Verify token you typed in the App Dashboard. - Check that the callback URL ends with
/api/webhooks/whatsapp. - Check that the handler answers with the
hub.challengevalue and nothing else. - Restart
npm run devafter you edit.env.local.
The signature doesn't match
The Horizon line shows [401] for a POST request.
- Check that
WHATSAPP_APP_SECRETis the right secret. Use the App Secret from the same app that owns the WhatsApp product. - Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - Restart
npm run devafter you edit.env.local.
Meta retries the same event
Meta treats any status other than 200 as a failed delivery and retries for up to 7 days. Return 200 fast and do slow work afterwards. Handle duplicates by the message id.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and update the callback URL in the App Dashboard if it differs. -s needs a subdomain you reserved, see Pricing.
Nothing reaches your app
- Check that the Horizon terminal is still running. If its last line is
Connection lost. Reconnecting…, wait forReconnected. - Check that the
npm run devterminal is running on port 3000.
Next steps
- Read Meta's WhatsApp Cloud API webhooks guide for every field.
- Read the messages webhook reference for the payload shape.
Test TikTok webhooks locally
Receive TikTok for Developers webhook events on localhost with a Horizon tunnel, and verify the TikTok-Signature header.
Test X (Twitter) webhooks locally
Receive X Account Activity API webhook events on localhost with a Horizon tunnel, and pass the CRC check and signature verification.