Verify a Mujib webhook signature with HMAC-SHA256
A Mujib webhook signature is the X-Mujib-Signature header on every request: the send time plus an HMAC-SHA256 fingerprint, computed with the webhook’s secret over that time and the raw body together. Verify it before you trust any event, and reject the request when its signature is more than 5 minutes old.
That way you know the request came from Mujib and not from anyone who learned your URL, and that nobody changed a character of it. The secret is shown to you once, when you create the webhook, as described in WhatsApp webhook.
How is the Mujib webhook signature built?
Section titled “How is the Mujib webhook signature built?”Mujib computes HMAC-SHA256 with the webhook’s secret over a string made of the time, a dot and the body as sent, and puts the result in the header like this:
X-Mujib-Signature: t=1767225600,v1=cde5fca83dacb883f5dbab42a1ba111d11c9b4778fdc91b404cc171beddd842e| Part | Meaning |
|---|---|
t | The send time in seconds since the start of 1970-01-01 UTC (Unix time) |
v1 | The fingerprint: HMAC-SHA256 as lower-case hex |
| The key | The webhook’s whole secret as shown to you, whsec_ prefix included, used as text with no decoding |
| The signed string | The value of t, a dot, then the raw body byte for byte: <t>.<raw body> |
Every delivery attempt is signed afresh with its own time; the body does not change between attempts.
How do I verify a webhook signature, step by step?
Section titled “How do I verify a webhook signature, step by step?”Verify over the raw body before you parse it as JSON, then compare the two fingerprints in constant time.
- Read the raw body as it arrived, before any processing.
- Split the header at the commas, then each part at its first
=, to gettandv1. - Reject the request when either is missing, when
tis not an integer, or when it differs from your server’s clock by more than 300 seconds in either direction. - Compute HMAC-SHA256 with the secret over
t, then., then the raw body. - Compare the result with
v1using a constant-time comparison, not plain equality. - Only after it passes: parse the body as JSON, skip it if you already handled the same
X-Mujib-Delivery, then answer with a2xxstatus.
How do I verify a webhook signature in Node.js?
Section titled “How do I verify a webhook signature in Node.js?”This function needs no library and takes the body as a Buffer or a string:
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/** true when `header` (X-Mujib-Signature) signs `rawBody` (a Buffer or string) with `secret`. */export function verifyMujibSignature(secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) { const parts = Object.fromEntries( String(header ?? '').split(',').map((part) => { const i = part.indexOf('='); return [part.slice(0, i).trim(), part.slice(i + 1).trim()]; }), ); const t = Number(parts.t); if (!Number.isInteger(t) || Math.abs(nowSeconds - t) > TOLERANCE_SECONDS || !parts.v1) return false; const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest(); const given = Buffer.from(parts.v1, 'hex'); return given.length === expected.length && timingSafeEqual(given, expected);}And here is a complete receiver on the built-in node:http module. It collects the raw body, answers before handling the event, and skips repeated deliveries:
import { createServer } from 'node:http';import { verifyMujibSignature } from './verify-mujib.mjs';
const secret = process.env.MUJIB_WEBHOOK_SECRET;const handled = new Set(); // keep this in your database in production
createServer((req, res) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { const raw = Buffer.concat(chunks); // the body exactly as it arrived if (!verifyMujibSignature(secret, req.headers['x-mujib-signature'], raw)) { res.writeHead(401).end(); return; } res.writeHead(200).end('ok'); // answer first, then work const deliveryId = req.headers['x-mujib-delivery']; if (handled.has(deliveryId)) return; // a retry of a delivery already handled handled.add(deliveryId); const event = JSON.parse(raw.toString('utf8')); console.log(event.type, event.id); });}).listen(3000);With a framework, hand the function the raw body, not the object a JSON parser produced.
How do I verify the signature in Python?
Section titled “How do I verify the signature in Python?”The function uses only the standard library and takes the body as bytes:
import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_mujib_signature(secret, header, raw_body, now=None): """True when header (X-Mujib-Signature) signs raw_body (bytes) with secret.""" parts = {} for part in (header or "").split(","): key, sep, value = part.partition("=") if sep: parts[key.strip()] = value.strip() try: t = int(parts.get("t", "")) except ValueError: return False v1 = parts.get("v1", "") now = int(time.time()) if now is None else now if abs(now - t) > TOLERANCE_SECONDS or not v1: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), v1.encode())With FastAPI, await request.body() gives you the raw body:
import jsonimport os
from fastapi import FastAPI, Request, Response
from verify_mujib import verify_mujib_signature
app = FastAPI()SECRET = os.environ["MUJIB_WEBHOOK_SECRET"]
@app.post("/hooks/mujib")async def mujib_webhook(request: Request): raw = await request.body() # the body exactly as it arrived if not verify_mujib_signature(SECRET, request.headers.get("x-mujib-signature", ""), raw): return Response(status_code=401) event = json.loads(raw) # Deduplicate on request.headers["x-mujib-delivery"], then queue the work. print(event["type"], event["id"]) return Response(status_code=200)How do I verify the signature in PHP?
Section titled “How do I verify the signature in PHP?”hash_hmac and hash_equals are part of PHP itself, so no library is needed:
<?phpconst MUJIB_TOLERANCE_SECONDS = 300;
/** true when $header (X-Mujib-Signature) signs $rawBody with $secret. */function verify_mujib_signature(string $secret, string $header, string $rawBody, ?int $now = null): bool{ $parts = []; foreach (explode(',', $header) as $part) { $kv = explode('=', $part, 2); if (count($kv) === 2) { $parts[trim($kv[0])] = trim($kv[1]); } } if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; } $t = (int) $parts['t']; if (abs(($now ?? time()) - $t) > MUJIB_TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']);}Read the body from php://input: $_POST stays empty for JSON.
<?phprequire __DIR__ . '/verify-mujib.php';
$raw = file_get_contents('php://input'); // the body exactly as it arrived$header = $_SERVER['HTTP_X_MUJIB_SIGNATURE'] ?? '';
if (!verify_mujib_signature(getenv('MUJIB_WEBHOOK_SECRET') ?: '', $header, $raw)) { http_response_code(401); exit;}
$event = json_decode($raw, true);// Deduplicate on $_SERVER['HTTP_X_MUJIB_DELIVERY'], then queue the work.error_log($event['type'] . ' ' . $event['id']);http_response_code(200);echo 'ok';How do I test my code before a real event arrives?
Section titled “How do I test my code before a real event arrives?”Run your function on this fixed example first, then send a real test from the dashboard. The example was signed by Mujib’s own signer with a throwaway secret; the body is one line with no trailing newline:
import { verifyMujibSignature } from './verify-mujib.mjs';
const secret = 'whsec_example_secret_do_not_use';const header = 't=1767225600,v1=cde5fca83dacb883f5dbab42a1ba111d11c9b4778fdc91b404cc171beddd842e';const body = '{"id":"5d7c1e2a-3b4f-4c6d-9e8f-0a1b2c3d4e5f","type":"contact.created","createdAt":"2026-01-01T00:00:00.000Z","workspaceId":"7f3e9a10-2c4b-4d5e-8f60-71829a0b1c2d","data":{"contact":{"id":"c1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8","name":"سارة","phone":"962790000000","channel":"whatsapp","stage":"new","createdAt":"2026-01-01T00:00:00.000Z"}}}';
console.log(verifyMujibSignature(secret, header, body, 1767225600)); // trueconsole.log(verifyMujibSignature(secret, header, body, 1767225901)); // false: more than 5 minutes laterfrom verify_mujib import verify_mujib_signature
secret = "whsec_example_secret_do_not_use"header = "t=1767225600,v1=cde5fca83dacb883f5dbab42a1ba111d11c9b4778fdc91b404cc171beddd842e"body = '{"id":"5d7c1e2a-3b4f-4c6d-9e8f-0a1b2c3d4e5f","type":"contact.created","createdAt":"2026-01-01T00:00:00.000Z","workspaceId":"7f3e9a10-2c4b-4d5e-8f60-71829a0b1c2d","data":{"contact":{"id":"c1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8","name":"سارة","phone":"962790000000","channel":"whatsapp","stage":"new","createdAt":"2026-01-01T00:00:00.000Z"}}}'.encode()
print(verify_mujib_signature(secret, header, body, now=1767225600)) # Trueprint(verify_mujib_signature(secret, header, body, now=1767225901)) # False: more than 5 minutes later<?phprequire __DIR__ . '/verify-mujib.php';
$secret = 'whsec_example_secret_do_not_use';$header = 't=1767225600,v1=cde5fca83dacb883f5dbab42a1ba111d11c9b4778fdc91b404cc171beddd842e';$body = '{"id":"5d7c1e2a-3b4f-4c6d-9e8f-0a1b2c3d4e5f","type":"contact.created","createdAt":"2026-01-01T00:00:00.000Z","workspaceId":"7f3e9a10-2c4b-4d5e-8f60-71829a0b1c2d","data":{"contact":{"id":"c1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8","name":"سارة","phone":"962790000000","channel":"whatsapp","stage":"new","createdAt":"2026-01-01T00:00:00.000Z"}}}';
var_dump(verify_mujib_signature($secret, $header, $body, 1767225600)); // bool(true)var_dump(verify_mujib_signature($secret, $header, $body, 1767225901)); // bool(false): more than 5 minutes laterThen open the webhook in the dashboard and press Send a test. A webhook.test event arrives signed with your real secret, and its result shows in Recent deliveries.
Why does verification fail when the secret is right?
Section titled “Why does verification fail when the secret is right?”Almost always the fingerprint was computed over something other than the raw body, or with a secret that is no longer current.
| Cause | Fix |
|---|---|
| You parsed the body as JSON and wrote it back before verifying | JSON libraries change spacing, and some write Arabic letters as escapes such as \u0633. Verify over the raw body |
You dropped the whsec_ prefix from the secret | The key is the whole secret as shown to you |
| You rotated the secret but did not update your system | The old secret stops at once when you press Rotate secret; put the new one in your system |
| Your server’s clock is off | Turn on automatic time sync; the allowed difference is 300 seconds |
| You are replaying an old request you saved | An old signature is refused after 5 minutes; press Send a test for a fresh one |
| A proxy or tool added a space or a newline at the end of the body | Any extra byte changes the fingerprint; pass the body through untouched |
How does the signature protect me from replayed requests?
Section titled “How does the signature protect me from replayed requests?”The time t is part of the signed string, so someone who captured an old request cannot change its time without the secret, and your function refuses a request whose signature is more than 5 minutes old.
- Within that window, store
X-Mujib-Deliveryand skip every request whose value you already handled. - A retry from Mujib is signed with a new time, so it always passes verification, and the same
X-Mujib-Deliverythen gives it away. - Never accept an unsigned request, not even in a test environment: the test event is signed like every other.
Frequently asked questions
Is the HMAC key the whole secret or the part after whsec_?
The whole secret as it was shown to you, whsec_ prefix included. Drop the prefix and the signature never matches.
Does the signature change on every retry?
Yes. Every attempt is signed afresh with its own time, while the body and the X-Mujib-Delivery id stay the same.
What should I answer when verification fails?
An error status such as 401. Mujib counts it as a failure and retries, and after 20 failed deliveries in a row the webhook pauses itself.
What if I lose the secret?
It cannot be shown again. Open the webhook, press “Rotate secret”, and put the new secret in your system right away.