> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sahlfinancial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> أربعة أحداث KYC موقّعة بـ HMAC-SHA256، تُسلَّم مرة واحدة على الأقل، بحد أقصى 6 محاولات.

بدلاً من الاستقصاء الدوري (polling)، يمكنك أن تجعل Sahl ترسل حدثاً إلى خادمك عند انتهاء كل استدعاء. والـ Webhooks اختيارية. يُرسَل كل حدث KYC بعد اعتماد (commit) قاعدة البيانات للاستدعاء، وفي الخلفية، فلا تنتظر استجابة API نقطتك الطرفية أبداً ولا تفشل بسببها.

## تسجيل نقطة طرفية

في وحدة التحكم افتح **Settings**، ثم **Webhooks**، ثم **Add Webhook**.

1. أدخل **Endpoint URL**. يجب أن يكون `https` وأن يُحلَّ إلى عنوان عام. تُرفض العناوين الخاصة وعناوين الحلقة المحلية (loopback) وعناوين البيانات الوصفية للسحابة، عند الحفظ ومرة أخرى قبل كل إرسال. ولا تُتبَع إعادات التوجيه.
2. اختر الأحداث (أدناه).
3. احفظ. إذا لم تقدّم سرّاً خاصاً بك (16 حرفاً فأكثر)، تولّد Sahl سرّاً يظهر مرة واحدة، بصيغة `whsec_` يليها 48 حرفاً. انسخه.
4. انقر **Test** على النقطة الطرفية. ترسل Sahl حدث `test.ping` إلى عنوانك، موقّعاً كأي تسليم حقيقي، وتعرض الحالة التي ردّ بها خادمك. وبنية حمولة الاختبار لا تشبه حدث KYC (راجع قسم «نبضة الاختبار» أدناه).

تتطلب إدارة النقاط الطرفية دور مسؤول المستأجر (tenant admin) أو مدير API. وتُدار نقاط Webhook الطرفية من وحدة التحكم، لا عبر Partner API.

## الأحداث

لا يصدر أحداثاً إلا الاستدعاءات التي تحمل `reference`، لأن الحدث يسمّي حالة (case). فالاستدعاء بلا مرجع لا يخزّن شيئاً ولا يصدر شيئاً.

| الحدث | يُرسَل بعد | ملاحظات |
| - | - | - |
| `kyc.documents_read` | `POST /v1/kyc/extract` | |
| `kyc.case_verified` | `POST /v1/kyc/verify` | |
| `kyc.case_assessed` | `POST /v1/kyc/assess` | بدلاً من `case_verified`. يرسل `/assess` حدثاً واحداً. |
| `kyc.eid_completed` | أول `GET /v1/kyc/eid/{key}` يرى فحصاً منتهياً | لا ترسل الاستعلامات اللاحقة شيئاً. وقد يرسل استعلامان متزامنان عند أول رصد حدثاً لكل منهما. |

تُرسَل الأحداث للبيئتين كلتيهما. وتبيّن الحمولة أيّهما.

## الحمولات

تحمل كل حمولة هذه المفاتيح الأربعة.

| المفتاح | النوع | المعنى |
| - | - | - |
| `event` | string | اسم الحدث. |
| `event_id` | uuid | معرّف جديد لكل حدث. وإذا أُعيد تسليم الحدث نفسه يحتفظ بـ `event_id`. |
| `occurred_at` | string | وقت بصيغة ISO 8601 مع الإزاحة الزمنية، حين بنت Sahl الحدث. |
| `tenant_id` | uuid | معرّف مساحة عملك. |

تحمل الحمولات المعرّفات، و`reference` الخاص بك، والبيئة، وملخصاً للحكم. ولا تحمل أبداً قيمة حقل أو اسماً أو تاريخ ميلاد أو أي محتوى وثيقة: فالتسليمات تُخزَّن لدى Sahl وتُرسَل إلى عنوان كتبتَه أنت.

### `kyc.documents_read`

```json theme={null}
{
  "event": "kyc.documents_read",
  "event_id": "0b6f1c1e-0000-4000-8000-000000000000",
  "occurred_at": "2026-10-07T12:00:00+00:00",
  "tenant_id": "00000000-0000-4000-8000-000000000000",
  "case_id": "00000000-0000-4000-8000-000000000001",
  "reference": "client-0001",
  "environment": "sandbox",
  "document_ids": ["22222222-2222-4222-8222-222222222222"],
  "reader_unavailable": false,
  "failed_checks": []
}
```

تحوي `failed_checks` معرّفات الفحوص الفاشلة ذات الخطورة `critical` أو `warning`، دون تكرار.

### `kyc.case_verified` و`kyc.case_assessed`

```json theme={null}
{
  "event": "kyc.case_assessed",
  "event_id": "0b6f1c1e-0000-4000-8000-000000000002",
  "occurred_at": "2026-10-07T12:01:00+00:00",
  "tenant_id": "00000000-0000-4000-8000-000000000000",
  "case_id": "00000000-0000-4000-8000-000000000001",
  "reference": "client-0001",
  "environment": "sandbox",
  "verdict": {
    "status": "passed",
    "failed_checks": ["completeness"],
    "risk_level": "Low",
    "suitability": "Suitable"
  }
}
```

| مفتاح `verdict` | المعنى |
| - | - |
| `status` | `passed` أو `failed`، أي قيمة `passed` في الحكم. |
| `failed_checks` | معرّفات الفحوص الحرجة والتحذيرية الفاشلة. يُقتطع معرّف الفحص (screening) إلى نوعه (`screening:sanctions` و`screening:pep`) حتى لا يغادر اسم أي شخص Sahl. |
| `risk_level` | في `kyc.case_assessed` فقط. مستوى خطر الامتثال: `Low` أو `Medium` أو `High`. |
| `suitability` | في `kyc.case_assessed` فقط. جملة الملاءمة. |

### `kyc.eid_completed`

```json theme={null}
{
  "event": "kyc.eid_completed",
  "event_id": "0b6f1c1e-0000-4000-8000-000000000003",
  "occurred_at": "2026-10-07T12:30:00+00:00",
  "tenant_id": "00000000-0000-4000-8000-000000000000",
  "key": 123456,
  "reference": "client-0001",
  "complete": true,
  "passed": true,
  "failed_checks": []
}
```

تكون `complete` بقيمة false حين انتهى الطلب مؤرشفاً دون أن يُكمله العميل. وتحوي `failed_checks` معرّفات فحوص eID الحرجة (`critical`) الفاشلة.

### نبضة الاختبار

يرسل زر **Test** بنية مختلفة، موقّعة بالطريقة نفسها:

```json theme={null}
{
  "event_type": "test.ping",
  "timestamp": "2026-10-07T12:00:00+00:00",
  "data": { "message": "This is a test ping from Sahl", "webhook_id": "...", "tenant_id": "..." }
}
```

قيمة ترويسته `X-Sahl-Event` هي `test.ping`. وليس فيها مفتاح `event` ولا `event_id`، فوجّه الحدث بحسب الترويسة لا بحسب مفتاح. ولا تحمل ترويسة `X-Sahl-Delivery`، وتنتظر ردّك 10 ثوانٍ، وتعدّ أي حالة أقل من 400 نجاحاً (أما التسليمات الحقيقية فتتطلب 2xx وتنتظر 30 ثانية).

## ترويسات الطلب

| الترويسة | القيمة |
| - | - |
| `Content-Type` | `application/json` |
| `X-Sahl-Event` | اسم الحدث. |
| `X-Sahl-Delivery` | معرّف فريد لكل تسليم. يبقى ثابتاً عبر إعادات محاولة التسليم الواحد. |
| `X-Sahl-Timestamp` | الثواني بتوقيت Unix حين وُقّعت هذه المحاولة. |
| `X-Sahl-Signature-V2` | `sha256=` يليها HMAC-SHA256 بصيغة سداسية عشرية لـ `"<timestamp>.<raw body>"`. |
| `X-Sahl-Signature` | `sha256=` يليها HMAC-SHA256 بصيغة سداسية عشرية للجسم الخام وحده. أُبقيت للمستقبِلات القديمة. ولا تمنع هجمات إعادة الإرسال (replay). |

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

1. اقرأ بايتات الجسم الخام قبل تحليل JSON. فإعادة تسلسل JSON لا تطابق التوقيع.
2. احسب `HMAC-SHA256(secret, timestamp + "." + body)` وقارنه بـ `X-Sahl-Signature-V2` في زمن ثابت.
3. ارفض أي طابع زمني يبعد عن ساعتك أكثر من بضع دقائق (تستخدم الشيفرة أدناه 5 دقائق، وهذا اختيارك وليس قاعدة من Sahl). تُوقَّع كل إعادة محاولة من جديد، فيكون طابعها الزمني حديثاً.

<CodeGroup>
  ```javascript JavaScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const TOLERANCE_SECONDS = 300;

  // rawBody: the request body exactly as received (Buffer or string).
  // headers: lowercase header names, as Node and Express give them.
  export function verifySahlWebhook(rawBody, headers, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
    const timestamp = headers["x-sahl-timestamp"];
    const received = headers["x-sahl-signature-v2"];
    if (!/^\d+$/.test(timestamp ?? "") || !received) return false;
    if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false;
    const expected =
      "sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
    const a = Buffer.from(received);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib, hmac, time

  TOLERANCE_SECONDS = 300


  def verify_sahl_webhook(raw_body: bytes, headers: dict, secret: str, now: float | None = None) -> bool:
      """raw_body is the request body exactly as received. headers keys are lowercase."""
      timestamp = headers.get("x-sahl-timestamp", "")
      received = headers.get("x-sahl-signature-v2", "")
      if not timestamp.isdigit() or not received:
          return False
      if abs((now or time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
          return False
      signed = timestamp.encode() + b"." + raw_body
      expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(received, expected)
  ```
</CodeGroup>

مستقبِل في Express. تحتفظ `express.raw` بالبايتات.

```javascript theme={null}
import express from "express";
import { verifySahlWebhook } from "./verify.mjs";

const app = express();
const seen = new Set(); // use a database table in production

app.post("/sahl/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySahlWebhook(req.body, req.headers, process.env.SAHL_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  const id = req.headers["x-sahl-delivery"];
  if (!seen.has(id)) {
    seen.add(id);
    // handle event.event, event.reference, event.case_id ...
  }
  res.status(204).end(); // any 2xx counts as delivered
});

app.listen(3000);
```

شُغّلت الدالتان أعلاه على الشيفرة التي توقّع التسليمات الحقيقية: التسليم الصحيح يجتاز التحقق، والجسم المعدَّل يفشل، والطابع الزمني القديم يفشل.

## التسليم وإعادة المحاولة

| العنصر | القيمة |
| - | - |
| النجاح | أي حالة HTTP من 200 إلى 299. |
| المهلة | 30 ثانية افتراضياً. |
| إعادات التوجيه | لا تُتبَع. تُعدّ 3xx فشلاً. |
| المحاولات | 6 في المجموع، بما فيها الأولى. |
| الانتظار بعد محاولة فاشلة | دقيقة واحدة، 5 دقائق، 30 دقيقة، ساعتان، 6 ساعات. |
| بعد الفشل السادس | يبقى التسليم `failed` نهائياً. |
| الضمان | مرة واحدة على الأقل. قد يصل الحدث نفسه أكثر من مرة. |
| الترتيب | غير مضمون. |

تُجرى المحاولة الأولى فور الاستدعاء. أما المحاولات اللاحقة فتجريها مهمة إعادة محاولة تعمل كل بضع دقائق، فقد تأتي إعادة المحاولة بعد موعدها المجدول بقليل. والنقطة الطرفية التي توقفها تحتفظ بالتسليمات المستحقة وتستأنفها عند إعادة تشغيلها.

اجعل معالجك عديم الأثر عند التكرار (idempotent). أزِل التكرار بحسب `X-Sahl-Delivery` (معرّف واحد لكل تسليم) أو `event_id` (معرّف واحد لكل حدث). وأجب بسرعة بـ 2xx ثم نفّذ العمل بعد ذلك.

تعرض وحدة التحكم كل تسليمات النقطة الطرفية مع حالة HTTP وعدد المحاولات، وتحتفظ بما يصل إلى 2,000 حرف من جسم ردّك.

## استكشاف الأخطاء

| العَرَض | السبب |
| - | - |
| التوقيع لا يتطابق أبداً | تحققتَ من JSON المحلَّل لا من البايتات الخام. أو أن السر ليس سر النقطة الطرفية. |
| التوقيع يتطابق أحياناً فقط | ساعتك تنحرف أكثر من هامش التسامح لديك. |
| لا يصل أي حدث | الاستدعاء بلا `reference`، أو النقطة الطرفية غير مشتركة في ذلك الحدث، أو النقطة الطرفية متوقفة. |
| تقول وحدة التحكم "Refused, not sent" | عنوانك يُحلُّ إلى عنوان خاص أو عنوان بيانات وصفية، أو ليس https. |
| وصل الحدث مرتين | أمر طبيعي. أزِل التكرار. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.