You paste your callback URL and verify token into the Meta App Dashboard, click Verify and save, and get "The callback URL or verify token couldn't be validated." The same check guards every Meta webhook — WhatsApp Cloud API, Messenger, Instagram, Facebook Pages — and it fails for a small set of very concrete reasons. Here's exactly what Meta sends, the classic mistakes, and how to pass it with zero code while you inspect the real traffic.
When you click Verify and save, Meta sends a plain GET request (not a POST) to your callback URL with three query parameters:
GET /your-endpoint?hub.mode=subscribe
&hub.verify_token=YOUR_TOKEN
&hub.challenge=1158201444
Your endpoint must check that hub.verify_token matches the token you typed
into the dashboard, then respond with status 200 and the value of
hub.challenge as the raw response body — nothing else. Meta
compares byte-for-byte.
hub.challenge value — not hub.verify_token, not
"OK", not an empty 200. This is the #1 cause.res.json(challenge) returns
"1158201444" with quotes — which is not the same bytes. Send it as
plain text (res.send(...) / text/plain).localhost or a
private IP fails before your code even runs. (See
webhooks to localhost.)Chicken-and-egg: you want to see what Meta sends before you build the endpoint, but the dashboard won't save your URL until it passes. A Hookden bin's response template can echo any query parameter, so it passes the check as-is:
200, Content-Type
text/plain, Body:{{query.hub.challenge}}
https://hookden.pages.dev/h/… URL as the callback URL, type anything as
the verify token, and click Verify and save.The verification GET itself lands in your dashboard as the first capture — you can see
the exact query string Meta sent. One honest note: the bin echoes the challenge without
checking hub.verify_token, which is fine for development (only you know the
bin URL), but your production endpoint should do the comparison.
Once verified (and your webhook fields are subscribed), events arrive as POSTs signed
with your App Secret: the X-Hub-Signature-256 header carries
sha256= plus a hex HMAC-SHA256 of the raw request body.
const expected = "sha256=" +
crypto.createHmac("sha256", APP_SECRET).update(rawBody).digest("hex");
// timing-safe compare with req.headers["x-hub-signature-256"]
Two classic bugs: verifying a re-serialized body after JSON middleware parsed it (Meta
escapes some characters as \u00e9-style sequences and signs those exact
bytes), and using the verify token or an access token as the key — it's the App
Secret. GitHub uses the identical header and format, so in your bin's verification
settings you can pick the GitHub scheme, paste your App Secret, and get a
✓/✗ badge on every Meta delivery.
Related handshakes: Slack URL verification (echo a JSON field), Twitch's EventSub challenge (echo, but POST), Zoom's CRC (hash it, don't echo it), and Discord's Ed25519 check (sign it). Meta's is the only one that arrives as a GET.
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new