Updated on October 28, 2026
To validate a WhatsApp webhook signature, compute an HMAC SHA-256 of the request body using your Meta app secret as the key, then compare it with the X-Hub-Signature-256 header, which arrives as sha256=<hash>. If they match, the event came from Meta and was not tampered with. The mistake that breaks validation most often is hashing the JSON your framework already parsed instead of the exact bytes that arrived.
This guide walks through the steps, ready-to-use code for Node.js (Express), Python (FastAPI and Flask) and PHP, and the most common pitfalls. For the bigger picture of the platform, see our WhatsApp Cloud API guide.
Why validate the signature
Your webhook URL is public. Anyone who finds it can send a POST with JSON shaped like Meta’s, faking a customer message, a button reply that confirms a payment, or a delivered status that never happened. Without validation, your system accepts all of it.
Meta’s Graph API webhooks documentation says every event notification is signed with SHA-256 and the signature is sent in the X-Hub-Signature-256 header, prefixed with sha256=. Validation is optional, but Meta recommends it. In practice, it is what separates a trustworthy webhook from an open endpoint.
The two requests your endpoint receives
The same URL receives two kinds of calls, each with its own check:
| Request | When it happens | What to check | Response |
|---|---|---|---|
Verification GET | When you set or change the URL in the app dashboard | hub.mode equals subscribe and hub.verify_token matches the token you configured | 200 with the hub.challenge value |
Event POST | Every message, status or account change | X-Hub-Signature-256 against the HMAC of the raw body | Fast 200; process in a queue |
According to Create a webhook endpoint, a valid verification request gets a 200 with hub.challenge; an invalid one gets anything other than 200 (a 403, for example). The same page requires a valid TLS certificate on the endpoint: self-signed certificates are not supported.
The verify token and the app secret are different things. The verify token is a string you make up and enter in the dashboard; it only matters for the GET. The app secret is generated by Meta, lives under App settings > Basic, and is what signs the POST requests.
Want to try it while you read? Create a Vectalk account and send your first message through the official API. Create free account →
Step-by-step validation
- Read the request body as bytes, before any JSON parser touches it.
- Compute
HMAC-SHA256(key = app secret, message = raw body)and hex-encode it. - Build
sha256=+ hash and compare it with theX-Hub-Signature-256header using a constant-time comparison. - If it does not match, return 401 or 403 and drop the event.
- If it matches, parse the JSON, queue the event and return
200.
Constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, hash_equals) keeps an attacker from guessing the hash byte by byte by measuring response times.
Node.js example with Express
The key is express.raw() on the webhook route, so req.body is a Buffer with the original bytes:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const APP_SECRET = process.env.META_APP_SECRET;
const VERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN;
// Verification GET
app.get('/webhook', (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 === VERIFY_TOKEN) {
return res.status(200).send(challenge);
}
return res.sendStatus(403);
});
// Event POST: raw body on this route only
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const received = req.get('X-Hub-Signature-256') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', APP_SECRET)
.update(req.body)
.digest('hex');
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200);
eventQueue.push(event); // process outside the request cycle
});
app.use(express.json()); // other routes can parse JSON as usual
app.listen(3000);
If your app already registers app.use(express.json()) before this route, the body arrives parsed and the hash will not match. Either declare the webhook route before the global middleware, or keep the raw bytes inside express.json itself:
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
// then pass req.rawBody to createHmac
Python example with FastAPI
In FastAPI, await request.body() returns the exact bytes. Do not sign the Pydantic model of the body:
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, HTTPException, Request, Response
app = FastAPI()
APP_SECRET = os.environ["META_APP_SECRET"].encode()
VERIFY_TOKEN = os.environ["WEBHOOK_VERIFY_TOKEN"]
@app.get("/webhook")
async def verify(request: Request):
p = request.query_params
if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == VERIFY_TOKEN:
return Response(content=p.get("hub.challenge", ""), media_type="text/plain")
raise HTTPException(status_code=403)
@app.post("/webhook")
async def receive(request: Request):
body = await request.body()
received = request.headers.get("x-hub-signature-256", "")
expected = "sha256=" + hmac.new(APP_SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(received, expected):
raise HTTPException(status_code=401)
event = json.loads(body)
# enqueue the event and respond quickly
return {"ok": True}
Python example with Flask
In Flask, use request.get_data(), which returns the raw body:
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
APP_SECRET = os.environ["META_APP_SECRET"].encode()
@app.post("/webhook")
def receive():
body = request.get_data()
received = request.headers.get("X-Hub-Signature-256", "")
expected = "sha256=" + hmac.new(APP_SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(received, expected):
abort(401)
event = request.get_json()
return "", 200
PHP example
In PHP, the raw body comes from php://input. Watch the GET: PHP replaces dots with underscores in parameter names, so hub.verify_token becomes $_GET['hub_verify_token'].
<?php
$appSecret = getenv('META_APP_SECRET');
$verifyToken = getenv('WEBHOOK_VERIFY_TOKEN');
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if (($_GET['hub_mode'] ?? '') === 'subscribe'
&& hash_equals($verifyToken, $_GET['hub_verify_token'] ?? '')) {
echo $_GET['hub_challenge'] ?? '';
exit;
}
http_response_code(403);
exit;
}
$body = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $body, $appSecret);
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
http_response_code(200);
Common reasons the signature does not match
Hashing parsed JSON. This is the number one mistake. JSON.stringify(req.body) or json.dumps(payload) produces text that differs from the original: whitespace, key order and character escaping change. One different byte changes the whole hash. Always sign the bytes you received.
Wrong encoding. Converting the body to a string with the wrong charset (Latin-1 instead of UTF-8, for example) alters accented names and emoji. Stay in bytes until validation is done.
Wrong secret. The right value is the app secret of the app subscribed to the webhook. It is not the access token, not the verify token, and not the secret of another app in the same business portfolio. If you run separate apps for staging and production, each environment needs its own.
Ignoring the prefix. The header carries sha256= before the hash. Either compare the full string (as in the examples) or strip the prefix on both sides.
Middleware or gateways changing the body. Proxies, API gateways and serverless platforms sometimes reformat or encode the body before your code sees it. If validation fails only in production, log the received body length and compare it with Content-Length.
Secret rotated, server not updated. After you reset the app secret in the dashboard, events are signed with the new value. If your server rejects everything with 401, Meta keeps retrying: per the webhooks overview, deliveries that do not get a 200 are retried with decreasing frequency for up to 7 days, which can produce duplicate events. Monitor your endpoint’s 401 rate to catch this early.
Good practices after validation
- Respond fast. Validate, enqueue, return
200. Heavy work (AI, CRM, database) belongs in a worker. - Handle duplicates. Use the message (
wamid) or statusidas an idempotency key, since retries can repeat events. - Accept large payloads. The webhooks overview says a payload can be up to 3 MB; check your server’s body size limit.
- Do not rely on IP allowlists. Meta changes its IP addresses periodically and suggests mTLS instead.
- Test locally through a tunnel before deploying; see how to test the WhatsApp webhook locally with ngrok.
To send a signed test event to your own environment, compute the hash with the same secret:
body='{"object":"whatsapp_business_account","entry":[]}'
signature=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$META_APP_SECRET" | sed 's/^.* //')
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$signature" \
-d "$body"
How to do this with Vectalk
Vectalk is a proxy for the official Cloud API: the Hub receives events from Meta and forwards the same payload described in Meta’s documentation to the URLs you register, with no proprietary format. You can register as many URLs per instance as you need with POST https://apis.vectalk.com.br/api/webhooks, and your endpoint should return 200 within 20 seconds, per the Vectalk webhooks docs. The X-Hub-Signature-256 header covered here is what Meta sends to whoever receives directly from the app; to learn how to authenticate deliveries made by the Hub in your setup, check the docs or reach the team through the contact page. Payload parsing, queueing and idempotency stay the same. To get started, sign up at apps.vectalk.com.br/register.
With a secure webhook in place, the natural next steps are downloading the media customers send and pacing your replies so you do not hit the WhatsApp API throughput and rate limits.
Frequently asked questions
Where do I find the app secret?
In the Meta developer dashboard, under App settings > Basic, in the “App secret” field. Keep it in an environment variable or a secrets manager, never in the repository.
Should the verify token be the same as the app secret?
No, and it should not be. The verify token is a string you choose and is only used for the verification GET. The app secret is generated by Meta and signs POST events.
Why does the GET verification pass but the POST signature fail?
Because they are independent checks. The GET only compares the verify token. When the POST fails, the hash is almost always being computed over parsed JSON or with another app’s secret.
What status code should I return when the signature is invalid?
Anything other than 200, such as 401 or 403. Keep in mind that rejected legitimate deliveries are retried, so an outdated secret shows up as a flood of 401s and late events.
Do I need signature validation in a test environment?
Yes. Validating from day one keeps unchecked code out of production. To test without Meta, generate the signature with openssl as shown above. Payload details are in WhatsApp API message status webhooks.
Official sources consulted on October 28, 2026: Create a webhook endpoint (Meta), WhatsApp webhooks overview (Meta), Graph API webhooks: getting started (Meta), Vectalk webhooks documentation.
Try the official WhatsApp API today
Create your account, generate a token and send your first message with the quick-start guide. Same payloads and webhooks as Meta's Cloud API.
