الانتقال إلى المحتوى الرئيسي

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 هو معرّف الحدث، ويساوي الـ 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​

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 ثانية، أو تعذّر الوصول إليه. وعندها تعيد كسور المحاولة بعد:

بعد المحاولةالانتظار
15 ثوانٍ
25 دقائق
330 دقيقة
4ساعتان
55 ساعات
610 ساعات
714 ساعة
820 ساعة
924 ساعة

يُزاد كل انتظار عشوائياً بما يصل إلى 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، ثم كل حدث أُرسل مع محاولاته وآخر رد

تغيير السر​

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، ثم تعتمدها بيئة الهيئة التجريبية بعد قليل ويصلك الحدث. التفاصيل في وضع الاختبار.