# توقيع webhook: التحقق من طلبات مُجيب بـ HMAC-SHA256

> توقيع webhook في مُجيب يثبت أن الطلب جاء من مُجيب ولم يُعدَّل. هنا خطوات التحقق والحماية من إعادة الإرسال، مع أمثلة مجرّبة في Node.js وPython وPHP.

توقيع webhook في مُجيب هو الترويسة `X-Mujib-Signature` التي ترافق كل طلب، وفيها وقت الإرسال وبصمة HMAC-SHA256 محسوبة بسر الـ Webhook على الوقت والجسم الخام معًا. تحقق منها قبل أن تثق بأي حدث، وارفض الطلب إذا مضى على توقيعه أكثر من 5 دقائق.

بهذا تعرف أن الطلب جاء من مُجيب لا من أي جهة عرفت رابطك، وأن أحدًا لم يغيّر حرفًا فيه. السر يظهر لك مرة واحدة عند إنشاء الـ Webhook، كما في [webhook واتساب](https://docs.mujib.ai/developers/webhooks/).

## كيف يُبنى توقيع webhook في مُجيب؟

يحسب مُجيب HMAC-SHA256 بسر الـ Webhook على نص يتكون من الوقت ثم نقطة ثم الجسم كما أُرسل، ويضع النتيجة في الترويسة بهذا الشكل:

```text
X-Mujib-Signature: t=1767225600,v1=cde5fca83dacb883f5dbab42a1ba111d11c9b4778fdc91b404cc171beddd842e
```

| الجزء | المعنى |
| --- | --- |
| `t` | وقت الإرسال بالثواني منذ بداية `1970-01-01` بتوقيت UTC (وقت Unix) |
| `v1` | البصمة: ناتج HMAC-SHA256 بصيغة hex وبأحرف صغيرة |
| المفتاح | سر الـ Webhook كاملًا كما ظهر لك، ومعه البادئة `whsec_`، نصًّا كما هو بلا فك ترميز |
| النص الموقَّع | قيمة `t`، ثم نقطة، ثم الجسم الخام كما وصل بايتًا بايتًا: `<t>.<raw body>` |

كل محاولة إرسال توقَّع من جديد بوقتها، والجسم لا يتغير بين المحاولات.

## كيف أتحقق من التوقيع خطوة بخطوة؟

تحقق على الجسم الخام قبل أن تحوّله إلى JSON، ثم قارن البصمتين مقارنة ثابتة الزمن.

1. اقرأ الجسم الخام كما وصل، قبل أي معالجة.
2. قسّم الترويسة عند الفاصلة، ثم كل جزء عند أول `=`، لتأخذ `t` و`v1`.
3. ارفض الطلب إذا غاب أحدهما، أو لم يكن `t` عددًا صحيحًا، أو زاد الفرق بينه وبين ساعة خادمك على 300 ثانية في أي اتجاه.
4. احسب HMAC-SHA256 بالسر على `t` ثم `.` ثم الجسم الخام.
5. قارن الناتج بـ `v1` بدالة مقارنة ثابتة الزمن، لا بعلامة المساواة العادية.
6. بعد نجاح التحقق فقط: حوّل الجسم إلى JSON، وتجاهله إذا كنت عالجت قيمة `X-Mujib-Delivery` نفسها من قبل، ثم رد برمز من فئة `2xx`.

## كيف أتحقق من توقيع webhook في Node.js؟

هذه الدالة لا تحتاج إلى أي مكتبة، وتقبل الجسم `Buffer` أو نصًّا:

```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);
}
```

وهذا مستقبِل كامل بوحدة `node:http` المدمجة. يجمع الجسم خامًا، ويرد قبل أن يعالج الحدث، ويتجاهل الإرسال المكرر:

```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);
```

إذا استخدمت إطار عمل فأعطِ الدالة الجسم الخام، لا الكائن الذي ينتجه محلّل JSON.

## كيف أتحقق من التوقيع في Python؟

الدالة تستخدم المكتبة القياسية وحدها، وتأخذ الجسم `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())
```

ومع FastAPI تعطيك `await request.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)
```

## كيف أتحقق من التوقيع في PHP؟

`hash_hmac` و`hash_equals` من PHP نفسها، فلا تحتاج إلى أي مكتبة:

```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']);
}
```

اقرأ الجسم من `php://input`، فمصفوفة `$_POST` تبقى فارغة مع 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';
```

## كيف أختبر الكود قبل أن يصل حدث حقيقي؟

جرّب دالتك على هذا المثال الثابت أولًا، ثم أرسل تجربة حقيقية من اللوحة. المثال موقَّع بمُوقِّع مُجيب نفسه بسر تجريبي، والجسم سطر واحد بلا سطر جديد في آخره:

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

بعد ذلك افتح الـ Webhook في اللوحة واضغط «أرسل تجربة». يصل حدث `webhook.test` موقَّعًا بسرك الحقيقي، وتظهر نتيجته في «آخر الإرسالات».

## لماذا يفشل التحقق مع أن السر صحيح؟

السبب في الغالب أن البصمة حُسبت على نص غير الجسم الخام، أو بسر غير السر الحالي.

| السبب | الحل |
| --- | --- |
| حوّلت الجسم إلى JSON ثم أعدت كتابته قبل التحقق | مكتبات JSON تغيّر المسافات، وبعضها يكتب الحروف العربية رموزًا مثل `\u0633`. تحقق على الجسم الخام |
| حذفت البادئة `whsec_` من السر | المفتاح هو السر كاملًا كما ظهر لك |
| جدّدت السر ولم تحدّث نظامك | السر القديم يتوقف فورًا عند «جدّد السر»؛ ضع الجديد في نظامك |
| ساعة خادمك غير مضبوطة | فعّل مزامنة الوقت التلقائية؛ الفرق المسموح 300 ثانية |
| تعيد إرسال طلب قديم محفوظ لديك | التوقيع القديم يُرفض بعد 5 دقائق، فاضغط «أرسل تجربة» لتجربة جديدة |
| أضافت أداة وسيطة مسافة أو سطرًا في آخر الجسم | أي بايت زائد يغيّر البصمة؛ مرّر الجسم كما هو |

## كيف يحميني التوقيع من إعادة إرسال طلب قديم؟

الوقت `t` جزء من النص الموقَّع، فلا يستطيع من التقط طلبًا قديمًا أن يغيّر وقته من دون السر، والطلب الذي مضى على توقيعه أكثر من 5 دقائق ترفضه دالتك.

- داخل هذه المهلة احفظ قيمة `X-Mujib-Delivery` وتجاهل كل طلب عالجت قيمته من قبل.
- إعادة المحاولة من مُجيب توقَّع بوقت جديد، فتجتاز التحقق دائمًا، ثم يكشفها `X-Mujib-Delivery` نفسه.
- لا تقبل طلبًا بلا توقيع حتى في بيئة الاختبار؛ حدث التجربة موقَّع مثل غيره.

## الأسئلة الشائعة

### هل مفتاح HMAC هو السر كله أم ما بعد whsec_؟

السر كله كما ظهر لك، ومعه البادئة whsec_. إذا حذفتها فلن تتطابق البصمة أبدًا.

### هل يتغير التوقيع في كل إعادة محاولة؟

نعم. كل محاولة توقَّع من جديد بوقتها، أما الجسم والمعرّف X-Mujib-Delivery فيبقيان كما هما.

### بماذا أرد إذا فشل التحقق؟

برمز خطأ مثل 401. يعدّه مُجيب فشلًا ويعيد المحاولة، وبعد 20 إرسالًا فاشلًا متتاليًا يتوقف الـ Webhook وحده.

### ماذا أفعل إذا ضاع السر؟

لا يمكن عرضه مرة أخرى. افتح الـ Webhook واضغط «جدّد السر»، ثم ضع السر الجديد في نظامك فورًا.

---

المصدر: https://docs.mujib.ai/developers/webhook-signatures/ · آخر تحديث: 2026-11-04
