حدث webhook هو العنصر المركزي في lahsab: طلب POST موقع يصل إلى العنوان الذي تصرح به في قسم المطورين، وعن طريقه يعلم خادمك أن دفعة تأكدت أو أن فاتورة دفعت. لا حاجة إلى أي حلقة polling.
لكل بيئة عنوان استقبال وسر توقيع خاصان بها. مفتاح lsk_sandbox_… لا يطلق أبدا تسليما نحو عنوان الإنتاج عندك، وكل حدث يحمل قيمة environment الخاصة به.
الترويسات
| الترويسة | التفاصيل |
|---|---|
x-lahsab-signature | بصمة HMAC-SHA256 بالترميز السداسي العشري للسلسلة {timestamp}.{corps brut}، محسوبة بسر البيئة. |
x-lahsab-timestamp | توقيت Epoch بالثواني. يدخل في حساب التوقيع ويغلق الباب أمام إعادة استعمال توقيع قديم. |
x-lahsab-event-id | معرف ثابت للحدث، تتقاسمه كل محاولات تسليمه. هذا هو مفتاحك لإزالة التكرار. |
x-lahsab-environment | sandbox أو live. |
سر التوقيع (whsec_…) خاص بكل بيئة، ويولد عند التصريح بأول عنوان. تجده في قسم المطورين بلوحة التحكم.
التحقق من التسليم
نقطة استقبال لا تتحقق من التوقيع تقبل أي طلب POST كان. وهذا باب مفتوح لمن يريد منح نفسه وصولا دون أن يدفع شيئا. أربع خطوات، بالترتيب:
-
اقرأ الجسم الخام. التوقيع يحسب على البايتات المستلمة كما هي. تبني lahsab الجسم بمفاتيح JSON مرتبة، فإذا حلل إطار عملك JSON ثم أعاد بناءه تغير ترتيب المفاتيح ولم يعد التوقيع مطابقا. على Express: استعمل
express.rawوليسexpress.json. -
أعد حساب
HMAC_SHA256(secret, "{timestamp}.{corps brut}")بالترميز السداسي العشري، وقارنه معx-lahsab-signatureمقارنة بزمن ثابت (crypto.timingSafeEqual)، ولا تستعمل===أبدا. -
ارفض ما تجاوز 5 دقائق. إذا تجاوز الفارق بين
x-lahsab-timestampوساعة خادمك 300 ثانية فارفض الطلب: توقيع صحيح يعاد استعماله لاحقا يجب ألا يمر. -
أزل التكرار عبر
x-lahsab-event-id. إعادة المحاولة بعد انقضاء مهلة من جهتك قد تسلم الحدث نفسه مرتين. عالجه مرة واحدة فقط.
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SECONDS = 300;
const secret = process.env.LAHSAB_WEBHOOK_SECRET;
// Corps BRUT obligatoire : express.raw, pas express.json.
app.post(
"/webhooks/lahsab",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.header("x-lahsab-signature");
const timestamp = req.header("x-lahsab-timestamp");
const eventId = req.header("x-lahsab-event-id");
if (!signature || !timestamp || !eventId) return res.sendStatus(400);
// 1. Anti-rejeu : refuser un horodatage trop vieux (ou trop en avance).
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
return res.sendStatus(400);
}
// 2. Signature sur `timestamp.corpsBrut` (req.body est un Buffer).
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${req.body}`)
.digest("hex");
const valid =
expected.length === signature.length &&
crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signature, "hex"),
);
if (!valid) return res.sendStatus(401);
// 3. Déduplication : le même événement peut arriver deux fois.
if (await alreadyHandled(eventId)) return res.sendStatus(200);
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "invoice.paid") {
await grantAccess(
event.data.customer.externalId,
event.data.subscription?.currentPeriodEnd,
);
}
await markHandled(eventId);
res.sendStatus(200); // 4. Répondre 2xx vite, traiter en asynchrone.
},
); الغلاف
كل الأحداث تشترك في الشكل نفسه. المحتوى المفيد موجود في data، وشكله يتوقف على عائلة الحدث. معرف الحدث ينتقل في الترويسة x-lahsab-event-id وليس في الجسم.
{
"type": "invoice.paid",
"environment": "live",
"createdAt": "2026-07-13T10:04:00.000Z",
"data": { }
}
| العائلة | محتوى data |
|---|---|
payment.* | كائن intent نفسه بشكل مسطح (id، amount، status، checkoutUrl، customerRef، confirmationSource…). |
customer.* | customer |
customer.subscription.* | subscription (مع تضمين customer و price) |
invoice.* | invoice، إضافة إلى customer و subscription إذا كانت الفاتورة ناتجة عن اشتراك. |
فهرس الأحداث
18 حدثا موزعة على عائلتين. أحداث الدفع مستقلة عن الفوترة: يبقى intent بنية أساسية من الدرجة الأولى لمن يريد فقط تحصيل دفعة واحدة.
| حدث الدفع | يصدر عندما |
|---|---|
payment.intent_created | أنشئ intent جديد. |
payment.proof_submitted | أودع المشتري وصل الدفع. |
payment.proof_analyzed | أصدر تحليل هذا الوصل حكمه. |
payment.proof_validated | اعتبر الوصل متوافقا. ولا يعني ذلك أن الدفعة تأكدت. |
payment.confirmed | تأكد استلام المال. |
payment.rejected | رفضت الدفعة. |
payment.expired | انتهت صلاحية رابط الدفع دون تأكيد. |
| حدث الفوترة | يصدر عندما |
|---|---|
customer.created · customer.updated | أنشئ زبون أو عدلت بياناته. |
customer.subscription.created | اكتتب اشتراك جديد. |
customer.subscription.updated | تغيرت الحالة أو الفترة أو الإلغاء المبرمج. |
customer.subscription.deleted | انتهى الاشتراك (canceled). |
invoice.created | أنشئت فاتورة (draft). |
invoice.finalized | أصبحت نهائية: مرقمة، بالحالة open، وقابلة للدفع. |
invoice.paid l'accès | دفعت الفاتورة. هذا هو الحدث الذي يمنح الوصول. |
invoice.overdue | انقضى أجل الاستحقاق والفاتورة ما تزال open. |
invoice.marked_uncollectible | استنفدت مهلة السماح: الفاتورة غير قابلة للتحصيل. |
invoice.voided | ألغى التاجر الفاتورة. |
invoice.paid
هذا هو الحدث الذي تربط به نظامك. يحمل كل ما يلزم لمنح الوصول دون أي نداء إضافي: معرف الزبون عندك (customer.externalId)، والتاريخ الذي دفع الاشتراك إلى غايته (subscription.currentPeriodEnd).
{
"type": "invoice.paid",
"environment": "live",
"createdAt": "2026-08-13T09:12:00.000Z",
"data": {
"invoice": {
"id": "f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c",
"number": "LSB-2026-0042",
"status": "paid",
"amountDue": 2500,
"amountPaid": 2500,
"periodStart": "2026-08-13T00:00:00.000Z",
"periodEnd": "2026-09-13T00:00:00.000Z",
"dueDate": "2026-08-20T00:00:00.000Z",
"paidAt": "2026-08-13T09:12:00.000Z",
"hostedInvoiceUrl": "https://dashboard.lahsab.com/invoice/f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c",
"version": 3,
"updatedAt": "2026-08-13T09:12:00.000Z"
},
"customer": {
"id": "c0ffee00-1111-4222-8333-444455556666",
"externalId": "user_abc123",
"name": "Yacine B.",
"phone": "0770000000",
"version": 1,
"updatedAt": "2026-08-06T14:20:00.000Z"
},
"subscription": {
"id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"status": "active",
"currentPeriodStart": "2026-08-13T00:00:00.000Z",
"currentPeriodEnd": "2026-09-13T00:00:00.000Z",
"version": 5,
"updatedAt": "2026-08-13T09:12:00.000Z"
}
}
}
payment.proof_analyzed
عندما يودع المشتري وصلا، يصدر lahsab حدثين بهذا الترتيب، ثم حدثا ثالثا إذا صمد الوصل.
payment.proof_submitted يقول «وصل شيء ما الآن». يصدر فورا، دون انتظار أي طرف.
payment.proof_analyzed يقول ما قيمة هذا الملف. يصدر بعد بضع ثوان، حين يخرج التحليل بحكمه. الحمولة هي نفس intent، غير أن جدول proofs[] يكون هذه المرة مملوءا: analysisStatus (completed أو failed أو skipped)، وanalysisVerdict (match أو mismatch أو suspect أو unreadable)، وتقرير analysis كاملا مع تفصيل الفحوص الخمسة.
يصدر الحدث في كل الحالات، حتى عندما يفشل التحليل أو لا يشتغل أصلا. فقيمة analysisStatus المساوية لـ failed ليست حكما سلبيا، بل هي غياب الحكم.
payment.proof_validated
الوصل الذي يعتبر متوافقا ينقل intent إلى الحالة proof_validated. وهذه الحالة تقول شيئا واحدا: الوثيقة متماسكة، والمال لم يعاين. فتبقى الدفعة مستحقة.
طريقان يؤديان إلى هذه الحالة، يميز بينهما الحقل proofValidationSource في intent.
proofValidationSource | ما الذي حدث |
|---|---|
analysis | أصدر التحليل حكم match مع مبلغ مقروء بوضوح. |
manual | قبل التاجر الوصل بنفسه من لوحة التحكم. |
لا يؤكد أي نظام آلي دفعة أبدا. يبقى payment.confirmed مخصصا لمعاينة المال، وتبقى قيمة confirmationSource هي manual ما دامت المصادر الآلية (الرسائل القصيرة، كشف الحساب) غير مربوطة.
أي حدث يمنح الوصول
القرار قرارك أنت لا قرارنا. أمامك استراتيجيتان، وكلتاهما مشروعة.
انتظار invoice.paid. المال معاين، ولا خطر احتيال. غير أن المشتري ينتظر أن يفتح التاجر حسابه، وقد يستغرق ذلك ساعات.
منح الوصول على payment.proof_validated. يحصل المشتري على الوصول بعد ثوان من إيداعه. وأنت تتحمل خطر وصل مزور، ولا بد أن تكون قادرا على التراجع.
فإذا اخترت الثانية، فعليك ثلاثة التزامات:
- تحمل الحمولة أصلا
invoice.periodEndوinvoice.subscriptionId. امنح وصولا مؤقتا إلى ذلك التاريخ، دون أن تسجل أن الفاتورة دفعت؛ - لم يتغير شيء في lahsab: تبقى الفاتورة
open، ولا يصير الاشتراكactive، ولا يتقدمcurrentPeriodEnd. وسيأتيinvoice.paidلاحقا ليثبت ذلك؛ - أنصت إلى
payment.rejectedوinvoice.marked_uncollectibleوcustomer.subscription.updatedلكي تسحب الوصول. فبدون ذلك يتحول وصل مزور إلى وصول دائم؛ - واعلم أن الوصول الممنوح مبكرا يخبر المشتري عن إيداعه هو. فـ lahsab لا يخبره أبدا أن ملفا اجتاز التحليل، وصفحة الدفع تعرض الحالة نفسها تماما قبل وبعد. أما منتجك أنت فيخبره حين ينفتح، ومن ساءت نيته يستطيع أن يعيد المحاولة حتى تنجح. وميزانية المحاولات ليست ضيقة: 10 وصولات لكل دفعة، والفاتورة المستضافة تصدر رابطا جديدا بعد كل رفض. فحدد المحاولات، مثلا بألا تمنح شيئا على
payment.proof_validatedلزبون سبق أن صدر في حقهpayment.rejected، وأن تشترطinvoice.paidفي حالته.
الفواتير غير المدفوعة
lahsab لا ترسل رسائل تذكير مكانك. لتذكير زبون، استمع إلى invoice.overdue: حمولته تتضمن الفاتورة والزبون (الاسم والهاتف) ورابط الدفع، أي كل ما يلزم لصياغة رسالتك دون نداء إضافي. مهلة السماح (gracePeriodDays) والسلوك عند انتهائها (dunningExhaustedBehavior) يضبطان لكل تاجر من إعدادات لوحة التحكم (راجع الفواتير غير المدفوعة).
التسليم وإعادة المحاولات
-
أرسل استجابة
2xxبسرعة. كل رمز خارج2xx، وكل إعادة توجيه (3xx)، وكل مهلة تتجاوز 10 ثوان تحسب فشلا. أكد الاستلام أولا ثم عالج الحدث بشكل غير متزامن. -
إعادة المحاولة تلقائية، حتى 8 محاولات، بتباعد متزايد:
1 min،5 min،30 min،2 h،6 h،12 hثم24 h. بعد ذلك يتحول التسليم إلىfailed. -
السجل يحفظ كل شيء. يعرض قسم المطورين كل عمليات التسليم في البيئة الحالية (رمز الاستجابة، عدد المحاولات، الخطأ) مع زر إعادة الإرسال لإعادة تشغيل حدث عند الطلب.
ترتيب نسختك المحلية
إذا كنت تحتفظ بنسخة محلية من intent أو فاتورة أو اشتراك أو زبون أو منتج أو سعر أو قسيمة، فلا يمكنك تطبيق الحمولات بالترتيب الذي تصلك به. حالتان تسلمانك حالة قديمة، وكلتاهما طبيعية.
تسليم أعيدت محاولته يصل متأخرا. حدث customer.subscription.created رفض في المحاولة الأولى قد يصل بعد customer.subscription.updated الذي نجح من أول مرة. آخر ما يصل ليس بالضرورة الأحدث.
إعادة تشغيل مفتاح الإدمبوتنس ترجع الاستجابة الأصلية. أعد استعمال Idempotency-Key سبق استعماله وسيرجع لك الجسم المسجل عند النداء الأول، لا الحالة الجارية للكائن. هذا هو السلوك المنتظر من الإدمبوتنس، ومعناه أن API قد تسلمك لقطة عمرها أيام.
لذلك يحمل كل مورد عددا صحيحا اسمه version، يزيد عند كل كتابة على السطر. وهو حاضر في كل مكان: في استجابة API وفي حمولة webhook، بالقيمة نفسها للحالة نفسها.
const known = await mirror.versionOf(payload.id);
if (known !== null && payload.version < known) return; // قديمة، ترمى
await mirror.apply(payload);
ثلاث نقاط تجنبك المزالق:
- القيمة المتساوية تعالج ولا ترمى. ليست كل كتابة تنتج حدثا، وليس كل حدث ناتجا عن كتابة.
payment.proof_analyzedيحمل نتيجة فحص وصل دون أن يمس intent، فيصل بالإصدار نفسه الذي وصل بهpayment.proof_submittedقبله. رميه يفقدك نتيجة الفحص. - الإصدارات غير متتالية. القفز من 7 إلى 9 أمر عادي، الترتيب وحده هو المهم.
- الإصدار يقارن بنفسه فقط. هو عداد لكل سطر وليس رقما تسلسليا عاما. الإصدار 4 لفاتورة لا علاقة له بالإصدار 4 لفاتورة أخرى.
الحقل updatedAt يرافق version على الكائنات نفسها، للعرض ولتتبع الأخطاء. لا تعتمد عليه في الترتيب: lahsab تكتب أحيانا المورد نفسه مرتين في الميلي ثانية نفسها، فتنشأ فاتورة اشتراك (draft) ثم تختم مباشرة (open). الطابع الزمني لا يفصل بين الحالتين، أما الإصدار فيفصل.
ماذا بعد؟
- استقبال الأحداث على جهازك، موقعة كما في الإنتاج: Webhooks محليا.
- محرك الاشتراكات وقاعدته الذهبية: الاشتراكات.
- كل نقاط النهاية والحقول والأخطاء: مرجع API.