Start here: what Meta is actually checking
"The callback URL or verify token couldn't be validated" means Meta sent a GET request to your URL and did not get back HTTP 200 with the exact hub.challenge value as the body. Fix it by handling GET on the same path as your POST handler, comparing hub.verify_token to your saved string, and returning the raw challenge as plain text. Nothing else about your app is tested at this stage - not your access token, not your app secret, not your business verification.
The full dashboard message reads: The callback URL or verify token couldn't be validated. Please verify the provided information or try again later. Meta does not say which check failed, so the rest of this guide is about finding out quickly.
Last verified: November 2026 against Meta's Graph API Webhooks, WhatsApp Cloud API, Messenger Platform and Instagram Platform documentation.
The handshake is identical for WhatsApp Cloud API, Messenger and Instagram because all three ride on the same Webhooks product. When you save a Callback URL and Verify token in the App Dashboard (or edit either field later), Meta's servers call:
GET https://your-domain.com/webhooks/meta
?hub.mode=subscribe
&hub.challenge=1158201444
&hub.verify_token=the-string-you-typed
Your endpoint passes only if all of these are true:
- The URL resolves publicly over HTTPS with a certificate a normal client trusts. Meta's docs are explicit: "Self-signed certificates are not supported."
- A GET handler exists on that exact path (most frameworks register POST-only routes by default).
hub.verify_tokenequals the string in the dashboard's Verify token field, byte for byte.- The response status is
200. - The response body is the challenge value and nothing else - no JSON wrapper, no quotes, no HTML page.
If you are debugging events that stopped arriving after verification passed, that is a different failure; our cross-platform webhook not firing guide covers delivery, retries and silent 200s for Meta, Telegram, Slack and Discord side by side. This page stays on the handshake and the signature check that follows it.
The handler that passes, then the five ways it breaks
Here is a minimal Node handler that passes verification and is safe to leave in production. Keep the verify token in an environment variable; it is a shared secret you invent, not anything Meta issues.
import express from "express";
const app = express();
app.get("/webhooks/meta", (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.META_VERIFY_TOKEN) {
res.set("Content-Type", "text/plain");
return res.status(200).send(String(challenge));
}
return res.sendStatus(403);
});
Every failed verification we have traced comes back to one of these:
| What you see in your logs | Cause | Fix |
|---|---|---|
| No request at all | Meta cannot reach the host: DNS, firewall, localhost URL, expired tunnel | Hit the exact URL from outside your network with curl (below) |
| GET returns 404 or 405 | Route registered for POST only, or path differs by a trailing slash | Add a GET handler on the identical path |
| GET returns 401 or 403 before your code runs | Signature or auth middleware applied to every method | Exempt GET from the signature check |
| Your code returns 403 | Verify token mismatch: whitespace, wrong env var, stale deploy | Log both values with delimiters and compare |
| Your code returns 200 but verification still fails | Body is not the bare challenge: JSON-encoded, quoted, or wrapped | Send the string as text/plain |
Work down that table in order. The first row is the only one where your application logs are empty, which is itself the diagnosis.
Reproduce Meta's request yourself before pressing Verify
You do not need the dashboard to test the handshake. Run this from a machine outside your network before you touch the dashboard (a phone hotspot or a cloud shell is fine):
curl -i "https://your-domain.com/webhooks/meta?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=CHALLENGE_TEST_123"
A correct response looks like this - status 200, and a body of exactly the challenge:
HTTP/2 200
content-type: text/plain; charset=utf-8
CHALLENGE_TEST_123
Then repeat with a wrong token and confirm you get 403. If the good request returns anything other than the literal CHALLENGE_TEST_123, the dashboard will reject it too. Common wrong outputs and what they mean:
"CHALLENGE_TEST_123"with quotes - your framework JSON-encoded a string.{"hub.challenge":"CHALLENGE_TEST_123"}- you returned an object.- An HTML page - a reverse proxy, CDN challenge page, or a single-page-app catch-all route answered instead of your API.
301/308- a redirect (HTTP to HTTPS, apex to www, or slash normalization). Register the final URL directly rather than relying on Meta to follow redirects; the docs describe responding to the request, not following one.
Also run curl -v and read the TLS section. An incomplete certificate chain (leaf certificate without the intermediate) often works in a desktop browser, which caches intermediates, and fails for server-to-server clients.
Verify token mismatches that look identical
The verify token is compared as a plain string, and the comparison happens in your code, so any mismatch is visible if you log it. Log with delimiters so invisible characters show up:
console.log(`got=[${req.query["hub.verify_token"]}] want=[${process.env.META_VERIFY_TOKEN}]`);
The usual culprits:
- Trailing newline or space in the environment variable, typically from
echo "token" > .envor a secrets manager entry pasted with a line break. - Unset variable. If
META_VERIFY_TOKENis undefined and someone typedundefinedinto the dashboard while testing, it "works" until the variable is set. - Stale deployment. You changed the token in the dashboard and in
.env, but the running container still has the old value. Restart, then verify. - Different app. Teams with a dev and a production Meta app frequently paste the production callback URL into the dev app's configuration with the dev token.
- Special characters. Characters like
&,+or#in the token get mangled in query strings. Use a long random alphanumeric string.
PHP deserves its own warning. Meta's documentation notes that PHP converts periods in parameter names to underscores, so the values arrive as $_GET['hub_mode'], $_GET['hub_verify_token'] and $_GET['hub_challenge']. Code that reads $_GET['hub.verify_token'] always sees null and always returns 403.
Framework-specific versions (Flask, FastAPI, Next.js, n8n, Make)
Most framework bugs are the same bug: the framework helpfully serializes your string. These versions return the challenge correctly.
Flask
@app.get("/webhooks/meta")
def verify():
if (request.args.get("hub.mode") == "subscribe"
and request.args.get("hub.verify_token") == os.environ["META_VERIFY_TOKEN"]):
return request.args.get("hub.challenge", ""), 200, {"Content-Type": "text/plain"}
return "Forbidden", 403
Do not use jsonify(challenge); it adds quotes.
FastAPI
from fastapi import FastAPI, Query, Response
from fastapi.responses import PlainTextResponse
@app.get("/webhooks/meta")
def verify(mode: str = Query(None, alias="hub.mode"),
token: str = Query(None, alias="hub.verify_token"),
challenge: str = Query(None, alias="hub.challenge")):
if mode == "subscribe" and token == os.environ["META_VERIFY_TOKEN"]:
return PlainTextResponse(challenge)
return Response(status_code=403)
The trap in FastAPI is returning challenge directly: the default response class is JSON, so the body becomes "1158201444" with quotes. Note the alias - Python parameter names cannot contain dots.
Next.js App Router (app/api/webhooks/meta/route.ts)
export async function GET(req: Request) {
const p = new URL(req.url).searchParams;
if (p.get("hub.mode") === "subscribe" &&
p.get("hub.verify_token") === process.env.META_VERIFY_TOKEN) {
return new Response(p.get("hub.challenge") ?? "", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
}
return new Response("Forbidden", { status: 403 });
}
Avoid NextResponse.json() here. On the Pages Router, use res.status(200).send(challenge), not res.json(). If you have trailingSlash: true in next.config.js, the slash-less URL redirects, so register the URL with the trailing slash.
n8n
Set the Webhook node HTTP Method to GET and Respond to "Using 'Respond to Webhook' Node", then add a Respond to Webhook node with Respond With set to Text and the body set to the hub.challenge query value. The default "Immediately" mode replies with the message "Workflow got started", which Meta rejects. Also note that n8n registers the production URL only once the workflow is published; the test URL only listens while you are executing the workflow in the editor. Paste the production URL into Meta.
Make
The same rule applies: a custom webhook scenario must end in a Webhook response module that returns status 200 and the challenge as the body. A scenario that only accepts the data and returns Make's default acceptance text will not verify.
Signature middleware that blocks the GET
This one catches careful engineers, because it comes from doing security right. You add a middleware that validates X-Hub-Signature-256 on every request to /webhooks/meta. Event POSTs pass. The verification GET has no body and no signature header, so the middleware rejects it with 401 or 403 before your verify handler runs. Meta reports the generic validation error, and your handler's logs show nothing.
Scope the signature check to POST only:
app.post("/webhooks/meta",
express.raw({ type: "application/json" }), // keep the raw bytes
verifyMetaSignature,
handleEvents);
app.get("/webhooks/meta", handleVerification); // no signature middleware
Look for the same pattern in API gateways and edge functions: an authorizer, API key requirement or WAF rule attached to the route applies to GET as well. Bot-protection features at a CDN can serve a JavaScript challenge page to Meta's server, which is an HTML 200 or a 403 depending on the vendor; either fails. Check your CDN or WAF event log for the request rather than assuming it reached the origin.
HTTPS, certificates, tunnels and localhost
Meta requires HTTPS with a valid certificate for both the verification request and event delivery. In practice that rules out:
http://URLs andlocalhostor private IP addresses.- Self-signed certificates and certificates for a different hostname.
- Servers that send only the leaf certificate without the intermediate chain.
- Ports or paths that only exist inside your VPC or behind a VPN.
For local development, a tunnel (ngrok, Cloudflare Tunnel, or similar) gives you a public HTTPS hostname that forwards to your machine. Three tunnel-specific problems account for most local failures:
- The tunnel hostname changed. If your tunnel URL is not reserved, restarting it gives you a new hostname, and the callback registered at Meta points at nothing. Re-enter and re-verify after every restart, or reserve a stable domain.
- The tunnel points at the wrong port, often the frontend dev server instead of the API, which then returns your app's HTML shell.
- The local process is not running when you save the configuration. The tunnel answers with its own error page.
WhatsApp, Messenger and Instagram also support mutual TLS for webhooks as an opt-in. If you enable it, your server must trust Meta's outbound CA and check the client certificate's common name; Meta's Getting Started guide documents the current CA file and notes that it replaced a previous DigiCert-signed certificate that expired on April 15, 2026. A server that pinned the old CA rejects the TLS handshake before any HTTP happens.
Where the fields live for WhatsApp, Messenger and Instagram
The GET handshake is the same everywhere. What differs is where you enter the values and what you must do after verification so events actually flow.
| Product | Where to set Callback URL and Verify token | After verification, also do this |
|---|---|---|
| WhatsApp Cloud API | App Dashboard: WhatsApp > Configuration (or Use cases > Customize > Configuration) | Subscribe to the messages field, and make sure your app is subscribed to the WhatsApp Business Account via POST /{WABA-ID}/subscribed_apps |
| Messenger | App Dashboard: Messenger settings, Webhooks section | Subscribe the Page: POST /{PAGE-ID}/subscribed_apps?subscribed_fields=messages with a Page access token |
| App Dashboard: Configure webhooks in the Instagram product's API setup | Subscribe the account via /subscribed_apps (host depends on the login type); the app must be Live to get notifications for app users |
Points that matter per product, all from Meta's docs:
- WhatsApp: Meta's endpoint guide says the endpoint counts as verified only after a 200 that echoes
hub.challenge; until then no webhooks are sent. Failed event deliveries retry with decreasing frequency for up to 7 days, so make your handler idempotent. - Messenger: the Messenger webhooks guide asks for a
200 OKwithin 5 seconds. After 15 minutes of failed deliveries you get a developer alert; after an hour, a "Webhooks Disabled" alert and your app is unsubscribed from the Page, which means you must subscribe again after fixing it. Re-verifying the URL does not re-subscribe the Page. - Instagram: the Instagram webhooks setup guide notes that comment and mention notifications are not delivered for private accounts and that
commentsandlive_commentsrequire Advanced Access. Neither affects the handshake, but both are reported as "webhook verification works but nothing arrives".
When the webhook is healthy but the bot still ignores people, the platform-level causes are covered in our guides to a Messenger bot not responding and the Instagram Messaging API not working; for first-time WhatsApp setup, start with the WhatsApp Business API setup guide.
If the handshake passes and the platform then returns API errors when you reply, those are separate: see the WhatsApp, Messenger and Instagram error-code directories.
After the handshake: validating X-Hub-Signature-256
Once verified, every event arrives as a POST with an X-Hub-Signature-256 header in the form sha256=<hex digest>. The digest is an HMAC-SHA256 of the request body keyed with your app secret (App Dashboard, App settings > Basic). It is not keyed with the verify token and not with an access token - mixing those up is the most common reason signatures never match.
import crypto from "node:crypto";
function verifyMetaSignature(req, res, next) {
const header = req.get("x-hub-signature-256") || "";
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.META_APP_SECRET)
.update(req.body) // Buffer from express.raw(), not parsed JSON
.digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
req.event = JSON.parse(req.body.toString("utf8"));
next();
}
The detail that breaks most implementations: hash the raw bytes you received, never a re-serialized object. Meta's Messenger docs explain that the payload is signed in its escaped-unicode form (for example \u00e4 rather than ä), so parsing the JSON and calling JSON.stringify again produces different bytes and a different digest. With express.json() mounted globally, the raw body is already gone by the time your middleware runs; mount express.raw() on the webhook route or capture the buffer with the verify option.
In Python, read await request.body() (FastAPI) or request.get_data() (Flask) before touching JSON, and compare with hmac.compare_digest. In Next.js route handlers, call await req.text() once and hash that string's UTF-8 bytes.
Validation is described by Meta as optional but strongly recommended. Without it, anyone who learns your callback URL can post fake "incoming messages" into your bot.
The two-minute checklist
- curl the URL with
hub.mode=subscribe, your token and a test challenge from outside your network. Body must be exactly the challenge. - Confirm the status is 200, not a 30x redirect, and the Content-Type is not HTML.
- Confirm the GET route exists and no auth or signature middleware covers it.
- Log the received and expected verify tokens with delimiters.
- Check TLS with
curl -v: valid, public, full chain, matching hostname. - For tunnels, confirm the hostname registered at Meta is the one currently running.
- After verification, subscribe to fields and subscribe the WABA, Page or Instagram account.
- Validate
X-Hub-Signature-256against raw bytes with the app secret.
If you would rather not run and monitor the webhook yourself, Conferbot's WhatsApp integration (and the equivalent Messenger and Instagram connections) hosts the endpoint, the handshake and the signature checks for you. For a refresher on the concept itself, see the webhook glossary entry.
Was this article helpful?
Build and deploy in 10 minutes. No coding needed.
Meta Webhook Verification Failed FAQ
Everything you need to know about chatbots for meta webhook verification failed.
About the Author
The Conferbot team writes about building, deploying, and improving AI chatbots.
View all articlesRelated Articles
From the reference shelf
Fact-checked reference pages and free tools for the platform this article covers.