# Webhooks

> اجعل كسور ترسل نتائج هيئة الزكاة والضريبة والجمارك إلى نظامك فور وصولها، وتحقّق من كل طلب، وتعامل مع إعادة الإرسال والتكرار.

فاتورة المبيعات أو الإشعار الدائن الذي يرجع بـ `zatca.status: "pending"` تصله نتيجة الهيئة لاحقاً. بدلاً من أن تسأل عنها كل فترة، سجّل Webhook Endpoint، وسترسل كسور إلى نظامك طلب `POST` عبر HTTPS لحظة وصول النتيجة.

تتبع كسور معيار [Standard Webhooks](https://www.standardwebhooks.com/)، فمكتبات `standardwebhooks` المفتوحة المصدر تتحقق من طلباتها كما هي.

## تسجيل Endpoint

```bash
curl https://api.kosoor.com.sa/v1/webhook-endpoints \
  -H "Authorization: Bearer ksr_live_..." \
  -H "Idempotency-Key: 6f1d2c7e-hooks-erp" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://erp.example.com/kosoor/webhooks","events":["salesInvoice.zatcaCleared","salesInvoice.zatcaReported","salesInvoice.zatcaRejected"]}'
```

```json
{
  "id": "4d7b2e91-3c6a-4f85-9e1d-0a8c5b7f2e63",
  "object": "webhookEndpoint",
  "url": "https://erp.example.com/kosoor/webhooks",
  "events": ["salesInvoice.zatcaCleared", "salesInvoice.zatcaReported", "salesInvoice.zatcaRejected"],
  "status": "enabled",
  "disabledReason": null,
  "secret": "whsec_IhPDzwacK7vjZSdgdWIEKuvhvZqOrOozQwbRj2XIT9w=",
  "createdAt": "2026-11-07T09:30:00Z"
}
```

**احفظ الـ `secret` الآن.** لا يظهر إلا في هذا الرد وفي رد تغيير السر، وتحتاجه للتحقق من كل طلب.

- يحتاج المفتاح الصلاحية `webhooks:write` لإدارة الـ Endpoints (و `webhooks:read` لعرضها)، وصلاحية قراءة ما تتحدث عنه الأحداث: `sales_invoices:read` لأحداث `salesInvoice.*`، و `credit_notes:read` لأحداث `creditNote.*`.
- الـ Endpoint تابع للمفتاح الذي أنشأه، فلا تراه المفاتيح الأخرى، ويتوقف عن الاستقبال إذا أُبطل المفتاح أو انتهت صلاحيته. والمفتاح المحدود بفروع معينة لا تصله إلا أحداث مستندات تلك الفروع.
- الـ Endpoints الخاصة بمفتاح الاختبار موجودة في بيئة الاختبار، ولا تصلها إلا أحداث بيئة الاختبار، وتبقى بعد إعادة تعيينها.
- يجب أن يكون الـ URL بـ `https`، بدون اسم مستخدم أو كلمة مرور، وأن يشير اسم النطاق إلى عنوان عام (Public).
- لكل مفتاح 10 Endpoints على الأكثر، والحادي عشر يرجع `422 webhook_endpoint_limit`.

| | |
|---|---|
| `GET /v1/webhook-endpoints` | عرض الـ Endpoints الخاصة بالمفتاح (بدون الأسرار) |
| `GET /v1/webhook-endpoints/{id}` | قراءة Endpoint واحد |
| `DELETE /v1/webhook-endpoints/{id}` | حذفه، ويُلغى كل ما كان ينتظر الإرسال إليه |
| `POST /v1/webhook-endpoints/{id}/rotate-secret` | إصدار سر جديد (انظر [تغيير السر](#تغيير-السر)) |
| `POST /v1/webhook-endpoints/{id}/test` | إرسال حدث `webhookEndpoint.test` الآن |

كل طلب `POST` و `DELETE` يحمل `Idempotency-Key` مثل بقية الـ API.

## الأحداث

| الحدث | متى يُرسل |
|---|---|
| `salesInvoice.zatcaCleared` | اعتمدت الهيئة فاتورة ضريبية (B2B) كانت قد رجعت بـ `pending` |
| `salesInvoice.zatcaReported` | قبلت الهيئة الإبلاغ عن فاتورة مبسطة (B2C) |
| `salesInvoice.zatcaRejected` | رفضت الهيئة الفاتورة |
| `creditNote.zatcaCleared` | الأحداث الثلاثة نفسها للإشعار الدائن |
| `creditNote.zatcaReported` | |
| `creditNote.zatcaRejected` | |
| `webhookEndpoint.test` | استدعيت `POST /v1/webhook-endpoints/{id}/test`، ويُرسل مهما كانت الأحداث المشترك فيها |

النتيجة التي رجعت في رد طلب الإنشاء نفسه (فاتورة ضريبية اعتمدتها الهيئة عند الإصدار) لا يُرسل لها حدث، لأنها وصلتك في الرد.

## ماذا ترسل كسور

```http
POST /kosoor/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Kosoor-Webhooks/1.0
webhook-id: 019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74
webhook-timestamp: 1794043800
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

```json
{
  "id": "019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74",
  "object": "event",
  "type": "salesInvoice.zatcaReported",
  "createdAt": "2026-11-07T09:31:12Z",
  "data": {
    "id": "5c8a1f9e-2d7b-4c3a-9e6f-1a4b8d0c7e52",
    "object": "salesInvoice",
    "number": "INV-10045",
    "referenceId": "order-10045",
    "zatca": {
      "kind": "simplified",
      "status": "reported",
      "uuid": "b7c1e0d2-4f3a-4c8e-9a1b-2d5e6f7a8b9c",
      "counter": 1287,
      "hash": "...",
      "qr": "...",
      "submittedAt": "2026-11-07T09:31:10Z",
      "clearedAt": null,
      "reportedAt": "2026-11-07T09:31:12Z",
      "errors": []
    }
  }
}
```

- الـ `data` يحدد المستند ويحمل كتلة `zatca` الخاصة به، وهي نفس الكتلة التي يرجعها `GET`. اقرأ المستند كاملاً بـ `GET /v1/sales-invoices/{id}` أو `GET /v1/credit-notes/{id}` إذا احتجت أكثر من ذلك.
- الـ `id` هو معرّف الحدث، ويساوي الـ Header `webhook-id`، ولا يتغير في أي إعادة إرسال.
- الـ `createdAt` هو وقت وقوع الحدث بتوقيت UTC، أما `webhook-timestamp` فهو وقت إرسال هذه المحاولة بثواني Unix.

## تحقّق من كل طلب

أي أحد يستطيع إرسال `POST` إلى الـ URL الخاص بك، فتحقّق من التوقيع قبل أن تثق بالـ Body، وتحقّق منه على **بايتات الـ Body كما وصلت**: إذا أعاد JSON Parser بناء الـ Body فستتغير البايتات.

التوقيع هو HMAC-SHA256، مفتاحه بايتات السر (الجزء بعد `whsec_` بعد فك ترميز base64)، ويُحسب على `webhook-id` و `webhook-timestamp` والـ Body مفصولة بنقاط. يحمل `webhook-signature` قيمة `v1,<base64>` واحدة أو أكثر مفصولة بمسافات، فاقبل الطلب إذا طابقت أي واحدة منها. وارفض `webhook-timestamp` الذي يبعد أكثر من 5 دقائق عن ساعتك، حتى لا يُعاد إرسال طلب مسروق لاحقاً.

### Node.js

```bash
npm install standardwebhooks
```

```js
import express from 'express';
import { Webhook } from 'standardwebhooks';

const webhook = new Webhook(process.env.KOSOOR_WEBHOOK_SECRET); // "whsec_..."
const app = express();

app.post('/kosoor/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers); // checks the signature and the 5-minute window
  } catch {
    return res.status(400).end();
  }
  res.status(204).end();   // answer first
  queueForProcessing(event); // then work
});
```

### Python

```bash
pip install standardwebhooks
```

```python
import os
from flask import Flask, request
from standardwebhooks import Webhook

webhook = Webhook(os.environ["KOSOOR_WEBHOOK_SECRET"])  # "whsec_..."
app = Flask(__name__)

@app.post("/kosoor/webhooks")
def kosoor_webhook():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "", 400
    queue_for_processing(event)
    return "", 204
```

### C# (ASP.NET Core)

```csharp
using System.Security.Cryptography;
using System.Text;

app.MapPost("/kosoor/webhooks", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body, Encoding.UTF8);
    var body = await reader.ReadToEndAsync();
    var id = request.Headers["webhook-id"].ToString();
    var timestamp = request.Headers["webhook-timestamp"].ToString();
    var signatures = request.Headers["webhook-signature"].ToString().Split(' ');

    if (!long.TryParse(timestamp, out var seconds)
        || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 300)
        return Results.BadRequest();

    var secret = Environment.GetEnvironmentVariable("KOSOOR_WEBHOOK_SECRET")!; // "whsec_..."
    var key = Convert.FromBase64String(secret["whsec_".Length..]);
    var expected = Encoding.UTF8.GetBytes(
        "v1," + Convert.ToBase64String(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}"))));

    if (!signatures.Any(s => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), expected)))
        return Results.BadRequest();

    QueueForProcessing(body);
    return Results.NoContent();
});
```

### التحقق يدوياً

لترى طريقة الحساب باستخدام الـ Shell فقط:

```bash
SECRET="whsec_IhPDzwacK7vjZSdgdWIEKuvhvZqOrOozQwbRj2XIT9w="
ID="019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74"
TS="1794043800"
BODY='{"id":"019a5f3c-7e21-7b4d-8a6f-2c9e1d0b5a74","object":"event",...}'

KEY_HEX=$(echo -n "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)
printf 'v1,%s\n' "$(printf '%s.%s.%s' "$ID" "$TS" "$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt hexkey:"$KEY_HEX" -binary | base64)"
```

الناتج هو ما يحمله `webhook-signature` لهذا الـ Body.

## ردّ بسرعة ثم اعمل

- ردّ بأي `2xx` خلال **15 ثانية**. الرد الأبطأ يُحسب فشلاً ويُعاد إرسال الحدث.
- نفّذ العمل بعد الرد: ضع الحدث في Queue، وردّ بـ `204`، ثم عالجه من الـ Queue.
- لا تتبع كسور الـ Redirects، فأي رد `3xx` يُحسب فشلاً. سجّل الـ URL النهائي.

## إعادة الإرسال

تفشل المحاولة إذا ردّ الـ Endpoint بأي شيء غير `2xx`، أو تأخر أكثر من 15 ثانية، أو تعذّر الوصول إليه. وعندها تعيد كسور المحاولة بعد:

| بعد المحاولة | الانتظار |
|---|---|
| 1 | 5 ثوانٍ |
| 2 | 5 دقائق |
| 3 | 30 دقيقة |
| 4 | ساعتان |
| 5 | 5 ساعات |
| 6 | 10 ساعات |
| 7 | 14 ساعة |
| 8 | 20 ساعة |
| 9 | 24 ساعة |

يُزاد كل انتظار عشوائياً بما يصل إلى 10%، حتى لا تصلك الأحداث المتراكمة دفعة واحدة. وبعد المحاولة العاشرة، أي بعد 3 أيام تقريباً من الأولى، يُسجَّل الحدث فاشلاً. وإذا رددت بـ `429` أو `503` مع الـ Header `Retry-After`، تنتظر كسور هذه المدة على الأقل قبل المحاولة التالية.

## التكرار والترتيب

- **قد يصلك الحدث نفسه مرتين**: إذا ضاع ردك في الطريق، أو أُعيد إرساله من لوحة التحكم. احفظ الـ `webhook-id` لكل حدث عالجته، وتجاهل الحدث الذي سبق أن رأيته.
- **قد تصل الأحداث بغير ترتيبها.** فإعادة الإرسال تتداخل مع الأحداث الأحدث. اعتمد على قيمة `zatca.status` الحالية للمستند (واقرأها بـ `GET` إذا شككت)، لا على ترتيب وصول الأحداث.

## متى تتوقف كسور عن الإرسال

| `disabledReason` | السبب |
|---|---|
| `gone` | ردّ الـ Endpoint بـ `410 Gone`، فتتوقف كسور فوراً |
| `failing` | لم يُسلَّم شيء لمدة 5 أيام |
| `merchant` | ألغى التاجر تفعيله من لوحة التحكم |

يظهر الـ Endpoint المتوقف بـ `status: "disabled"` في `GET`، ويُلغى كل ما كان ينتظر الإرسال إليه. ويستطيع التاجر تفعيله من جديد من **المطوّرون ← Webhooks** في لوحة التحكم، وهناك تظهر كل الأحداث المرسلة مع محاولاتها وآخر رد من الـ Endpoint، وزر **إعادة إرسال** يرسل الحدث مرة أخرى بالـ `webhook-id` نفسه.
![تبويب Webhooks: الـ Endpoints، ثم كل حدث أُرسل مع محاولاته وآخر رد](https://docs.kosoor.com.sa/ar/img/onboarding/webhooks.ar.png.md)

## تغيير السر

```bash
curl -X POST https://api.kosoor.com.sa/v1/webhook-endpoints/4d7b2e91-3c6a-4f85-9e1d-0a8c5b7f2e63/rotate-secret \
  -H "Authorization: Bearer ksr_live_..." \
  -H "Idempotency-Key: rotate-2026-11"
```

يحمل الرد الـ `secret` الجديد. ولمدة **24 ساعة** توقّع كسور كل طلب بالسر القديم والجديد معاً، فيحمل `webhook-signature` قيمتين `v1,`. انشر السر الجديد خلال هذه المدة، وسيستمر التحقق بأي واحد منهما.

## جرّبها

1. أنشئ مفتاح اختبار و Endpoint يشير إلى URL تستطيع مراقبته، مثل خدمات Request Bin.
2. أرسل `POST /v1/webhook-endpoints/{id}/test`، والـ `id` في الرد هو الـ `webhook-id` الذي سيصلك.
3. اشترك في `salesInvoice.zatcaCleared` وبِع للعميل **Sandbox ZATCA Pending Co** في بيئة الاختبار: ترجع الفاتورة بـ `pending`، ثم تعتمدها بيئة الهيئة التجريبية بعد قليل ويصلك الحدث. التفاصيل في [وضع الاختبار](https://docs.kosoor.com.sa/ar/test-mode.md).

المصدر: https://docs.kosoor.com.sa/ar/webhooks
