All articles
Mobile messaging events passing through Meta-style webhook verification and a secure tunnel to a localhost application.
WhatsApp Cloud APIMeta webhookslocalhostwebhook security

Test WhatsApp Cloud API Webhooks on Localhost

To test a WhatsApp Cloud API webhook on localhost, expose your local endpoint through HTTPS, implement Meta's GET verification challenge, then verify each POST request's X-Hub-Signature-256 against the raw body. Register the tunnel URL in your Meta app, subscribe the WhatsApp Business Account to messages, and send a test message to receive a real payload without deploying.

WhatsApp webhooks use two different verification flows

The most important distinction is that webhook setup and webhook delivery are authenticated differently. During setup, Meta sends a GET request containing hub.mode, hub.verify_token, and hub.challenge. Your endpoint compares the verify token and returns the challenge as plain text. Later, event deliveries are POST requests; those should be authenticated by validating the HMAC signature created with your Meta App Secret.

A verify token is a random string you choose; it is not the WhatsApp access token and not the App Secret. Returning the challenge proves control of the callback endpoint. It does not authenticate future POST requests. Meta's official WhatsApp webhook guide covers callback configuration, subscriptions, and webhook fields.

Create a Next.js App Router endpoint

The route below handles both phases. Reading POST data with request.text() preserves the exact bytes needed for signature verification.

// app/api/webhooks/whatsapp/route.ts
import crypto from 'node:crypto';
import { NextRequest } from 'next/server';

export const runtime = 'nodejs';

export async function GET(request: NextRequest) {
  const mode = request.nextUrl.searchParams.get('hub.mode');
  const token = request.nextUrl.searchParams.get('hub.verify_token');
  const challenge = request.nextUrl.searchParams.get('hub.challenge');

  if (mode === 'subscribe' && token === process.env.META_VERIFY_TOKEN) {
    return new Response(challenge ?? '', { status: 200 });
  }
  return new Response('Forbidden', { status: 403 });
}

export async function POST(request: Request) {
  const rawBody = await request.text();
  const supplied = request.headers.get('x-hub-signature-256') ?? '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.META_APP_SECRET!)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(supplied);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const payload = JSON.parse(rawBody);
  await enqueueWhatsAppPayload(payload);
  return new Response('EVENT_RECEIVED', { status: 200 });
}

Do not call request.json() and then reconstruct the JSON for HMAC. Whitespace, escaping, or key ordering can change, producing a different digest. If you use Express, capture a Buffer before a global JSON parser. The general webhook signature guide explains raw-body handling across frameworks.

Start a tunnel and configure the callback

  1. Run the Next.js app locally, normally with npm run dev on port 3000.
  2. Run npx portpreview 3000 in a separate terminal.
  3. Set META_VERIFY_TOKEN to a random value and META_APP_SECRET to the App Secret from Meta's app settings.
  4. In the Meta developer dashboard, open the WhatsApp product's Configuration page.
  5. Set the callback URL to https://YOUR-TUNNEL.portpreview.dev/api/webhooks/whatsapp and enter the same verify token.
  6. After verification succeeds, subscribe to the messages field for the WhatsApp Business Account.

The tunnel must remain active during both the GET challenge and subsequent POST deliveries. A URL copied from an older session may resolve but no longer forward to your machine, so confirm the exact callback whenever the local tunnel changes.

Test the GET challenge independently

Before using the dashboard, reproduce the request locally:

curl -i \
  "http://localhost:3000/api/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=${META_VERIFY_TOKEN}&hub.challenge=123456"

A correct response is status 200 with body 123456, not JSON and not "123456" with quotes. If the token is wrong, 403 is appropriate. Do not log query parameters because the verify token appears there.

Understand a messages payload before writing business logic

WhatsApp wraps data several levels deep. A typical notification has object: "whatsapp_business_account", an entry array, a changes array, and a change whose field is messages. Inside value, incoming user content appears in messages; delivery, read, and failure updates for messages you sent appear in statuses.

for (const entry of payload.entry ?? []) {
  for (const change of entry.changes ?? []) {
    if (change.field !== 'messages') continue;
    for (const message of change.value.messages ?? []) {
      await handleInboundMessage({
        id: message.id,
        from: message.from,
        type: message.type,
        text: message.text?.body,
      });
    }
    for (const status of change.value.statuses ?? []) {
      await updateDeliveryStatus(status.id, status.status);
    }
  }
}

Do not assume every notification contains a text message. Images, audio, documents, locations, interactive replies, system messages, and status-only payloads have different shapes. Keep a dispatcher keyed by message.type, validate optional fields, and retain unknown event types for review rather than crashing.

Verify the POST signature correctly

The X-Hub-Signature-256 value uses the form sha256=<hex digest>. Compute HMAC-SHA256 over the raw request bytes using the Meta App Secret. The permanent or temporary WhatsApp access token is used for Graph API calls; it is not the HMAC key. Use constant-time comparison and reject a missing signature.

Keep local verification enabled. Anyone who learns a tunnel URL can POST arbitrary JSON to it. Without verification, a forged event might trigger automatic replies, mutate CRM records, or expose customer state. Rotate the App Secret if it is committed, printed, or shared accidentally.

Acknowledge quickly and deduplicate messages

Return 200 after authenticating and durably queuing the event. Do not wait while downloading media, calling an LLM, or updating several services. Providers retry deliveries when acknowledgements fail, and network ambiguity means duplicates are normal.

Use the WhatsApp message id as the idempotency key for inbound messages and status objects. Put a unique constraint around processed IDs. Status transitions can legitimately progress from sent to delivered to read, so deduplicate each relevant transition without discarding a later state.

Troubleshoot WhatsApp webhook setup

The callback URL could not be validated

Test the GET route through the public URL. Ensure it accepts GET, compares the exact verify token, and responds with only the challenge. Redirects, auth middleware, locale rewrites, or a JSON wrapper can break verification. Confirm the environment variable is loaded by the running dev process.

Verification succeeds but no messages arrive

Callback verification alone does not subscribe the WhatsApp Business Account to fields. Confirm the messages subscription in the dashboard and that the phone number belongs to the expected app and account. Send a message from an allowed recipient if the app is still in development mode.

Every POST fails signature validation

The usual causes are using the access token instead of App Secret, hashing parsed JSON, omitting the sha256= prefix, or comparing different encodings. Log body length and whether the header exists, but never print the secret or full customer payload.

Text messages work but media handling fails

Media notifications contain an ID, not necessarily the file bytes. Fetch media through the Graph API with a valid access token, then download it. Keep that slower workflow outside the webhook acknowledgement path.

The local endpoint sees duplicate events

Inspect response status and latency, add durable idempotency, and replay one captured event after each fix. The webhook replay guide shows how to avoid sending a new real message for every code change.

Protect customer data during local tests

  • Use test phone numbers and synthetic conversations where possible.
  • Redact phone numbers, message bodies, media URLs, contacts, and profile names from logs.
  • Store App Secret, access tokens, and verify token only in ignored environment files or a secret manager.
  • Restrict who can view tunnel captures and delete them after the debugging session.
  • Validate object, field, and account identifiers before executing business actions.

A tunnel makes iteration fast, but it also brings production-shaped personal data to a developer machine. Apply the controls in the tunnel security checklist before testing with real users.

Frequently asked questions

How do I test a WhatsApp Cloud API webhook on localhost?
Run your webhook handler locally, expose it with an HTTPS tunnel, register the public callback URL and verify token in Meta, subscribe to messages, then send a test message.
What should a WhatsApp webhook verification endpoint return?
For a valid GET request where hub.mode is subscribe and hub.verify_token matches, return the hub.challenge value as plain text with HTTP 200.
How do I verify WhatsApp webhook POST requests?
Compute HMAC-SHA256 over the exact raw request body with the Meta App Secret, prefix the hex digest with sha256=, and timing-safely compare it with X-Hub-Signature-256.
Why does my verified WhatsApp webhook receive no events?
Callback verification does not automatically subscribe every field. Confirm the WhatsApp Business Account is subscribed to messages and that your test sender and phone number are available to the app.