حدث 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-environmentsandbox أو live.

سر التوقيع (whsec_…) خاص بكل بيئة، ويولد عند التصريح بأول عنوان. تجده في قسم المطورين بلوحة التحكم.

التحقق من التسليم

نقطة استقبال لا تتحقق من التوقيع تقبل أي طلب POST كان. وهذا باب مفتوح لمن يريد منح نفسه وصولا دون أن يدفع شيئا. أربع خطوات، بالترتيب:

  1. اقرأ الجسم الخام. التوقيع يحسب على البايتات المستلمة كما هي. تبني lahsab الجسم بمفاتيح JSON مرتبة، فإذا حلل إطار عملك JSON ثم أعاد بناءه تغير ترتيب المفاتيح ولم يعد التوقيع مطابقا. على Express: استعمل express.raw وليس express.json.

  2. أعد حساب HMAC_SHA256(secret, "{timestamp}.{corps brut}") بالترميز السداسي العشري، وقارنه مع x-lahsab-signature مقارنة بزمن ثابت (crypto.timingSafeEqual)، ولا تستعمل === أبدا.

  3. ارفض ما تجاوز 5 دقائق. إذا تجاوز الفارق بين x-lahsab-timestamp وساعة خادمك 300 ثانية فارفض الطلب: توقيع صحيح يعاد استعماله لاحقا يجب ألا يمر.

  4. أزل التكرار عبر 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. يحصل المشتري على الوصول بعد ثوان من إيداعه. وأنت تتحمل خطر وصل مزور، ولا بد أن تكون قادرا على التراجع.

فإذا اخترت الثانية، فعليك ثلاثة التزامات:

  1. تحمل الحمولة أصلا invoice.periodEnd وinvoice.subscriptionId. امنح وصولا مؤقتا إلى ذلك التاريخ، دون أن تسجل أن الفاتورة دفعت؛
  2. لم يتغير شيء في lahsab: تبقى الفاتورة open، ولا يصير الاشتراك active، ولا يتقدم currentPeriodEnd. وسيأتي invoice.paid لاحقا ليثبت ذلك؛
  3. أنصت إلى payment.rejected وinvoice.marked_uncollectible وcustomer.subscription.updated لكي تسحب الوصول. فبدون ذلك يتحول وصل مزور إلى وصول دائم؛
  4. واعلم أن الوصول الممنوح مبكرا يخبر المشتري عن إيداعه هو. فـ lahsab لا يخبره أبدا أن ملفا اجتاز التحليل، وصفحة الدفع تعرض الحالة نفسها تماما قبل وبعد. أما منتجك أنت فيخبره حين ينفتح، ومن ساءت نيته يستطيع أن يعيد المحاولة حتى تنجح. وميزانية المحاولات ليست ضيقة: 10 وصولات لكل دفعة، والفاتورة المستضافة تصدر رابطا جديدا بعد كل رفض. فحدد المحاولات، مثلا بألا تمنح شيئا على payment.proof_validated لزبون سبق أن صدر في حقه payment.rejected، وأن تشترط invoice.paid في حالته.

الفواتير غير المدفوعة

lahsab لا ترسل رسائل تذكير مكانك. لتذكير زبون، استمع إلى invoice.overdue: حمولته تتضمن الفاتورة والزبون (الاسم والهاتف) ورابط الدفع، أي كل ما يلزم لصياغة رسالتك دون نداء إضافي. مهلة السماح (gracePeriodDays) والسلوك عند انتهائها (dunningExhaustedBehavior) يضبطان لكل تاجر من إعدادات لوحة التحكم (راجع الفواتير غير المدفوعة).

التسليم وإعادة المحاولات

  1. أرسل استجابة 2xx بسرعة. كل رمز خارج 2xx، وكل إعادة توجيه (3xx)، وكل مهلة تتجاوز 10 ثوان تحسب فشلا. أكد الاستلام أولا ثم عالج الحدث بشكل غير متزامن.

  2. إعادة المحاولة تلقائية، حتى 8 محاولات، بتباعد متزايد: 1 min، 5 min، 30 min، 2 h، 6 h، 12 h ثم 24 h. بعد ذلك يتحول التسليم إلى failed.

  3. السجل يحفظ كل شيء. يعرض قسم المطورين كل عمليات التسليم في البيئة الحالية (رمز الاستجابة، عدد المحاولات، الخطأ) مع زر إعادة الإرسال لإعادة تشغيل حدث عند الطلب.

ترتيب نسختك المحلية

إذا كنت تحتفظ بنسخة محلية من 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.