Webhooks
فاتورة المبيعات أو الإشعار الدائن الذي يرجع بـ zatca.status: "pending" تصله نتيجة الهيئة لاحقاً. بدلاً من أن تسأل عنها كل فترة، سجّل Webhook Endpoint، وسترسل كسور إلى نظامك طلب POST عبر HTTPS لحظة وصول النتيجة.
تتبع كسور معيار Standard Webhooks، فمكتبات standardwebhooks المفتوحة المصدر تتحقق من طلباتها كما هي.
تسجيل Endpoint
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"]}'
{
"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، ويُرسل مهما كانت الأحداث المشترك فيها |
النتيجة التي رجعت في رد طلب الإنشاء نفسه (فاتورة ضريبية اعتمدتها الهيئة عند الإصدار) لا يُرسل لها حدث، لأنها وصلتك في الرد.
ماذا ترسل كسور
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=
{
"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هو معرّف الحدث، ويساوي الـ Headerwebhook-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
npm install standardwebhooks
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
pip install standardwebhooks
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)
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 فقط:
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 نفسه.

تغيير السر
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,. انشر السر الجديد خلال هذه المدة، وسيستمر التحقق بأي واحد منهما.
جرّبها
- أنشئ مفتاح اختبار و Endpoint يشير إلى URL تستطيع مراقبته، مثل خدمات Request Bin.
- أرسل
POST /v1/webhook-endpoints/{id}/test، والـidفي الرد هو الـwebhook-idالذي سيصلك. - اشترك في
salesInvoice.zatcaClearedوبِع للعميل Sandbox ZATCA Pending Co في بيئة الاختبار: ترجع الفاتورة بـpending، ثم تعتمدها بيئة الهيئة التجريبية بعد قليل ويصلك الحدث. التفاصيل في وضع الاختبار.