Webhooks
الغلاف، والتحقق من التوقيع بأربع لغات، ودلالات التسليم وإعادة المحاولة، والمواضيع.
في هذه الصفحة
هذه الصفحة لمن يستقبل أحداث المتاجر. في نهايتها لديك نقطة استقبال تتحقّق من التوقيع، وتزيل التكرار، وترتّب الأحداث، وتعرف بالضبط ما يحدث عند الفشل.
كل حدث في متجر مثبَّت عليه تطبيقك يصل كطلب POST موقّع إلى نقطة الاستقبال الوحيدة لذلك التثبيت. التسليم مرة واحدة على الأقل؛ عالِج التكرار دائماً.
الغلاف#
{
"id": "9b2f6c1e-7d3a-4f0b-9a52-1c8e2d4b6a70",
"api_version": "v1",
"topic": "order.created",
"store_id": "3065a1f2-0c4d-4e8b-b7a9-5d2f8c1e9a44",
"install_id": "8b2c1d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d",
"sequence": 1661,
"occurred_at": "2026-08-18T16:00:00.123Z",
"data": {
"order_id": "6f1d2c3b-4a59-4e8f-9b70-2d1c3e4f5a6b",
"order_number": "1042",
"status": "placed",
"total_minor": 275000,
"currency": "SYP"
}
}| الحقل | المعنى |
|---|---|
id |
معرّف الحدث الثابت. مفتاح إزالة التكرار عندك؛ لا يتغيّر بين المحاولات. |
api_version |
دائماً v1 (العقد). المراجعة المؤرّخة في الترويسة X-Dukkan-Api-Version. |
topic |
الموضوع؛ الجدول في المواضيع. |
store_id |
المتجر الذي وقع فيه الحدث. ارفض قيمة تختلف عن المتجر الذي تحتفظ به لـ install_id. |
install_id |
التثبيت الذي وُزّع إليه هذا التسليم. ابحث عن سر التوقيع به، لا بمقطع مسار اخترته أنت. داخل التوقيع. |
sequence |
رقم تسلسلي رتيب لكل متجر مع فجوات مسموحة. رتّب به الأحداث المتأخرة، ولا تعتمد على تتاليه. |
occurred_at |
بصيغة RFC 3339 دائماً. |
test |
موجود بقيمة true فقط في تجربة على متجر تجريبي أُطلقت عبر POST /webhooks/test؛ غائب في حركة الإنتاج. |
data |
حمولة الموضوع؛ الأشكال في مرجع الأحداث. |
ملاحظة
تحمل حمولات الطلب المبلغ كـ total_minor مع currency دون decimals. اجلب الطلب عبر GET /orders/{id} عندما تحتاج إلى الأس العشري للعرض؛ راجع المال بالوحدات الصغرى.
الترويسات#
| الترويسة | مثال | المعنى |
|---|---|---|
X-Dukkan-Delivery-Id |
7d0c2b1a-5e4f-4a3b-8c9d-0e1f2a3b4c5d |
معرّف التسليم؛ ثابت عبر محاولات التسليم نفسه |
X-Dukkan-Install-Id |
8b2c1d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d |
نسخة غير موقّعة من install_id في الغلاف، لتحميل السر قبل التحقق |
X-Dukkan-Event |
order.created |
الموضوع نفسه الذي في الغلاف |
X-Dukkan-Timestamp |
1787150000 |
ثوانٍ Unix لحظة الإرسال |
X-Dukkan-Api-Version |
2026-09-09 |
المراجعة المؤرّخة من v1 التي أنتجت الحمولة |
X-Dukkan-Hmac-Sha256 |
3f9a… (hex) |
التوقيع |
التحقق من التوقيع#
التوقيع هو HMAC-SHA256 بمفتاحك dk_whsec_… على السلسلة timestamp.body: الطابع الزمني، ثم نقطة، ثم بايتات الجسم الخام كما وصلت. لا تُعِد تسلسل JSON قبل التحقق. ارفض الطوابع الزمنية الأقدم من 5 دقائق (حماية من إعادة الإرسال)، وقارن بوقت ثابت.
import { verifyWebhookSignature } from "@dukkan.one/app-sdk/webhooks";
const verdict = await verifyWebhookSignature({
rawBody, // the request bytes, never re-serialised
timestamp: headers.get("x-dukkan-timestamp"),
signature: headers.get("x-dukkan-hmac-sha256"),
secret, // or [current, previous] during a rotation
});
if (!verdict.ok) return new Response(JSON.stringify({ error: verdict.reason }), { status: 401 });import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDukkanWebhook(secret, headers, rawBody) {
const timestamp = headers["x-dukkan-timestamp"];
const signature = headers["x-dukkan-hmac-sha256"];
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp).update(".").update(rawBody).digest();
const received = Buffer.from(signature, "hex");
return expected.length === received.length && timingSafeEqual(expected, received);
}// Cloudflare Workers, Deno, Bun: request.arrayBuffer() gives the raw bytes.
export async function verifyDukkanWebhook(secret, request, rawBody) {
const timestamp = request.headers.get("x-dukkan-timestamp") ?? "";
const signature = request.headers.get("x-dukkan-hmac-sha256") ?? "";
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const prefix = new TextEncoder().encode(timestamp + ".");
const message = new Uint8Array(prefix.length + rawBody.byteLength);
message.set(prefix, 0);
message.set(new Uint8Array(rawBody), prefix.length);
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, message));
const received = Uint8Array.from(signature.match(/../g) ?? [], (h) => parseInt(h, 16));
if (received.length !== mac.length) return false;
let diff = 0;
for (let i = 0; i < mac.length; i++) diff |= mac[i] ^ received[i];
return diff === 0;
}import hashlib
import hmac
import time
def verify_dukkan_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
timestamp = headers.get("x-dukkan-timestamp", "")
signature = headers.get("x-dukkan-hmac-sha256", "")
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)<?php
function verifyDukkanWebhook(string $secret, array $headers, string $rawBody): bool
{
$timestamp = $headers['x-dukkan-timestamp'] ?? '';
$signature = $headers['x-dukkan-hmac-sha256'] ?? '';
if ($timestamp === '' || $signature === '') {
return false;
}
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}خطر
لا تعالج أي حمولة قبل نجاح التحقق، ولا تسجّل المفتاح في السجلات. يظهر المفتاح مرة واحدة عند الإنشاء أو التدوير؛ إن ضاع، دوّره بـ rotate_secret: true.
التسليم وإعادة المحاولة#
المرجع الرسمي لهذه الدلالات هو وصف webhooks.storeEvent في مواصفة الواجهة: «أجب بـ 2xx خلال المهلة؛ وإلا تُعاد المحاولة بتراجع أسي حتى 8 محاولات، ثم يُعلَّم التسليم ميتاً، ويؤدي تكرار الفشل إلى تعطيل الاشتراك». الأرقام التالية هي ما تنفّذه منصّة التجّار اليوم:
| البند | القيمة |
|---|---|
| المهلة لكل محاولة | 10 ثوانٍ |
| النجاح | أي 2xx؛ الجسم يُهمل |
| جدول إعادة المحاولة | تراجع أسي يبدأ بدقيقة ويتضاعف حتى سقف ساعة: 1، 2، 4، 8، 16، 32، 60، 60 دقيقة |
| عدد المحاولات لكل تسليم | 8، ثم يصبح التسليم dead |
| تعطيل الاشتراك | عند 8 إخفاقات متتالية على الأقل واستمرار الفشل 24 ساعة على الأقل معاً؛ لا يكفي أحدهما |
| أثناء التعطيل | تستمر الأحداث بالتراكم لهذا الاشتراك ولا تُفقد؛ يتوقف الإرسال فقط |
| إعادة التفعيل | أعد PUT /webhooks؛ تُسلَّم الأحداث المتراكمة |
| إعادة الإرسال يدوياً | من صفحة التطبيق في البوابة، محاولة واحدة إضافية لكل تسليم ميت |
| الترتيب | تسليم واحد قيد الإرسال لكل تثبيت في الوقت نفسه؛ التسليم البطيء يؤخّر ما بعده في المتجر نفسه فقط |
| الاحتفاظ | سجلات التسليم تُحذف بعد 30 يوماً |
نمط المعالج الآمن للتكرار#
- تحقّق من التوقيع، ثم أجب
2xxفوراً وعالِج في الخلفية؛ المعالجة الطويلة تُحتسب مهلة. - سجّل
idفي مخزن فريد قبل المعالجة؛ التعارض يعني تكراراً فتجاهله. - خزّن آخر
sequenceمعالَج لكلstore_id؛ حدث بتسلسل أصغر من المخزّن وصل متأخراً، وحدث بتسلسل أكبر بفجوة أمر طبيعي. - اجلب الحالة الحالية من الواجهة عند الشك بدلاً من الاعتماد على ترتيب الوصول.
طابِق ليلياً#
Webhooks هي المسار السريع، لا المسار الوحيد. مرة في اليوم، مرّ على كل ما تغيّر منذ آخر علامة مياه مرتفعة لديك وحدّثه؛ فالتسليم الذي انتهى في صندوق الرسائل الميتة أو المعالج الذي فشل بصمت يُصحَّح خلال يوم بدلاً من أبداً:
import { syncOrders } from "@dukkan.one/app-sdk/commerce";
const result = await syncOrders(client, {
since: lastHighWaterMark,
onPage: async (orders) => { for (const order of orders) await upsert(order); },
});
await save(result.highWaterMark);updated_at_min على نقاط نهاية القوائم هو الأساس؛ ويضيف مساعد الحزمة تداخلاً لمدة دقيقة كي لا يُتجاوز أبداً سجل حُدّث أثناء المرور السابق.
المواضيع#
| الموضوع | النطاق المطلوب | متى |
|---|---|---|
order.created |
orders:read |
إنشاء طلب من أي مصدر |
order.status_changed |
orders:read |
كل انتقال في حالة الطلب |
order.paid |
orders:read |
عندما تعبر المبالغ المحصَّلة فعلاً إجمالي الطلب في دفتر المدفوعات |
fulfillment.requested |
orders:read |
إنشاء شحنة بحالة pending: طلب حجز مندوب |
fulfillment.created |
orders:read |
إنشاء أي شحنة |
fulfillment.updated |
orders:read |
تغيّر حالة الشحنة أو رقم تتبعها |
refund.created |
orders:read |
تسجيل استرداد |
product.created، product.updated، product.deleted |
products:read |
تغيّر منتج أو أحد متغيراته |
inventory.movement_created |
inventory:read |
حركة مخزون جديدة |
app.uninstalled |
بلا نطاق | إلغاء التثبيت؛ يصل حتى بعد إلغاء الرموز |
الحمولة data لكل موضوع في مرجع الأحداث. order.paid مستقل تماماً عن التسليم؛ راجع دورة حياة الدفع عند الاستلام.
إدارة الاشتراك#
| العملية | الأثر |
|---|---|
GET /webhooks |
الاشتراك الحالي أو null؛ لا يعيد المفتاح أبداً |
PUT /webhooks |
ينشئ الاشتراك أو يستبدله بالكامل (قائمة المواضيع كلها)؛ rotate_secret: true يصدر مفتاحاً جديداً يظهر مرة واحدة |
DELETE /webhooks |
يوقف التسليم لهذا التثبيت |
يجب أن يكون endpoint_url رابط HTTPS علنياً؛ تُرفض العناوين الخاصة والمحلية. يتطلب الاشتراك في موضوع النطاقَ الذي يحكمه، وإلا 403.