WhatsApp webhook verification failed? Here's Meta's handshake, decoded

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.

What Meta actually sends

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.

The five classic failures

Pass it with zero code (and see the traffic)

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:

  1. Create a bin (no signup needed) and open its settings.
  2. Set the custom response — Status 200, Content-Type text/plain, Body:
{{query.hub.challenge}}
  1. Paste your 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.

Verifying real events: X-Hub-Signature-256

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.

Retries, duplicates, and the dev-mode trap

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

← All guides · Docs · Hookden vs webhook.site