Skip to main content
Share
Guides

Meta Webhook Verification Failed: Fixing "The Callback URL or Verify Token Couldn't Be Validated"

WhatsApp, Messenger and Instagram all verify your webhook the same way: a GET with hub.mode, hub.verify_token and hub.challenge that must get the raw challenge back with a 200. Here is the handler that passes, framework-specific versions, and the middleware, TLS and tunnel mistakes that make it fail.

Content & Engineering
Oct 8, 2026
12 min read
Updated Nov 2026Last verified November 2026
the callback url or verify token couldn't be validatedwhatsapp webhook verification failedhub.challengemeta webhook verify tokenmessenger webhook verification failed
TL;DR

WhatsApp, Messenger and Instagram all verify your webhook the same way: a GET with hub.mode, hub.verify_token and hub.challenge that must get the raw challenge back with a 200. Here is the handler that passes, framework-specific versions, and the middleware, TLS and tunnel mistakes that make it fail.

Key Takeaways
  • "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.

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:

  1. The URL resolves publicly over HTTPS with a certificate a normal client trusts. Meta's docs are explicit: "Self-signed certificates are not supported."
  2. A GET handler exists on that exact path (most frameworks register POST-only routes by default).
  3. hub.verify_token equals the string in the dashboard's Verify token field, byte for byte.
  4. The response status is 200.
  5. 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 logsCauseFix
No request at allMeta cannot reach the host: DNS, firewall, localhost URL, expired tunnelHit the exact URL from outside your network with curl (below)
GET returns 404 or 405Route registered for POST only, or path differs by a trailing slashAdd a GET handler on the identical path
GET returns 401 or 403 before your code runsSignature or auth middleware applied to every methodExempt GET from the signature check
Your code returns 403Verify token mismatch: whitespace, wrong env var, stale deployLog both values with delimiters and compare
Your code returns 200 but verification still failsBody is not the bare challenge: JSON-encoded, quoted, or wrappedSend 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.

Try it yourself
Build your first chatbot free
Free plan, no credit card required. Live on your site in about 10 minutes.
Start building free

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" > .env or a secrets manager entry pasted with a line break.
  • Unset variable. If META_VERIFY_TOKEN is undefined and someone typed undefined into 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.

WhatsApp API Cost Calculator
Free tool - no signup, runs in your browser.
Open free tool

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 and localhost or 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:

  1. 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.
  2. The tunnel points at the wrong port, often the frontend dev server instead of the API, which then returns your app's HTML shell.
  3. 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.

ProductWhere to set Callback URL and Verify tokenAfter verification, also do this
WhatsApp Cloud APIApp 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
MessengerApp Dashboard: Messenger settings, Webhooks sectionSubscribe the Page: POST /{PAGE-ID}/subscribed_apps?subscribed_fields=messages with a Page access token
InstagramApp Dashboard: Configure webhooks in the Instagram product's API setupSubscribe 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 OK within 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 comments and live_comments require 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

  1. curl the URL with hub.mode=subscribe, your token and a test challenge from outside your network. Body must be exactly the challenge.
  2. Confirm the status is 200, not a 30x redirect, and the Content-Type is not HTML.
  3. Confirm the GET route exists and no auth or signature middleware covers it.
  4. Log the received and expected verify tokens with delimiters.
  5. Check TLS with curl -v: valid, public, full chain, matching hostname.
  6. For tunnels, confirm the hostname registered at Meta is the one currently running.
  7. After verification, subscribe to fields and subscribe the WABA, Page or Instagram account.
  8. Validate X-Hub-Signature-256 against 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.

Share this article:

Was this article helpful?

Ready to build your chatbot?

Join the businesses. Deploy on website, WhatsApp, and 11 more channels in minutes. Free forever plan available.

No credit cardNo coding13+ channels
Start Building Free

Get chatbot insights delivered weekly

Join 5,000+ professionals getting actionable AI chatbot strategies, industry benchmarks, and product updates.

🎯Automate this with a free chatbot

Build and deploy in 10 minutes. No coding needed.

❓FAQ

Meta Webhook Verification Failed FAQ

Everything you need to know about chatbots for meta webhook verification failed.

🔍
Popular:

Meta sent a GET request to your callback URL with hub.mode, hub.verify_token and hub.challenge, and did not receive HTTP 200 with the exact challenge value as the body. Either the request never reached your server, a route or middleware rejected it, your token comparison failed, or your framework returned the challenge wrapped in JSON, quotes or HTML. Reproduce the request with curl to see which.

Return status 200 and a body containing only the hub.challenge value, ideally with a text/plain Content-Type. Do not return JSON, do not quote the string, and do not render a template. If the hub.verify_token does not match the token you configured, return 403. The same rule applies to WhatsApp Cloud API, Messenger and Instagram, since they share Meta's Webhooks product.

No. The verify token is an arbitrary string you invent and type into the dashboard's Verify token field, then compare against in your GET handler. It is used only for the handshake. The app secret signs event payloads in X-Hub-Signature-256, and access tokens authorize your outgoing API calls. Mixing these three up is a very common cause of both verification and signature failures.

Usually your curl ran from inside your own network, so it reached a host Meta cannot: a VPN-only address, a firewall-protected port, or a localhost tunnel that has since restarted with a new hostname. It can also be a CDN or WAF that challenges unknown server clients but lets your IP through. Test from an external network and check your CDN event logs.

No. Meta's webhook documentation states that self-signed certificates are not supported and that your server needs a valid, correctly installed TLS certificate. Use a certificate from a public CA such as Let's Encrypt, make sure the intermediate chain is served, and confirm the hostname matches. For local development, use an HTTPS tunnel that presents a publicly trusted certificate.

The verification GET has no body and no X-Hub-Signature-256 header, so a middleware that validates signatures on every method rejects it with 401 or 403 before your verify handler runs. Apply the signature middleware to POST only, and leave the GET route unauthenticated apart from the verify token comparison inside the handler itself.

Three causes cover almost every case. You are hashing parsed and re-serialized JSON instead of the raw request bytes; you are using the wrong key, such as the verify token or an access token instead of the app secret; or you are comparing against the full header without accounting for the sha256= prefix. Hash the raw body with HMAC-SHA256 and the app secret.

Verification only proves Meta can reach you. You still need to subscribe to the messages field in the WhatsApp configuration, and your app must be subscribed to the WhatsApp Business Account through the subscribed_apps edge. Meta also notes some webhooks are not sent while the app is in development mode. For delivery-side debugging, use our cross-platform webhook not firing guide.

Set the Webhook node's method to GET and its Respond option to use a Respond to Webhook node. That node should respond with Text whose value is the hub.challenge query parameter. The default Immediately mode replies with Workflow got started, which fails verification. Publish the workflow and give Meta the production URL, because the test URL only listens during a manual execution.

Returning the challenge string directly from a FastAPI route sends it through the default JSON response class, so the body becomes the challenge wrapped in quotes. Return PlainTextResponse(challenge) instead. Also declare the query parameters with aliases such as hub.mode and hub.verify_token, because Python parameter names cannot contain dots and the values will otherwise be None.

About the Author

Content & Engineering

The Conferbot team writes about building, deploying, and improving AI chatbots.

View all articles
Skip the blank canvas
Start from one of 250+ free chatbot templates for lead generation, support, e-commerce, and 20+ industries - customize and launch in minutes.
Browse free templates

Related Articles

From the reference shelf

Fact-checked reference pages and free tools for the platform this article covers.

Omnichannel Platform

One Chatbot,
Every Channel

Your chatbot works seamlessly across WhatsApp, Messenger, Slack, and 6 more platforms. Build once, deploy everywhere.

View All Channels
Conferbot
online
Hi! How can I help you today?
I need pricing info
Conferbot
Active now
Welcome! What are you looking for?
Book a demo
Sure! Pick a time slot:
#support
Conferbot
New ticket from Sarah: "Can't access dashboard"
Auto-resolved. Password reset link sent.