WhatsApp Cloud API Webhooks on Localhost: The Verify-Token Handshake, Retries and App Secret Setup
You start your WhatsApp bot on localhost:3000, open a tunnel, paste the public URL into the Meta App Dashboard, click Verify and save, and get a red error. Or it works once, you restart the tunnel the next morning, and the dashboard wants verifying all over again.
Both problems share a cause: Meta’s webhook onboarding is a handshake, not a simple “send me events” switch. Meta first sends a GET carrying a hub.challenge value. Only after you answer it correctly does it start delivering signed POST requests. If you fail to acknowledge those with a 200, Meta keeps retrying for up to 7 days on the WhatsApp Business Platform.
This guide covers the handshake, the retry behavior and the app-secret setup, then shows why a stable subdomain turns a recurring chore into a one-time registration.
TL;DR
- Meta verifies your callback URL with a
GETcontaininghub.mode,hub.verify_tokenandhub.challenge. Return HTTP 200 with the challenge value, but only if the token matches. - The verify token is a string you invent. The app secret is issued by Meta. They are different credentials.
- Verification alone delivers nothing. You must also subscribe to the
messagesfield. - WhatsApp retries failed deliveries for up to 7 days, so expect duplicates and stale events.
- Meta sends a new verification
GETwhenever you set or edit the callback URL or verify token. A changing tunnel URL therefore means repeating the handshake. A stable subdomain means you register once.
Why Meta can’t call localhost
Meta’s servers sit on the public internet; your laptop sits behind NAT with no inbound ports open. Meta also requires a valid TLS certificate on your endpoint, and its documentation states that self-signed certificates are not supported.
So a local integration needs three things:
- A server that answers both
GET(verification) andPOST(events) on one path. - A public HTTPS URL forwarding to it.
- Because the dashboard stores one callback URL per app, a URL that stays the same between sessions.
Step 1: The verify-token handshake
In the Meta App Dashboard, open WhatsApp → Configuration. If you created your app with the “Connect with customers through WhatsApp” use case, the panel is under Use cases → Customize → Configuration instead. You enter a Callback URL and a Verify token. The token is any string you choose; Meta does not issue it. Meta sends a verification request whenever you set or edit either field:
GET /webhook?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=1158201444 | Parameter | Meaning |
|---|---|
hub.mode | Always subscribe during verification |
hub.verify_token | The string you typed into the dashboard |
hub.challenge | A random value generated by Meta |
Meta’s documentation sets the pass condition: respond with status 200 and the hub.challenge value. If you answer anything else, Meta treats the endpoint as unverified and sends no webhooks.
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 === process.env.VERIFY_TOKEN) { // Raw challenge, plain text, no JSON wrapper return res.status(200).type('text/plain').send(challenge); } return res.sendStatus(403); }); In FastAPI you cannot name a Python parameter hub.mode, so use Query(alias="hub.mode"). PHP has a related quirk: it converts periods in query parameter names to underscores, so read hub_mode, hub_verify_token and hub_challenge there.
Common handshake failures
| Symptom | Likely cause | Fix |
|---|---|---|
| “Callback URL or verify token couldn’t be validated” | JSON response, non-200 status, or a one-character token mismatch | Return the raw challenge as plain text with 200 |
| Browser shows a missing-parameter error | You opened the URL without Meta’s query string | Expected; only Meta’s GET carries hub.challenge |
| Timeout during verification | Server or tunnel not running yet | Start the server, then the tunnel, then click verify |
| Handshake passes, nothing arrives | No webhook field subscribed | Subscribe to messages |
The timeout row is the localhost-specific one. Meta calls your URL live when you save, so your server and tunnel must already be up.
Verify token vs. app secret
People mix these up constantly, and Meta uses them for different jobs.
- The verify token is chosen by you and used in the
GEThandshake. Hookdeck’s WhatsApp guide notes it is only used once, during setup. - The app secret is issued by Meta, found under App settings → Basic, and is the key used to sign every event delivery.
Keep them in separate environment variables, for example VERIFY_TOKEN and META_APP_SECRET. If you use the verify token where the app secret belongs, every real message will fail validation.
Each POST carries an X-Hub-Signature-256 header in the form sha256=<hex>, an HMAC-SHA256 of the payload keyed with your app secret. Meta says validating it is optional but recommended. Compute the HMAC over the raw request bytes, before any JSON middleware touches the body. Hookdeck notes that Meta signs an escaped-Unicode form of the payload, so a body you parsed and re-serialized will not match.
const crypto = require('crypto'); // express.raw() keeps the exact bytes Meta signed app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { const expected = 'sha256=' + crypto .createHmac('sha256', process.env.META_APP_SECRET) .update(req.body) .digest('hex'); const received = req.get('x-hub-signature-256') || ''; const valid = received.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)); if (!valid) return res.sendStatus(401); res.sendStatus(200); // acknowledge first queue.push(JSON.parse(req.body)); // then process asynchronously }); The signature carries no timestamp. Hookdeck’s Facebook webhooks guide points out that a captured, validly signed payload would still verify if replayed, so deduplicate on message IDs instead of treating the signature as proof of freshness.
Step 2: Subscribe to the messages field
Verification proves the URL is yours; it does not pick your events. After it succeeds, the dashboard shows a list of webhook fields. For a chatbot or inbox, subscribe to messages, which carries inbound customer messages and the status of messages you send (sent, delivered, read, failed).
Meta’s documentation lists the permissions: whatsapp_business_messaging for messages webhooks and whatsapp_business_management for all other webhooks.
If dashboard test events arrive but real messages do not, check that your app is subscribed to the WhatsApp Business Account itself. Both Hookdeck and Webhook Relay report this failure mode: the URL is verified, but the app was never subscribed to the WABA. The fix is POST /{WABA_ID}/subscribed_apps, and GET on the same endpoint confirms it. Also confirm your app is in Live mode, because Meta notes that some webhooks are not sent to apps in Dev mode.
Step 3: Retries, duplicates and the 7-day window
Meta treats anything other than HTTP 200 as a failed delivery. The retry window depends on the product:
- WhatsApp Business Platform: Meta’s documentation says delivery is retried immediately and then with decreasing frequency for up to 7 days, after which unacknowledged webhooks are dropped.
- Generic Graph API webhooks: Meta’s “Get started with webhooks” page describes retries over 36 hours, after which unacknowledged updates are dropped.
These describe different products, so they do not contradict each other. For WhatsApp, plan around 7 days. One exception: for numbers using Meta’s “No Storage” configuration, webhook retries are limited to 1 hour, and an undeliverable incoming-message webhook is dropped with error code 131035.
Meta also warns that retries go to all apps subscribed to the WABA’s webhooks, which can produce duplicate notifications. That has four consequences.
Acknowledge first, work second. Return 200, then do slow work (database writes, LLM calls, replies) from a queue. Meta does not publish one universal timeout, and BSP docs vary. 360dialog, for example, documents a hard five-second limit for a 200, so keep your handler fast.
Deduplicate. Key inbound messages on the message ID (wamid...) and status events on status ID plus status value, since one outbound message legitimately produces sent, delivered and read.
Don’t trust arrival order. Hookdeck notes that WhatsApp makes no ordering guarantee, so a read status can arrive before delivered. Use the timestamp field inside each event rather than your receive time.
Expect stale events after a local outage. If your laptop is closed overnight and the tunnel is down, Meta keeps retrying the registered URL, and a burst of old events can arrive when you reconnect. Raise your body limit too. Meta says webhook payloads can be up to 3 MB, and a 413 from your server counts as a failed delivery that Meta retries.
Meta offers no API for fetching historical webhook data, so log raw payloads on receipt if you need them later.
Why a stable subdomain changes the workflow
Meta stores one callback URL per app, and editing it repeats the handshake. If your tunnel hands out a random hostname on every start, each morning becomes: start the server, start the tunnel, copy the URL, paste it into the dashboard, click verify while your server is awake, and hope the token has no typo. Hookdeck makes the same point about tunnels in its WhatsApp guide: the URL changes on every restart unless you pay for a fixed subdomain.
A stable subdomain collapses that into a single registration. Verify https://yourname.example-tunnel.dev/webhook once, and you can restart the tunnel, reboot, or switch laptops without touching Meta’s configuration, as long as the same name points at your machine. Tunnel services with reserved subdomains, InstaTunnel among them, exist for this reason. Check that the name persists across restarts, not just reconnects within one session.
It also helps with the retry scenario above: retried events go to the registered address, so a stable hostname lets a reconnect pick up where Meta left off.
Keep dev and prod apart with overrides
If one Meta app serves both environments, don’t swap the app-level URL when you switch. Meta supports alternate callback URLs per WABA or per business phone number. When a supported field fires, Meta checks the phone number first, then the WABA, then falls back to the app’s callback URL.
curl -X POST "https://graph.facebook.com/$GRAPH_API_VERSION/$TEST_WABA_ID/subscribed_apps" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "override_callback_uri": "https://dev.example-tunnel.dev/webhook", "verify_token": "'"$DEV_VERIFY_TOKEN"'" }' For a single number, send a webhook_configuration object with the same two fields to POST /{PHONE_NUMBER_ID}. Per Meta’s override docs:
- The URL is capped at 200 characters, and both the URL and a verify token are required.
- Overrides apply only to certain fields, including
messages. Template webhooks (such asmessage_template_status_update) and account-level webhooks (such asaccount_update) always go to the app URL. - You can inspect the active setup with
GET /{PHONE_NUMBER_ID}?fields=webhook_configuration, which shows the phone-number, WABA and application URLs. - Subscribing the app to the WABA again without body parameters removes a WABA-level override.
Use the Graph API version your app targets in place of $GRAPH_API_VERSION. A permanent dev. hostname makes an ideal override target for your test number while production keeps its own URL.
A note on the 2026 mTLS change
If you enabled mutual TLS on your webhook subscription, Meta announced that from March 31, 2026 its client certificate would be signed by a new Meta-owned CA. The common name stays client.webhooks.fbclientcerts.com. Meta’s Graph API v25 announcement explained that the previous DigiCert-signed certificate could not be renewed because DigiCert is deprecating the Client Authentication EKU. That certificate’s root expired on April 15, 2026. Developers were told to add meta-outbound-api-ca-2025-12.pem to their trust store before the deadline. A server that enforced mTLS and skipped that update would have stopped receiving webhooks entirely, and the fix is a trust-store change.
Meta also states that mTLS is enabled per application, not per Messaging account or phone number. Most localhost setups don’t use mTLS at all. If you want to restrict inbound traffic, Meta suggests mTLS over allow-listing its IP ranges, since those addresses change periodically.
Troubleshooting: webhook not receiving messages
- Test the handshake by hand. Request your URL with
?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=123and confirm123comes back as plain text with status 200. - Compare tokens character by character, including trailing spaces.
- Confirm the
messagessubscription, then checkGET /{WABA_ID}/subscribed_apps. - Check app mode. Some webhooks are not sent while the app is in Dev mode.
- Look for an override that shadows your app-level URL.
- Check the tunnel. Retries make a downed tunnel look like a Meta problem.
- Check signature validation isn’t rejecting valid POSTs because of a wrong secret or a re-serialized body.
FAQ
What is the WhatsApp webhook verify token?
A string you choose and enter in the Meta App Dashboard. Meta sends it back as hub.verify_token, and your server compares it to the stored value before echoing hub.challenge.
What should the verification endpoint return?
HTTP 200 with the hub.challenge value as the response body. Returning it as plain text, with no JSON wrapper, is the safe approach. Any other response leaves the endpoint unverified.
How long does Meta retry failed WhatsApp webhooks?
Meta’s WhatsApp documentation says up to 7 days, with decreasing frequency (1 hour for No Storage numbers). Generic Graph API webhooks use a shorter 36-hour window.
Do I need to re-verify if I change my local port?
No, provided the public URL and path stay the same, because Meta only knows the public address. Meta re-verifies whenever you set or edit the callback URL or verify token, so a new hostname or path means a new handshake.
Conclusion
Meta’s model has three parts: a GET handshake secured by a verify token you invented, POST deliveries signed with an app secret Meta issued, and a 7-day retry net that rewards fast acknowledgements and idempotent handlers. Localhost development is painful only when the public URL keeps changing. Fix that with a stable subdomain, register once, subscribe to messages, and keep the verify token and app secret apart.
Sources
- Create a webhook endpoint, WhatsApp Business Platform (Meta)
- Webhooks overview, WhatsApp Business Platform (Meta)
- Webhook overrides (Meta)
- Get started with webhooks, Graph API (Meta)
- WhatsApp Business Platform, No Storage (Meta)
- Introducing Graph API v25 (Meta, Feb 2026)
- WhatsApp Cloud API Webhooks: Setup, Verify Token and Payloads (Webhook Relay)
- Guide to WhatsApp Webhooks (Hookdeck)
- Guide to Facebook Webhooks (Hookdeck)
- Receiving messages via Webhook (360dialog)
Originally published at https://instatunnel.my/blog/whatsapp-cloud-api-webhooks-on-localhost-the-verify-token-handshake-retries-and-app-secret-setup