Features Pricing Use cases Compare Blog

Why Your WhatsApp X-Hub-Signature-256 Never Matches

15 min read

The short answer

Meta generates the signature over an escaped-unicode rendering of the payload, using lowercase hex digits, and warns that calculating against the decoded bytes produces a different signature. Any framework that parses the JSON and re-serialises it will therefore fail, and PHP and Node escape unicode differently again. Capture the raw request body before parsing, and HMAC exactly those bytes with your app secret.

Outgoing webhook endpoint rows listing a callback URL, subscribed event types, delivery status and counts, with buttons to reveal and rotate the signing secret

What exactly is Meta signing?

Every webhook notification WhatsApp sends - an inbound message, a delivery receipt, an echo from a coexistence number's own phone - arrives with a header you can check before trusting the body: X-Hub-Signature-256. Meta states the mechanism plainly: "We sign all Event Notification payloads with a SHA256 signature and include the signature in the request's 'X-Hub-Signature-256' header, preceded with 'sha256='." (Meta - Messenger Platform webhooks)

The verification Meta asks for reads like one line of code. "Generate a SHA256 signature using the payload and your app's App Secret. Compare your signature to the signature in the X-Hub-Signature-256 header (everything after sha256=). If the signatures match, the payload is genuine." (Meta - Graph API webhooks getting started)

Read only that far, it sounds like an HMAC-SHA256 over whatever bytes your web server received, keyed with your app secret, rendered as hex. Most implementations that fail get every part of that right. What they get wrong is what "the payload" means - and that one word carries a qualification that determines whether the whole exercise works.

Checked against Meta's documentation on 4 September 2026.

Why does re-serialising the JSON break the comparison?

Here is the sentence that explains almost every failed WhatsApp signature ever reported. Meta's own documentation warns: "Please note that we generate the signature using an escaped unicode version of the payload, with lowercase hex digits. If you just calculate against the decoded bytes, you will end up with a different signature. For example, the string äöå should be escaped to äöå." (Meta - Messenger Platform webhooks)

Meta does not sign the JSON object your framework parses - it signs the exact string of bytes it sent over the wire, and any character above the ASCII range must appear in that string as an escaped \uXXXX sequence for the hash to match.

This is easy to miss because most WhatsApp payloads are pure ASCII - message ids, phone numbers, status codes - and non-ASCII bytes only turn up when a contact's name or message body uses accented characters, an emoji, or a script outside Latin. A signature that has "always worked" in testing can start failing the moment a real customer named Zoë or Núñez sends a message, because that is the first payload where the escaping actually matters.

The practical failure mode is a framework that helpfully parses the incoming JSON into an object for you - Express's body-parser, Laravel's request lifecycle, any middleware that calls the equivalent of json_decode before your code runs - and then, when you go to verify, you re-encode that object back into a string to hash it. That re-encoding is a second, independent act of JSON serialisation, performed by a different piece of software than the one that produced Meta's original bytes, and there is no guarantee it escapes unicode, orders object keys, or spaces punctuation the same way. The diagram below draws the two paths side by side: Meta's path and yours are identical in shape right up to the point where parsing and re-serialising peels off as an extra step - and that one extra step is the entire difference between a signature that matches and one that never will.

Two parallel signing pipelines converging on a comparison box, with a fork labelled parse then re-serialise breaking off the lower path into a box reading a different digest every time
Meta's signing path and yours are the same shape - until re-serialising the JSON forks off the bytes that were actually hashed

Checked against Meta's documentation on 4 September 2026.

Where is the sentence that explains this actually documented?

This is the part worth being precise about, because it is checkable in both directions rather than merely asserted.

The escaped-unicode warning above is real, and it is verbatim on the Messenger Platform webhooks page. It is also, measurably, absent from the page most WhatsApp developers actually read first - the Graph API webhooks getting-started guide, which carries the same generic instruction ("generate a SHA256 signature... compare... if the signatures match, the payload is genuine") with no mention of escaping, decoded bytes, or hex case. And WhatsApp's own Cloud API guide for setting up webhooks does not mention signature verification at all - not the header, not the algorithm, not the app secret.

Page a WhatsApp developer might actually open Mentions signature verification Includes the escaped-unicode warning
Messenger Platform → Webhooks Yes Yes
Graph API → Webhooks → Getting Started Yes No
WhatsApp Cloud API → Set up webhooks No No

That is not a criticism of any one page in isolation - each is written for its own audience, and the Messenger Platform predates the WhatsApp Cloud API by years, which is presumably why the caveat lives there. But a developer wiring up a WhatsApp Business Platform webhook has no obvious reason to open the Messenger Platform docs at all, and the one sentence that would have saved them a debugging session sits on a page for a different product. The bug most teams file as "our HMAC implementation must be wrong" is frequently just this: the one paragraph that explains the mismatch was never on the page they read.

If you take one habit from this piece, make it this: when a Meta signature "should" work and doesn't, go read the Messenger Platform webhooks page even if you are building nothing that touches Messenger. It is the one that says the quiet part.

Checked against Meta's documentation on 4 September 2026.

How do PHP and Node differ when they encode the same payload?

Meta's example is specific on purpose: the string äöå "should be escaped to äöå." That is what Meta's own serialiser produces. Whether your language's serialiser produces the same thing by default is a separate question, and PHP and Node answer it differently.

PHP's json_encode() escapes non-ASCII characters to \uXXXX sequences by default - it would render café as "café" unless you pass the JSON_UNESCAPED_UNICODE flag. Node's JSON.stringify() does the opposite by default: it emits the literal UTF-8 characters, rendering the same string as "café", and there is no built-in flag to make it escape instead - you would need a custom replacer to do it.

Default rendering of café Flag to change it
PHP json_encode() "café" JSON_UNESCAPED_UNICODE
Node.js JSON.stringify() "café" (literal UTF-8) none built in - needs a custom replacer

Neither behaviour is wrong; they are simply different defaults for a decision Meta's own serialiser has already made one way. The practical consequence is that a PHP re-encode and a Node re-encode of the identical parsed object produce two different strings, and neither is guaranteed to match the string Meta actually hashed, because both are new serialisations rather than the original bytes. This is why "it works when I test with an ASCII-only payload and breaks on a real customer's name" is such a common bug report on both stacks: the escaping only diverges from the original once a non-ASCII character enters the payload, and ASCII-only test fixtures never exercise it.

How do you capture a raw body in a framework that parses it for you?

The fix implied by all of the above is the same regardless of language: stop trying to reconstruct the string Meta sent, and capture it directly, before anything parses it.

In an Express application, the common express.json() or body-parser middleware parses the body and discards the original text unless you ask it not to. Both accept a verify callback that runs with the raw Buffer before parsing replaces it - store that buffer (or its string form) on the request object there, and sign against it rather than against req.body once it has become a JavaScript object.

In a Laravel application, the framework's request lifecycle has already read the input stream by the time a controller method runs, but the raw content is still recoverable with $request->getContent() (or file_get_contents('php://input') earlier in the pipeline) as long as nothing upstream of your webhook route has consumed and discarded it - which is why a webhook endpoint is usually best kept outside any middleware that assumes a parsed, validated request shape.

The pattern is the same in any framework: the only string that will ever verify correctly is the one that arrived on the wire, captured before your first line of application code touches it - not before it is validated, not before it is routed, before it is parsed. Anything that happens to the body between arrival and hashing - decoding, re-encoding, trimming whitespace, normalising line endings - is a chance to produce bytes that were never what Meta signed.

Which secret signs the header, and where do you find it?

The signature is keyed with your app's App Secret - not an access token, not the WhatsApp Business Account id, and not the verify token used during webhook subscription. These are four different values with four different jobs, and conflating any two of them produces a signature comparison that fails for a reason that has nothing to do with escaping or serialisation.

The verify token is only ever used once, during the GET request Meta sends to confirm your callback URL when you first subscribe a webhook - it never appears on a POST notification and has nothing to do with X-Hub-Signature-256. The access token authenticates calls you make outward to Graph; it plays no part in verifying calls Meta makes inward to you. The app secret is the only one of the four that signs incoming webhook payloads.

You find it in the App Dashboard, and Meta's own guidance on what to do if it leaks is direct: inside the App Dashboard, under Basic > App Secret, Meta says that if your app secret is ever compromised, you can reset it there. (Meta - Facebook Login security) Anyone who reads that value can forge a signature your endpoint will accept, so it belongs in the same category as a database password: encrypted at rest, never logged, and never pasted into a chat thread or a support ticket while debugging why a signature does not match.

Checked against Meta's documentation on 4 September 2026.

How should the comparison itself be written?

Once you have the raw bytes and the correct secret, the comparison has two remaining requirements that are easy to get half right.

First, the digest has to be rendered as lowercase hex - Meta's documentation says so explicitly in the escaping sentence above, and most HMAC libraries default to lowercase already, but a comparison against an uppercase or mixed-case rendering will fail even when every byte hashed was correct. Second, the comparison itself should not be a plain == or ===: a byte-by-byte equality check on two strings can be timed by an attacker who submits guesses and measures how long the rejection takes, which is why PHP's hash_equals() and Node's crypto.timingSafeEqual() exist - they compare in constant time regardless of where the first mismatched byte falls. A signature check that is correct in every other respect but written with a naive string comparison is still a smaller version of the same authentication weakness that not checking the signature at all would be.

The full sequence, in order, is: read the raw request body as bytes; compute an HMAC-SHA256 of those exact bytes using your app secret; render the result as lowercase hex; prefix it with sha256=; and compare it, in constant time, to the full value of the X-Hub-Signature-256 header. Skip or reorder any one of those five steps and the comparison can fail on a completely genuine request.

What should your endpoint do when a signature fails?

The honest answer is: refuse the request and stop, without acting on the payload it carried. That sounds obvious stated directly, and it is nonetheless the step some implementations quietly skip once they get tired of chasing false negatives - logging the mismatch and processing the payload anyway "for now," which turns a defence into a warning nobody reads.

A webhook endpoint that cannot verify a signature has no way to tell a genuine Meta notification from anything shaped like one, and the two situations call for different responses. If your endpoint is refusing every payload because of a bug in your own hashing (the escaping and re-serialisation problems above are the two most common causes), the fix is to correct the verification, not to bypass it - bypassing it to unblock message delivery converts a bug into a standing hole that outlives the incident that caused it. If verification is correct and a request still fails, treat it as untrusted input: return a non-2xx response and take no action on its contents, the same way you would treat a request with no signature header at all.

Does a missing signature header mean the request is fake?

Not automatically, and it is worth separating two situations that look similar. A POST notification with no X-Hub-Signature-256 header at all is one thing - Meta's own sample verification code checks for exactly this and logs a warning when the header is absent, which only makes sense if a legitimate notification is expected to always carry one. The other situation is the initial GET request Meta sends to verify your callback URL when you first configure a webhook subscription: that handshake carries a challenge and a verify token in the query string, has no body, and was never going to carry X-Hub-Signature-256 in the first place, because there is no payload to sign yet. Code that expects a signature on every incoming request and rejects the subscription handshake for lacking one will never successfully subscribe.

Past that distinction, a POST with the header missing or malformed on a route that should only ever receive signed notifications is a reasonable basis to refuse it outright - Meta's documentation does not describe a legitimate reason for that header to be absent on an event notification, and "the network stripped it" is a much rarer failure than "the JSON was re-serialised" in practice. If you want to distinguish infrastructure interference from real spoofing, that is a job for logging the raw headers you actually received and comparing them against what a direct, un-proxied test request shows - not for weakening the check itself. See why WhatsApp webhooks stop arriving at all for the broader set of causes - a stripped header is one narrow case inside a wider category of "the payload changed somewhere between Meta and your code."

What does signing your own outgoing webhooks teach you about this?

WabaCRM's own webhook forwarding feature - offered by a company that is itself a Meta-verified Tech Provider - sits on the other side of exactly this problem: it signs payloads leaving our platform toward a customer's endpoint, using the same X-Hub-Signature-style construction described here - an HMAC-SHA256 over the exact bytes of the outgoing request, keyed to a per-endpoint secret shown and rotatable from the endpoint's own row.

Endpoint rows in a webhook management screen showing a callback URL, subscribed event names, delivery status and counts, with controls beside each row
Outgoing endpoints carry their own signing secret, revealable and rotatable from the row that sends to them

Building that feature is what makes the asymmetry in this piece concrete rather than academic: a receiver who cannot verify our signature has no honest option but to treat every payload we send as unauthenticated, exactly as this piece argues about Meta's payloads. That symmetry is also why it is worth naming a gap in our own implementation rather than only pointing at other people's: the timestamp field on each outgoing payload sits outside the HMAC. The signature proves the body was not altered and was signed with the right secret; it does not, by itself, prove the request is being seen for the first time rather than replayed from a capture taken minutes or days earlier. Signing the timestamp as part of the digest - the way several webhook providers do - closes that gap, but changes what the header covers, which makes it a breaking change to the signing format rather than a quiet patch. Verifying a signature is necessary. It is worth being precise, including about your own product, that necessary is not the same claim as sufficient.


Related reading: why WhatsApp webhooks stop arriving at all, what coexistence numbers cannot do, and the shared inbox that these signed notifications ultimately feed: what a WhatsApp shared inbox actually is. For the underlying platform this all runs on, see the WhatsApp Business API explained and WabaCRM's features.

Every Meta quotation on this page was read from Meta's own documentation on 4 September 2026. Meta changes that documentation without notice; the linked pages are authoritative and this one is not.

Questions people also ask

Is X-Hub-Signature-256 the same as the older X-Hub-Signature header?

The Meta webhook documentation checked for this piece describes only X-Hub-Signature-256, generated with SHA256 and a lowercase hex digest prefixed sha256=. Neither page states that an older X-Hub-Signature header is still sent for WhatsApp notifications, that it is deprecated, or that the two should be treated as equivalent. Various third-party integration guides describe X-Hub-Signature as an earlier SHA1-based header used by other Meta products, but that description does not appear on the WhatsApp-relevant pages checked here, so it is left out of this answer rather than repeated secondhand. If your endpoint only ever receives X-Hub-Signature-256 on WhatsApp notifications, that matches what Meta documents - don't build fallback verification logic for a header you have not actually observed arriving.

Do I need to verify signatures if my endpoint URL is secret?

A secret URL is not authentication - it just makes guessing harder, and URLs leak: browser history, proxy access logs, referrer headers, a curl command pasted into a support ticket. Meta's own documentation says plainly, "You don't have to validate the payload, but you should." Signature verification is the only check that actually proves a request originated from Meta rather than from anyone who obtained or guessed the URL by some other route. Treat the endpoint path as an operational convenience, never as a security boundary, and verify every payload's signature regardless of how obscure or hard-to-guess the URL looks, because obscurity is not a control anyone can point to later.

Does the signature cover the request headers or only the body?

Meta's documented process describes generating the signature "using the payload" and comparing it to the value in the header - the payload being the request body, not the surrounding headers. The X-Hub-Signature-256 header itself is necessarily excluded from what it authenticates, since a header cannot sign itself. Practically, this means a proxy that rewrites unrelated headers - adding X-Forwarded-For, changing a User-Agent, appending a request id - does not break verification, because none of that reaches the hash. Only a change to the body's actual bytes changes the digest, whether that change comes from Meta's infrastructure, your own, or a re-encoding step you introduced trying to inspect the JSON.

Will a proxy or load balancer change the bytes Meta signed?

It can, and this is a common cause of signature failures that look identical to the JSON re-serialisation problem but have a different fix. A reverse proxy that decompresses and recompresses the body, a WAF that rewrites the payload to inject a scanning header, or middleware that normalises line endings can all alter bytes before your application ever sees them - and the signature was computed on the bytes Meta actually sent, not on whatever survives your infrastructure. If verification fails intermittently rather than always, capture the raw body as close to the edge of your infrastructure as you can, and compare what arrives there against what your application ultimately receives.

What should I do if my app secret was exposed?

Meta's own guidance is direct: inside the App Dashboard, under Basic > App Secret, Meta says that if your app secret is ever compromised, you can reset it there. Do that immediately - an exposed app secret lets anyone forge a signature your endpoint will accept as genuine, which defeats the entire point of checking one. After resetting, every system that signs or verifies with the old secret needs the new one at the same moment, including any outgoing webhook feature of your own that reuses the same value for a different direction of traffic, so treat the rotation as a coordinated deployment rather than a single dashboard click you can do quietly.

Keep reading

Your customers are already on WhatsApp

Free for your first 1,000 contacts, with no time limit and no card. Setting up the workspace takes minutes; connecting a number takes as long as Meta's own review of it.

Sign up with your company email address. No sales call, no onboarding fee, nothing to schedule.

Why this is safe to point your customer list at

Payments are processed by Razorpay on their own checkout — your card details are never entered on, or stored by, WabaCRM. Every inbound WhatsApp webhook is checked against its signature before it is trusted.

Tech Provider is a Meta platform access tier — not a partnership, a reseller agreement or an endorsement.