---
title: "التحقق من توقيعات webhook"
description: "يُوقَّع كل طلب webhook وطلب أداة يرسله ThunderPhone بطابع زمني. تحقّق منه باستخدام الوصفة أو مساعد SDK هنا، وارفض الطلبات المعاد تشغيلها التي مضى عليها أكثر من خمس دقائق."
---

يحمل كل طلب نرسله إلى خادمك (عمليات تسليم webhook وwebhook القديم ذي عنوان URL واحد واستدعاءات نقاط نهاية الأدوات) رأسي توقيع. كلاهما يستخدم HMAC-SHA256 مع السر نفسه.

| الرأس | يوقّع | الحماية من إعادة الإرسال |
|--------|-------|-------------------|
| `X-ThunderPhone-Signature-V2` (موصى به) | الطابع الزمني والطريقة والمسار والاستعلام والنص | ارفض الطلبات التي يزيد عمرها على 5 دقائق |
| `X-ThunderPhone-Signature` | النص فقط | لا توجد: يظل الطلب الملتقط صالحًا للتحقق إلى الأبد |

تحقق من `X-ThunderPhone-Signature-V2` في عمليات التكامل الجديدة، وانقل عمليات التكامل الحالية إليه. لا يزال `X-ThunderPhone-Signature` يُرسل مع كل طلب دون تغيير، لذا تواصل أدوات التحقق المبنية عليه العمل. سنعلن عن تاريخ قبل أن نتوقف عن إرساله.

## التوقيع V2

```http
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
```

- `t` هو وقت Unix، بالثواني، عند إرسالنا الطلب. يُوقَّع التسليم المعاد
  مرة أخرى عند إرساله، باستخدام `t` خاص به.
- `v1` هو HMAC-SHA256 سداسي عشري للسلسلة الموقَّعة أدناه، باستخدام
  السر الخاص بك كمفتاح.

القيمة عبارة عن قائمة مفصولة بفواصل من أزواج `key=value`. تجاهل المفاتيح التي
لا تتعرف عليها، واقبل الطلب إذا تطابق أي `v1`: فقد نضيف
توقيعًا ثانيًا أثناء تغيير مستقبلي.

### السلسلة الموقَّعة

أربعة حقول، يتبع كلٌّ منها سطر جديد واحد (`\n`، البايت `0x0A`) باستثناء
الأخير:

```text
<t>\n<METHOD>\n<path and query>\n<body>
```

| الحقل | القيمة |
|-------|-------|
| `t` | قيمة `t` من الرأس |
| `METHOD` | طريقة HTTP بأحرف كبيرة: `POST` أو `GET` أو `PUT` أو `PATCH` أو `DELETE` |
| path and query | هدف الطلب من سطر طلب HTTP: المسار، ثم `?` وسلسلة الاستعلام عند وجودها. بدون مخطط أو مضيف أو جزء. استخدمه تمامًا كما وصل، دون فك ترميزه أو إعادة ترتيب المعلمات |
| `body` | نص الطلب الخام. لا تحتوي استدعاءات الأدوات `GET` و`DELETE` على نص، لذا تنتهي سلسلتها الموقَّعة بالسطر الجديد بعد المسار |

للتحقق:

1. قبل أي تحليل، اقرأ الرأس والطريقة والمسار الخام
   والاستعلام ونص الطلب الخام.
2. خذ `t` وكل قيمة `v1` من الرأس.
3. ارفض الطلب إذا كانت `t` قبل ساعتك أو بعدها بأكثر من **300 ثانية**.
4. احسب HMAC-SHA256 السداسي عشري للسلسلة الموقَّعة باستخدام سرك.
5. قارنه بـ `v1` بزمن ثابت. أرسل استجابة `401` إذا لم يتطابق أيٌّ منها.

### مثال عملي

السر `whsec_example_4c1f2a9b`. نرسل هذا الطلب عند
`t = 1767225600` ‏(2026-01-01 00:00:00 UTC):

```http
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e

{"customer_id":"cust_123"}
```

السلسلة الموقَّعة هي هذه الأسطر الأربعة، دون سطر جديد بعد النص:

```text
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}
```

قيمة HMAC-SHA256 الخاصة بها هي
`03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e`.
تحقق من تنفيذك بمقارنتها، أو أعد إنتاجها باستخدام `openssl`:

```bash
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
  | openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'
```

تحتوي استدعاء أداة `GET` إلى `/tools/lookup?customer_id=cust_123` في الثانية
نفسها على نص فارغ. سلسلتها الموقَّعة هي
`1767225600\nGET\n/tools/lookup?customer_id=cust_123\n` وتوقيعها
هو `3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280`.

### باستخدام حزم SDK

تتضمن [حزمتا SDK لـ Python وTypeScript](/api-reference/sdks) دالة مساعدة
(`thunderphone` بالإصدار 0.2.0 أو أحدث، و`@thunderphone/sdk` بالإصدار 0.2.0 أو أحدث). تتحقق
من نافذة الخمس دقائق، وتقارن بزمن ثابت، وتقبل قائمة
من الأسرار لكي تتمكن من تدويرها دون توقف الخدمة.

<CodeGroup>
```python Python
from thunderphone import verify_signature

ok = verify_signature(
    header,            # the X-ThunderPhone-Signature-V2 value
    secret=SECRET,     # or [NEW_SECRET, OLD_SECRET] while rotating
    method=method,     # "POST"
    path=path,         # "/tools/lookup?region=us", exactly as received
    body=raw_body,     # bytes, before any JSON parsing
    tolerance=300,     # optional; seconds
)
```

```javascript Node.js
import { verifySignature } from "@thunderphone/sdk";

const ok = verifySignature({
  header,               // the X-ThunderPhone-Signature-V2 value
  secret: SECRET,       // or [NEW_SECRET, OLD_SECRET] while rotating
  method,               // "POST"
  path,                 // "/tools/lookup?region=us", exactly as received
  body: rawBody,        // Buffer or string, before any JSON parsing
  toleranceSeconds: 300, // optional
});
```
</CodeGroup>

### بدون حزمة SDK

<CodeGroup>
```python Python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
    """path is the path plus ?query exactly as received; body is the raw body."""
    timestamp, signatures = None, []
    for item in (header or "").split(","):
        key, _, value = item.strip().partition("=")
        if key == "t" and value.isdigit() and len(value) <= 15:
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)
```

```javascript Node.js
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
  let timestamp;
  const signatures = [];
  for (const item of (header || "").split(",")) {
    const [key, value = ""] = item.trim().split("=", 2);
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === undefined) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = Buffer.from(
    crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
      .update(body)
      .digest("hex"),
  );
  return signatures.some((sig) => {
    const candidate = Buffer.from(sig);
    return (
      candidate.length === expected.length &&
      crypto.timingSafeEqual(candidate, expected)
    );
  });
}
```
</CodeGroup>

### ربط الإطار

يقرأ كل مثال المسار والاستعلام الخامَّين والنص الخام، ثم يستدعي
مساعد حزمة SDK.

<CodeGroup>
```python FastAPI
import json

from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature

app = FastAPI()


def raw_path_and_query(request: Request) -> str:
    path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
    query = request.scope.get("query_string", b"").decode("latin-1")
    return f"{path}?{query}" if query else path


@app.post("/thunderphone-webhook")
async def hook(request: Request):
    body = await request.body()  # raw bytes, NOT request.json()
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    event = json.loads(body)
    # … dispatch on event["type"] …
    return {"ok": True}
```

```javascript Express
import express from "express";
import { verifySignature } from "@thunderphone/sdk";

const app = express();

app.post(
  "/thunderphone-webhook",
  // IMPORTANT: parse as raw; do NOT use express.json() here.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifySignature({
      header: req.header("X-ThunderPhone-Signature-V2"),
      secret: process.env.WEBHOOK_SECRET,
      method: req.method,
      path: req.originalUrl, // path and query as received
      body: req.body,
    });
    if (!ok) return res.sendStatus(401);
    const event = JSON.parse(req.body.toString("utf8"));
    // … dispatch on event.type …
    res.sendStatus(204);
  },
);
```

```python Django
import json

from django.http import HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature


@csrf_exempt
@require_POST
def hook(request):
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=SECRET,
        method=request.method,
        path=request.get_full_path(),
        body=request.body,  # raw bytes
    ):
        return HttpResponseForbidden("invalid signature")
    event = json.loads(request.body)
    # … dispatch on event["type"] …
    return JsonResponse({"ok": True})
```
</CodeGroup>

يمثل `req.originalUrl` ‏(Express) وكلٌّ من `raw_path` و`query_string` في ASGI
‏(FastAPI) الهدف كما تم استلامه تمامًا. يفك `get_full_path()` في Django
و`request.path` في Flask ترميز المسار أولًا، ما ينتج عنه السلسلة نفسها للمسارات
العادية. إذا كان مسار نقطة النهاية لديك يحتوي على أحرف مرمزة بنسبة مئوية أو
محجوزة مثل `%2F` أو `;` أو `=`، فاقرأ الهدف الخام الذي يوفره خادمك بدلًا من ذلك
(مثل `request.META["RAW_URI"]` في
Gunicorn).

## أي سر؟

يوقّع السر نفسه كلا الرأسين.

| المصدر | السر |
|--------|--------|
| [نقطة نهاية Webhook](/ar/webhooks/endpoints) (`/v1/developer/webhook-endpoints`) | `secret` لكل نقطة نهاية (48 حرفًا سداسيًا عشريًا) يُعاد مرة واحدة عند الإنشاء |
| [Webhook القديم بعنوان URL واحد](/api-reference/organizations#legacy-single-url-webhook) | `secret` لكل مؤسسة يُعاد عند `GET /v1/webhook` |
| [استدعاء نقطة نهاية أداة](/ar/tools/overview) (استدعاء مباشر إلى `endpoint.url` لديك) | **سر webhook على مستوى المؤسسة** (وهو نفسه المستخدم في webhook القديم بعنوان URL واحد) — وليس سرًا لكل نقطة نهاية |

خزّن السر في مدير الأسرار أو متغير البيئة لديك — ولا تلتزم به في المستودع مطلقًا.

## التحقق من استدعاءات الأدوات

عندما يستدعي الوكيل إحدى
[أدوات الدوال](/ar/tools/overview) مباشرةً (تحتوي الأداة على
`endpoint`)، يحمل الطلب ترويسات ThunderPhone هذه إلى جانب
`endpoint.headers` الذي أعددته:

- `X-ThunderPhone-Call-ID` — المعرّف الرقمي للمكالمة المباشرة.
- `X-ThunderPhone-Signature-V2` — التوقيع المؤرخ زمنيًا أعلاه، والمشفّر
  باستخدام **سر خطاف الويب على مستوى المؤسسة**.
- `X-ThunderPhone-Signature` — توقيع النص الأساسي فقط القديم، باستخدام المفتاح نفسه.

يوجد اختلافان عن خطافات الويب:

1. **لا تحتوي أدوات `GET` / `DELETE` على نص أساسي.** تنتقل الوسائط كمعلمات
   استعلام. يغطي توقيع V2 هذه المعلمات عبر المسار والاستعلام،
   ويكون حقل النص الأساسي فارغًا. تحقّق باستخدام سلسلة الاستعلام الخام: قد يؤدي تحليل
   المعلمات وإنشاء سلسلة استعلام جديدة إلى تغيير ترتيبها
   أو ترميزها. توقّع الترويسة القديمة سلسلة البايتات الفارغة.
2. **المؤسسات التي لم تُعِد خطاف ويب قديمًا لا تملك سرًا للمؤسسة.** في
   هذه الحالة، لا تحمل استدعاءات الأدوات سوى `X-ThunderPhone-Call-ID` ولا تحمل
   أي ترويسات توقيع. أعِد خطاف الويب القديم
   (`PUT /v1/webhook`) للحصول على سر توقيع، أو صادِق على استدعاءات الأدوات
   باستخدام ترويسة خاصة بك عبر `endpoint.headers`.

```python
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
    body = await request.body()  # b"" for GET/DELETE tools
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=ORG_WEBHOOK_SECRET,
        method=request.method,
        path=raw_path_and_query(request),
        body=body,
    ):
        raise HTTPException(status_code=401)
    call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
    args = json.loads(body) if body else dict(request.query_params)
    ...
```

في Express، يترك محلل النص الأساسي `req.body` ككائن فارغ عند استخدام `GET`،
لذا مرّر `Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)`.

إرسال الأدوات في وضع خطاف الويب (الأدوات التي لا تحتوي على `endpoint`، والتي
تصل إلى خطاف ويب مؤسستك بصيغة `telephony.tool` / `web.tool`) هو خطاف ويب
موقّع عادي — تنطبق الوصفة القياسية أعلاه. راجع
[أدوات الدوال](/ar/tools/overview) لكلا شكلي الطلب.

## التوقيع القديم: `X-ThunderPhone-Signature`

الترويسة الأصلية هي قيمة HMAC-SHA256 بالترميز الست عشري للنص الخام وحده، باستخدام
السر نفسه. لا تحمل طابعًا زمنيًا، لذا يمكن لأي شخص يلتقط طلبًا واحدًا
موقّعًا إعادة تشغيله. ولا تزال تُرسل مع كل طلب أثناء انتقال عمليات التكامل إلى V2.

1. اقرأ نص الطلب **الخام** — البايتات الدقيقة التي أرسلناها إليك.
2. احسب `hmac_sha256(secret, body).hexdigest()`.
3. قارن في **وقت ثابت** مع `X-ThunderPhone-Signature`.

نوقّع البايتات نفسها التي نرسلها تمامًا. وهذه البايتات هي أيضًا
**تسلسل JSON المتعارف عليه** للحمولة — مفاتيح مرتبة
أبجديًا، وفواصل مضغوطة (`,` و `:` بلا مسافات)، وUTF-8.
إذا كان إطار العمل لديك لا يوفّر إلا JSON محللًا، فأعِد تسلسله وفق الصيغة المتعارف عليها ثم
احسب HMAC له:

```python
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
```

فضّل النص الخام — فهو خطوة أقل ومحمي من مشكلات التحويل المتكرر لأرقام JSON
في بعض اللغات. وينطبق الأمر نفسه على حقل النص في V2.

<CodeGroup>
```python Python
import hashlib
import hmac


def verify(body: bytes, signature: str, secret: str) -> bool:
    """Constant-time HMAC-SHA256 verification."""
    expected = hmac.new(
        secret.encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

```javascript Node.js
import crypto from "node:crypto";

export function verify(body, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  if (!signature || expected.length !== signature.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature),
  );
}
```

```go Go
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func Verify(body []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}
```

```ruby Ruby
require "openssl"

def verify(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
  Rack::Utils.secure_compare(expected, signature.to_s)
end
```
</CodeGroup>

## الأخطاء الشائعة

<AccordionGroup>
  <Accordion title="انحراف الساعة">
    تُقاس نافذة الخمس دقائق مقارنةً بساعة خادمك. أبقِها
    متزامنة مع NTP؛ فالساعة التي يتجاوز انحرافها 5 دقائق ترفض كل
    طلب.
  </Accordion>

  <Accordion title="وكيل يعيد كتابة المسار">
    المسار الموقّع هو المسار الذي طلبناه: مسار عنوان URL الذي
    أعددته في ThunderPhone. إذا كان وكيل عكسي أو بوابة API يزيل
    بادئة أو يضيفها قبل أن يرى تطبيقك الطلب، فأعد إنشاء
    المسار الذي أرسلناه (مثلًا، أعد إضافة البادئة) قبل التحقق.
  </Accordion>

  <Accordion title="إعادة التسلسل بالتنسيق الافتراضي">
    يؤدي تحليل جسم الطلب ثم تفريغه مجددًا باستخدام الإعدادات
    الافتراضية لمكتبة JSON لديك (مسافات بعد `,` / `:`, ومفاتيح مرتبة حسب الإدراج) إلى إنتاج
    بايتات مختلفة وتعطيل HMAC. تحقّق من جسم الطلب الخام — أو إذا
    اضطررت إلى إعادة تسلسله، فطابق صيغتنا القياسية تمامًا: مفاتيح
    مرتبة، وفواصل مضغوطة، وUTF-8.
  </Accordion>

  <Accordion title="إطار العمل يحلل JSON تلقائيًا">
    تستهلك برمجية Express الوسيطة `express.json()` تدفق النص
    وتفقد البايتات الخام. استخدم `express.raw()` تحديدًا في مسار
    webhook، أو خزّن النص الخام مؤقتًا في برمجية وسيطة تسبقها.
    وينطبق الأمر نفسه على NestJS / Koa — راجع وثائقهما حول "النص الخام".
  </Accordion>

  <Accordion title="مقارنة غير آمنة زمنيًا">
    تُعد `expected === signature` في JS أو `expected == signature` في
    Python مقارنات متغيرة التوقيت. استخدم `crypto.timingSafeEqual`
    أو `hmac.compare_digest` على التوالي. فرق الأداء
    معدوم.
  </Accordion>

  <Accordion title="سر خاطئ لنقاط نهاية الأدوات">
    تُوقَّع استدعاءات نقاط نهاية الأدوات المباشرة باستخدام **سر خطاف الويب
    على مستوى المؤسسة** (`GET /v1/webhook`) — وليس باستخدام أي سر خاص بنقطة نهاية
    من `/v1/developer/webhook-endpoints`. أعد استخدام المساعد نفسه،
    لكن تأكد من تمرير سر المؤسسة إليه في مسارات الأدوات.
  </Accordion>

  <Accordion title="سلاسل الاستعلام في أدوات GET/DELETE">
    يوقّع V2 سلسلة الاستعلام كما أُرسلت تمامًا. توقّع
    الترويسة القديمة جسم الطلب الفارغ ولا توقّع سلسلة الاستعلام أبدًا، لذا فإن تجزئة عنوان URL من أجل
    الترويسة القديمة لن تتطابق أبدًا.
  </Accordion>

  <Accordion title="التكرارات داخل النافذة">
    يمنع الطابع الزمني إعادات التشغيل الأقدم من 5 دقائق، وليس نسخة ثانية
    ضمن تلك النافذة. تُسلَّم الطلبات إلى نقاط النهاية مرة واحدة على الأقل، لذا قد
    تصل أيضًا إعادة محاولة للحدث نفسه؛ أزل التكرار بالاعتماد على
    `event_id` الخاص بالغلاف.
  </Accordion>

  <Accordion title="عدم إرجاع 401 عند عدم التطابق">
    إن إرجاع 200 عند فشل التحقق يجعل المعالج هدفًا لإعادة التشغيل.
    استجب دائمًا برمز غير 2xx إذا فشل التحقق.
  </Accordion>
</AccordionGroup>

---

## الخطوات التالية

<CardGroup cols={2}>
  <Card title="نظرة عامة على Webhooks" icon="bolt" href="/ar/webhooks/overview">
    دلالات التسليم، وإعادة المحاولات، وعناوين IP المصدر.
  </Card>
  <Card title="نقاط نهاية Webhook" icon="plug" href="/ar/webhooks/endpoints">
    أدر عناوين URL متعددة، ودوّر الأسرار.
  </Card>
  <Card title="أدوات الدوال" icon="screwdriver-wrench" href="/ar/tools/overview">
    مسارا استدعاء الأدوات وشكلا طلباتهما.
  </Card>
  <Card title="تكاملات الأدوات" icon="wrench" href="/ar/guides/build-tool-integration">
    أنشئ تكاملًا كاملًا مدعومًا بأدوات من البداية إلى النهاية.
  </Card>
</CardGroup>
