← Blog

·

How to test the WhatsApp webhook locally with ngrok

Updated on October 25, 2026

To test the WhatsApp webhook locally, run your server on your machine, expose the port through an HTTPS tunnel (ngrok or cloudflared) and register the public URL as the Callback URL in your Meta app dashboard. The server must answer the verification GET by returning hub.challenge and, on POSTs, validate the X-Hub-Signature-256 header with the app secret. You then see real events arrive in your terminal, with no deploy.

This guide includes a complete Node.js server, the commands for both tunnels, the test button in Meta’s dashboard and the most common errors at this stage.

Why you need a tunnel

Meta only delivers webhooks to a public HTTPS URL. The webhooks documentation requires a valid TLS certificate and states that self-signed certificates are not supported. http://localhost:3000 meets neither requirement.

A tunnel fixes this: an agent runs on your machine, opens an outbound connection to the service and gets a public HTTPS URL that forwards requests to your local port. No router port forwarding or static IP needed.

ToolCommandGenerated URLNotes
ngrokngrok http 3000HTTPS, shown in the agent consoleRequires an account and authtoken; has a traffic inspector and request replay
cloudflared (Quick Tunnel)cloudflared tunnel --url http://localhost:3000Temporary subdomain on trycloudflare.comNo account needed; the address changes on every run; testing only

How Meta talks to your endpoint

There are two different requests on the same URL:

  1. Verification GET. When you save or edit the Callback URL or verify token in the dashboard, Meta sends a GET with hub.mode=subscribe, hub.challenge (a random string) and hub.verify_token (the value you typed in the dashboard). If the token matches, respond 200 with the hub.challenge value as the body. If it doesn’t, respond 403 or any non-200 code.
  2. Event POST. Every incoming message or status change arrives as JSON with the header X-Hub-Signature-256: sha256=<hash>. The hash is an HMAC-SHA256 of the body, keyed with the app secret. Respond 200 to acknowledge.

If a POST fails, Meta retries immediately and then several more times, with decreasing frequency, for up to 7 days. Deliveries not acknowledged in that period are dropped. So the same event can arrive more than once, and your server must deduplicate. Meta also notes there is no API to fetch past webhooks: store what you receive.

Want to try it while you read? Create a Vectalk account and send your first message through the official API. Create free account →

Node.js test server

The example uses Express. The key detail is keeping the raw request body to compute the signature. If you compute the HMAC over re-serialized JSON, the bytes may change and the comparison fails.

mkdir webhook-local && cd webhook-local
npm init -y
npm install express
// server.js
import express from 'express';
import crypto from 'node:crypto';

const { VERIFY_TOKEN, APP_SECRET, PORT = 3000 } = process.env;
const app = express();

// Keep the raw body to validate the signature
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; }
}));

// 1) Verification (GET)
app.get('/webhook', (req, res) => {
  const mode = req.query['hub.mode'];
  const token = req.query['hub.verify_token'];
  const challenge = req.query['hub.challenge'];

  if (mode === 'subscribe' && token === VERIFY_TOKEN) {
    console.log('Webhook verified');
    return res.status(200).send(challenge);
  }
  return res.sendStatus(403);
});

// 2) Events (POST)
app.post('/webhook', (req, res) => {
  const header = req.get('x-hub-signature-256') || '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', APP_SECRET)
    .update(req.rawBody || '')
    .digest('hex');

  const valid = header.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));

  if (!valid) {
    console.warn('Invalid signature');
    return res.sendStatus(401);
  }

  res.sendStatus(200); // acknowledge fast

  for (const entry of req.body.entry || []) {
    for (const change of entry.changes || []) {
      const v = change.value || {};
      for (const m of v.messages || []) console.log('message', m.from, m.type, m.id);
      for (const s of v.statuses || []) console.log('status', s.id, s.status);
    }
  }
});

app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`));

To run it, add "type": "module" to package.json and start it with the variables:

VERIFY_TOKEN=any-string-you-choose APP_SECRET=your_app_secret node server.js

VERIFY_TOKEN is any string you define and repeat in the dashboard. APP_SECRET is the app secret, found under App settings → Basic in the developer dashboard. Never commit it to the repository.

Step by step with ngrok

  1. Create an ngrok account and install the agent (on macOS, brew install ngrok).
  2. Connect the agent to your account:
ngrok config add-authtoken YOUR_AUTHTOKEN
  1. With the server running on port 3000, open the tunnel:
ngrok http 3000
  1. Copy the HTTPS forwarding URL shown in the console.
  2. In your Meta app dashboard, go to WhatsApp → Webhooks → Configuration (menu names may vary), paste https://YOUR-URL/webhook into Callback URL, enter the same VERIFY_TOKEN in Verify token and click Verify and save. Your terminal should print “Webhook verified”.
  3. In the field list, subscribe to the messages field.
  4. Click Test next to messages. Meta sends a sample payload that should show up in your terminal. According to Meta’s test webhook guide, the messages test works with the app in either development or live mode.
  5. Send a real message from your phone to the test or production number connected to the app and watch the event arrive.

The ngrok agent includes a traffic inspector, usually at http://localhost:4040, showing headers, body and response for each request. With full capture mode enabled on your account, the Traffic Inspector lets you replay a request to your server without waiting for Meta, which speeds up parser debugging a lot.

Step by step with cloudflared

If you don’t want to create an account, Cloudflare’s Quick Tunnel works for quick tests:

cloudflared tunnel --url http://localhost:3000

The command prints a trycloudflare.com URL. Use it in Meta’s dashboard the same way. Per Cloudflare’s documentation, Quick Tunnels are for testing and development, have no uptime guarantee, allow up to 200 in-flight requests and generate a new hostname on every run. Since the URL changes, you have to verify again in the dashboard each time you restart the tunnel.

Common errors and fixes

SymptomLikely causeFix
“The callback URL couldn’t be validated” in the dashboardToken mismatch, server down, wrong route, or JSON returned instead of the challenge textReturn only the hub.challenge value, with status 200, on the exact registered route
Verification GET works but no POST arrivesmessages field not subscribed, or the WhatsApp account isn’t linked to the appSubscribe to the field and check which app the account is linked to
Every signature is invalidHMAC computed over re-serialized JSON, wrong secret, or header compared without the sha256= prefixUse the raw body and the app secret (not the access token)
The same event arrives several timesSlow or non-200 response triggering retriesRespond 200 before processing and deduplicate by id
Stopped working after restarting the tunnelThe public URL changedUpdate the Callback URL and verify again

One more caution: the webhook is configured per app. If you point the production app’s Callback URL at your machine, production stops receiving events. Use a separate development app for local testing.

From testing to production

A tunnel is a development tool. In production, the endpoint needs its own domain, a valid certificate, a queue to process events outside the request and storage for payloads. The message status guide shows how to handle sent, delivered, read and failed. When webhooks carry errors, the WhatsApp API errors table helps you decide what to do. If the app still uses a temporary token, switch to a permanent access token before going live.

How this works with Vectalk

With Vectalk, events are Meta’s own, in the same entry[].changes[].value format, so the parser above works unchanged. What changes is registration: instead of Meta’s dashboard, you register the URL through the API with instance_id, url (HTTPS) and name, and you can have several URLs per instance, all receiving the same event.

curl -X POST https://apis.vectalk.com.br/api/webhooks \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "instance_id": "YOUR_INSTANCE",
    "url": "https://YOUR-TUNNEL-URL/webhook",
    "name": "Local dev"
  }'

This lets you register the tunnel URL as an extra webhook without removing the production one, and turn it off later with active: false on the update webhook endpoint. Vectalk expects a 200 within 20 seconds and resends the event otherwise, according to the webhooks documentation. Before enforcing signature validation in this flow, check the documentation and the tunnel inspector to see which headers arrive.

Create your account at apps.vectalk.com.br/register.

Frequently asked questions

Can I register http://localhost as a WhatsApp webhook?

No. Meta requires a public HTTPS URL with a valid certificate. To test on your machine, use a tunnel such as ngrok or cloudflared.

What should I return on the verification GET?

Status 200 with the hub.challenge value as the body, no JSON and no quotes, as long as hub.verify_token matches the token you registered.

Which secret validates X-Hub-Signature-256?

The App Secret, not the access token or the verify token. The hash is an HMAC-SHA256 of the request body, compared with the value after sha256=.

Why do I receive the same webhook twice?

Because Meta retries when it doesn’t get a 200, for up to 7 days. Respond quickly and deduplicate by the message or status id.

Can I use ngrok in production?

It is designed to expose local services during development. For production, host the endpoint on a server with its own domain and certificate.


Official sources consulted on October 25, 2026: Create a webhook endpoint (WhatsApp), Set up a WhatsApp echo bot, Graph API webhooks: getting started, ngrok: Share Localhost Quickstart, ngrok: Test Webhooks Locally, ngrok: Traffic Inspector, Cloudflare Quick Tunnels, Vectalk docs: webhooks, Vectalk docs: update webhook.

Try the official WhatsApp API today

Create your account, generate a token and send your first message with the quick-start guide. Same payloads and webhooks as Meta's Cloud API.