# التحقق من التواقيع

وصفة واحدة لنظامي الـ webhooks

توقّع webhooks الخاصة بـ API v1 (`POST /v1/webhooks`) و[إضافة Webhooks](https://dzbuild.com/ar/ar/api-docs/webhooks/overview.md) للتاجر (`/dashboard/webhooks`، خطة Unlimited فما فوق) التسليمات بالطريقة نفسها، كلٌّ بسر الـ webhook أو النقطة المعنية. في webhook الخاص بـ API v1، هذا السر هو `secret` الذي يُرجعه `POST /v1/webhooks` مرة واحدة.

الـ webhook المسجّل عبر الـ API قبل اعتماد التوقيع بسر كل webhook يبقى على التوقيع القديم: قيمة ست عشرية مجرّدة بلا جزء `t=`، لا يستطيع أي سر لديك إعادة حسابها. احذف ذلك الـ webhook وسجّله من جديد، ثم تحقّق بالسر الجديد.

## الوصفة[​](#الوصفة "رابط مباشر إلى الوصفة")

يرسل النظامان ترويسة مفصولة بفواصل على طريقة Stripe، ويوقّعان **الجسم الخام** لا تجزئته:

```
X-DZ-Signature: t=<unix seconds>,v1=<hex hmac-sha256>



expected = hex( hmac_sha256( WEBHOOK_SECRET, t + "." + raw_body ) )



if (!constant_time_equal(expected, v1)) reject 401

if (abs(now - t) > 300)                 reject 401   # نافذة إعادة ±5 دقائق
```

ثلاث قواعد للأمان:

1. **استخدم بايتات الجسم الخام.** إعادة تسلسل JSON تُغيّر مدخل التوقيع.
2. **قارن بزمن ثابت.** `==` العادي يُسرّب معلومات توقيت تساعد التخمين العنيف.
3. **ارفض الطوابع القديمة** (أكثر من 5 دقائق عن ساعة خادمك). شغّل NTP.

مجموعة الترويسات الكاملة التي يصل بها تسليم الإضافة:

```
Content-Type:    application/json

User-Agent:      DZBuild-Webhooks/1.0

X-DZ-Timestamp:  <unix seconds>

X-DZ-Signature:  t=<unix seconds>,v1=<hex hmac-sha256>

X-DZ-Event:      order.confirmed

X-DZ-Delivery:   <numeric delivery id>

X-DZ-Token:      <your endpoint secret, in plain text>
```

`X-DZ-Token` توأم عملي للتوقيع، موجّه لأدوات الـ no-code (n8n وMake وZapier) التي لا تدعم إلا المصادقة بالترويسات: قارنه بالسر المخزَّن لديك بمقارنة ذات زمن ثابت. إنه بيان اعتماد من نوع bearer داخل ترويسة — لا تستعمله إلا عبر HTTPS، وفضّل HMAC حين تكتب شيفرة حقيقية.

يصل تسليم API v1 بترويسات أقل:

```
Content-Type:     application/json

User-Agent:       dzbuild-webhook/1

X-DZ-Timestamp:   <unix seconds>

X-DZ-Signature:   t=<unix seconds>,v1=<hex hmac-sha256>

X-DZ-Delivery-Id: <numeric delivery id>
```

لا يحمل `X-DZ-Event` ولا `X-DZ-Token`: اقرأ اسم الحدث من الحقل `event` في الجسم بعد التحقق منه. التوقيع يغطي الجسم وحده، لذا اعتبر `X-DZ-Delivery-Id` غير موثَّق.

## الكود[​](#الكود "رابط مباشر إلى الكود")

**Node.js (Express)**

```
import crypto from 'node:crypto';

import express from 'express';



const app = express();

const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;



// "t=1717112657,v1=abc..." → { t: "1717112657", v1: "abc..." }

function parseSigHeader(raw) {

  const out = {};

  for (const part of String(raw || '').split(',')) {

    const i = part.indexOf('=');

    if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();

  }

  return out;

}



// مهم: التقط الجسم الخام لـ HMAC، منفصلًا عن JSON المحلَّل.

app.post('/webhooks/dzbuild',

  express.raw({ type: 'application/json' }),

  (req, res) => {

    const { t: ts, v1: sig } = parseSigHeader(req.get('X-DZ-Signature'));

    if (!ts || !sig) return res.status(401).end();



    if (Math.abs(Math.floor(Date.now()/1000) - Number(ts)) > 300) {

      return res.status(401).end();   // طابع زمني قديم أو مستقبلي

    }



    const expected = crypto

      .createHmac('sha256', WEBHOOK_SECRET)

      .update(`${ts}.${req.body.toString('utf8')}`)   // الجسم الخام، لا تجزئته

      .digest('hex');



    // افحص الطول أولًا — timingSafeEqual يرمي استثناءً عند اختلاف أطوال المخازن.

    if (expected.length !== sig.length ||

        !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {

      return res.status(401).end();

    }



    // تم التحقق — الآن يمكنك التحليل والتصرّف.

    const event = JSON.parse(req.body.toString('utf8'));

    console.log('verified', event.event, event);

    res.status(200).end();   // ack بأسرع ما يمكن

  });



app.listen(3000);
```

**PHP (raw)**

```
<?php

$secret = getenv('DZBUILD_WEBHOOK_SECRET');

$body   = file_get_contents('php://input');     // الجسم الخام

$header = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';



$parts = [];

foreach (explode(',', $header) as $piece) {

    $kv = explode('=', trim($piece), 2);

    if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }

}

$ts  = $parts['t']  ?? '';

$sig = $parts['v1'] ?? '';



if ($ts === '' || $sig === '') { http_response_code(401); exit; }

if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }



$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);



if (!hash_equals($expected, strtolower($sig))) {

    http_response_code(401);

    exit;

}



$event = json_decode($body, true);

// عالج $event['event'] و $event['data']

http_response_code(200);
```

في Laravel استعمل route middleware أو controller يقرأ `$request->getContent()` للجسم الخام. عطّل CSRF على مسار webhook.

**Python (Flask)**

```
import hashlib, hmac, os, time

from flask import Flask, request, abort



app = Flask(__name__)

WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()



def parse_sig(raw):

    out = {}

    for part in (raw or '').split(','):

        k, _, v = part.partition('=')

        if v:

            out[k.strip()] = v.strip()

    return out



@app.post('/webhooks/dzbuild')

def receive():

    parts = parse_sig(request.headers.get('X-DZ-Signature'))

    ts, sig = parts.get('t'), parts.get('v1')

    if not ts or not sig: abort(401)

    if abs(int(time.time()) - int(ts)) > 300: abort(401)



    body = request.get_data()      # بايتات خام — لا تستعمل request.json

    expected = hmac.new(WEBHOOK_SECRET,

                        ts.encode() + b'.' + body,

                        hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, sig.lower()): abort(401)



    event = request.get_json()

    print('verified', event['event'], event)

    return '', 200
```

**Go (net/http)**

```
package main



import (

    "crypto/hmac"

    "crypto/sha256"

    "encoding/hex"

    "io"

    "net/http"

    "os"

    "strconv"

    "strings"

    "time"

)



var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))



func parseSig(h string) (ts, v1 string) {

    for _, part := range strings.Split(h, ",") {

        kv := strings.SplitN(strings.TrimSpace(part), "=", 2)

        if len(kv) != 2 { continue }

        switch kv[0] {

        case "t":

            ts = kv[1]

        case "v1":

            v1 = kv[1]

        }

    }

    return

}



func receive(w http.ResponseWriter, r *http.Request) {

    body, err := io.ReadAll(r.Body)

    if err != nil { http.Error(w, "read", 400); return }



    ts, sig := parseSig(r.Header.Get("X-DZ-Signature"))

    if ts == "" || sig == "" { http.Error(w, "no sig", 401); return }



    tsInt, err := strconv.ParseInt(ts, 10, 64)

    if err != nil { http.Error(w, "ts", 401); return }

    if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401); return }



    mac := hmac.New(sha256.New, secret)

    mac.Write([]byte(ts + "." + string(body)))

    expected := hex.EncodeToString(mac.Sum(nil))



    if !hmac.Equal([]byte(expected), []byte(sig)) {

        http.Error(w, "bad sig", 401); return

    }

    w.WriteHeader(http.StatusOK)

}



func abs(x int64) int64 { if x < 0 { return -x }; return x }
```

## قيود جهة الاستقبال[​](#قيود-جهة-الاستقبال "رابط مباشر إلى قيود جهة الاستقبال")

تنطبق هذه على تسليمات **API v1**، وهي الجواب المعتاد على سؤال "نقطتي لا تُنادى أبدًا":

| القيد            | القيمة                                                                   | ماذا يحدث إن خالفته                                                                                                                                                                                         |
| ---------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| مهلة الاتصال     | 5 ثوانٍ                                                                  | تُحسب إخفاق نقل: يُعاد مع تباعد متزايد، ويُسقط بعد 5 محاولات                                                                                                                                                |
| المهلة الإجمالية | 10 ثوانٍ                                                                 | الأمر ذاته: يُعاد مع تباعد متزايد، ويُسقط بعد 5 محاولات                                                                                                                                                     |
| إعادة التوجيه    | غير متبوعة                                                               | `301`/`302` إخفاق، و 3xx لا يُعاد أبدًا                                                                                                                                                                     |
| التحقق من TLS    | صارم                                                                     | الشهادات الموقّعة ذاتيًا أو المنتهية تفشل في كل محاولة                                                                                                                                                      |
| المخطط           | `https://` فقط                                                           | يجيب التسجيل بـ `400 bad_request` "url must be https"                                                                                                                                                       |
| المضيف           | اسم مضيف لا عنوان IP، على المنفذ 443 أو 80، ولا يشير إلا إلى عناوين عامة | يجيب التسجيل بـ `400 bad_request`. ويُفحص العنوان من جديد قبل كل محاولة، فالمضيف الذي يصير لاحقًا يشير إلى عنوان خاص، أو لم يعد له سجل DNS، تفشل محاولته؛ فتُعاد، ثم ينتقل التسليم إلى قائمة الرسائل الميتة |
| حالة الاستجابة   | `2xx` = تم التسليم                                                       | تُعاد محاولة 5xx و`408` و`429`؛ وأي 4xx آخر، وكل 3xx، ينقل التسليم فورًا إلى قائمة الرسائل الميتة                                                                                                           |
| الطريقة / الجسم  | `POST` عادي، وجسم JSON                                                   | —                                                                                                                                                                                                           |

وجّه الـ webhook إلى الرابط **النهائي** (بلا إعادة توجيه من `www` إلى النطاق الجذر، وبلا قفزة من HTTP إلى HTTPS) وقدّم شهادة موثوقة عموميًا.

## أخطاء شائعة[​](#أخطاء-شائعة "رابط مباشر إلى أخطاء شائعة")

| الخطأ                                                     | العَرَض                                                                    | الحل                                                                                                                    |
| --------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| التحقق من تسليم webhook سُجّل قبل التوقيع بسر كل webhook  | كل تسليم يُرفض بحجة "توقيع خاطئ"، والترويسة بلا جزء `t=`                   | احذف الـ webhook وسجّله من جديد واستعمل `secret` الجديد. انظر الملاحظة في أعلى الصفحة                                   |
| إعادة تسلسل جسم JSON قبل التوقيع                          | التوقيع لا يطابق أبدًا                                                     | استخدم بايتات الجسم الخام — انظر ملاحظات الأطر في [التسجيل](https://dzbuild.com/ar/ar/api-docs/webhooks/registering.md) |
| تجزئة الجسم قبل HMAC                                      | التوقيع لا يطابق أبدًا                                                     | DZBuild توقّع `t + "." + raw_body`، لا تجزئة الجسم                                                                      |
| قراءة `t` من `X-DZ-Timestamp` بينما `v1` من ترويسة مجرّدة | أخطاء تحليل / توقيع فارغ                                                   | `X-DZ-Signature` مفصولة بفواصل: `t=…,v1=…`                                                                              |
| انحراف ساعة الخادم                                        | رفض "Timestamp out of window"                                              | شغّل NTP، تحقّق من `timedatectl status` على Linux                                                                       |
| المقارنة بـ `==` بدل زمن ثابت                             | ثغرة timing-attack دقيقة                                                   | استعمل `crypto.timingSafeEqual` / `hmac.compare_digest` / `hash_equals`                                                 |
| استعمال `timingSafeEqual` بلا فحص طول                     | يُرمى `RangeError` بدل إرجاع 401                                           | قارن الأطوال أولًا، كما في مثال Node                                                                                    |
| تسجيل السر على القرص                                      | السر ينتهي في ملفات السجل                                                  | لا تُسجّله؛ استعمل secrets store؛ أعد توليده إن تسرّب                                                                   |
| الردّ بـ 200 فورًا والمعالجة لاحقًا                       | فقدان أحداث عند تحطّم العامل                                               | إما احفظ في طابورك ثم ack، أو نفّذ العمل بشكل متزامن وردّ آخر شيء                                                       |
| إزالة التكرار بالاعتماد على ترويسة `X-DZ-Delivery-Id`     | تسليم أُعيد إرساله بقيمة ترويسة مغيّرة يتجاوز إزالة التكرار ويُعالَج مرتين | أزل التكرار بـ `delivery_id` الموجود في الجسم بعد التحقق منه                                                            |

## Idempotency من جانبك[​](#idempotency-من-جانبك "رابط مباشر إلى Idempotency من جانبك")

نفس `delivery_id` قد يصل أكثر من مرة. وفي API v1 لهذا شكل محدّد:

* **النقطة التي تُرجع 5xx أو `408` أو `429`، أو تتجاوز المهلة، يُعاد الإرسال إليها حتى أربع مرات خلال نحو ساعتين ونصف.** التكرارات أمر روتيني: الطلب الذي انتهت مهلته عندنا ربما عولج عندك، فإزالة التكرار إلزامية.
* **بعد المحاولة الفاشلة الخامسة يُسقط التسليم.** النقطة البطيئة قد تفقد أحداثًا، فأرجِع الإقرار بسرعة ونفّذ العمل بشكل غير متزامن.

أزل التكرار من جانب الاستقبال، بالاعتماد على `delivery_id` من الجسم بعد التحقق منه (لا على ترويسة `X-DZ-Delivery-Id` غير الموقَّعة):

```
INSERT INTO webhook_log (delivery_id, event, body) VALUES (?, ?, ?);

-- أمسك انتهاك UNIQUE على delivery_id → معالَج بالفعل، أعد 200 على أي حال
```

هذا النمط يعني: حتى إن أعدنا النداء، تُنفّذ العمل مرة وتردّ بسرعة.
