Skip to content

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.

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
PartMeaning
tThe send time in seconds since the start of 1970-01-01 UTC (Unix time)
v1The fingerprint: HMAC-SHA256 as lower-case hex
The keyThe webhook’s whole secret as shown to you, whsec_ prefix included, used as text with no decoding
The signed stringThe 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.

  1. Read the raw body as it arrived, before any processing.
  2. Split the header at the commas, then each part at its first =, to get t and v1.
  3. Reject the request when either is missing, when t is not an integer, or when it differs from your server’s clock by more than 300 seconds in either direction.
  4. Compute HMAC-SHA256 with the secret over t, then ., then the raw body.
  5. Compare the result with v1 using a constant-time comparison, not plain equality.
  6. Only after it passes: parse the body as JSON, skip it if you already handled the same X-Mujib-Delivery, then answer with a 2xx status.

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:

verify-mujib.mjs
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:

receiver.mjs
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.

The function uses only the standard library and takes the body as bytes:

verify_mujib.py
import hashlib
import hmac
import 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:

app.py
import json
import 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)

hash_hmac and hash_equals are part of PHP itself, so no library is needed:

verify-mujib.php
<?php
const 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.

webhook.php
<?php
require __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:

check.mjs
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)); // true
console.log(verifyMujibSignature(secret, header, body, 1767225901)); // false: more than 5 minutes later
check.py
from 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)) # True
print(verify_mujib_signature(secret, header, body, now=1767225901)) # False: more than 5 minutes later
check.php
<?php
require __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 later

Then 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.

CauseFix
You parsed the body as JSON and wrote it back before verifyingJSON 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 secretThe key is the whole secret as shown to you
You rotated the secret but did not update your systemThe old secret stops at once when you press Rotate secret; put the new one in your system
Your server’s clock is offTurn on automatic time sync; the allowed difference is 300 seconds
You are replaying an old request you savedAn 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 bodyAny 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-Delivery and 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-Delivery then 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.