# Verify a Mujib webhook signature with HMAC-SHA256

> A Mujib webhook signature proves a request came from Mujib unchanged: the verification steps and replay protection, with tested code in Node.js, Python and PHP.

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](https://docs.mujib.ai/en/developers/webhooks/).

## 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:

```text
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?

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?

This function needs no library and takes the body as a `Buffer` or a string:

```js title="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:

```js title="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.

## How do I verify the signature in Python?

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

```python title="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:

```python title="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)
```

## How do I verify the signature in PHP?

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

```php title="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.

```php title="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?

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:

```js title="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
```

```python title="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
```

```php title="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?

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?

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.

---

Source: https://docs.mujib.ai/en/developers/webhook-signatures/ · Last updated: 2026-11-04
