التوثيق
الدمججلسة الدفع

نظرة عامة

جلسة الدفع هي الكائن المركزي اللي بينشئه أي دمج. نفس الشكل بيرجعلك من POST، وGET، وعميل الـ SDK، ومن كل webhook خاص بـ checkout.session.

جلسة الدفع بتمثّل محاولة عميل واحد إنه يدفعلك. السيرفر بتاعك بينشئها، وXPay بيرجّعلك كائن الجلسة، ومن هنا كل نمط دمج هو مجرد طريقة مختلفة لعرض نفس الكائن: صفحة مستضافة، أو رابط دفع، أو SDK drop-in، أو Elements مخصّصة بالكامل.

كائن الجلسة اللي بيرجعلك من POST /checkout/sessions، واللي بتعمله GET بعدين، والـ data.object اللي بيتحمل على كل checkout.sessionAPI webhook. كلهم نفس الشكل. فيه DTO واحد وmapper واحد ورا الثلاثة. لو بنيت على رد الإنشاء، معالِج الـ webhook بتاعك هيقرا نفس الحقول بنفس الأسماء.

علشان تعرف علاقة جلسة الدفع بـ Payment Intent، وCharge، وRefund، والعميل، وBalance Transaction، شوف نموذج الكائنات.

إزاي بتتركّب

اختار نمط الدمج اللي يناسب قد إيه عايز تتحكم في الواجهة. كل دليل نمط بيشرح البناء، وطريقة الاختبار، ومعالِج الـ webhook، وقائمة التحقق قبل الإنتاج للسطح ده. كلها بتنشئ جلسة دفع بنفس الطريقة.

دورة الحياة

الجلسة بتمر بآلتين حالة قصيّرتين: status للجلسة نفسها وpaymentStatus للفلوس.

status

الحالةإمتى بتتحددإنت بتعمل إيه
openبتتحدد عند الإنشاء. الجلسة بتقبل التعديلات وقابلة للعرض في واجهة الدفع.استنى العميل يدفع، أو الجلسة تنتهي صلاحيتها.
completeبتتحدد لما الدفع ينجح. الجلسة بتتقفل: مفيش تعديلات تانية، ومفيش محاولات دفع.نفّذ الطلب بناءً على checkout.session.completed.
expiredبتتحدد لما expiresAt يعدّي، أو لما تستدعي POST /checkout/sessions/:id/expire. الجلسة مينفعش تتعاد.أنشئ جلسة جديدة لو العميل عايز يحاول تاني.

paymentStatus

الحالةإمتى بتتحدد
unpaidالافتراضي. ما حصلتش أي عملية دفع ناجحة.
paidفيه عملية دفع نجحت بكامل مبلغ الجلسة.
no_payment_requiredالجلسة اتنشئت في وضع setup (جمع بيانات الدفع بس من غير خصم).

حقل paymentIntent في الرد بيبقى null لحد ما العميل يبعت الفورم لأول مرة. بعد كده بيحمل شكل Payment IntentAPI بالكامل، نفس اللي بيرجعه GET /payment-intents/:id. الـ ChargesAPI والـ RefundsAPI بتبقى متضمَّنة جوّه الـ Payment Intent.

إنشاء جلسة

الحقل الوحيد المطلوب في POST /checkout/sessions هو afterCompletion. وكل حاجة تانية ليها قيمة افتراضية. عمليًا هتبعت كمان lineItems علشان العميل يشوف بيدفع مقابل إيه. والبنود ممكن تكون ثابتة، أو قابلة للتعديل، أو إضافات اختيارية العميل بيختار يضيفها، والجلسة اللي كلها اختيارية صحيحة: بتفتح بإجمالي 0 والعميل بيضيف بنود قبل ما يدفع. شوف بنود الفاتورة والتسعير.

curl -X POST https://api.xpay.app/checkout/sessions \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "afterCompletion": {
      "type": "redirect",
      "redirect": { "url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" }
    },
    "lineItems": [
      {
        "priceData": {
          "currency": "EGP",
          "unitAmount": 149900,
          "productData": { "name": "Test product" }
        },
        "quantity": 1
      }
    ]
  }'
const res = await fetch("https://api.xpay.app/checkout/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    afterCompletion: {
      type: "redirect",
      redirect: { url: "https://yourshop.example/order/{CHECKOUT_SESSION_ID}" },
    },
    lineItems: [
      {
        priceData: {
          currency: "EGP",
          unitAmount: 149900, // 1,499.00 EGP, in minor units
          productData: { name: "Test product" },
        },
        quantity: 1,
      },
    ],
  }),
});

const session = await res.json();
import os, requests

res = requests.post(
    "https://api.xpay.app/checkout/sessions",
    headers={
        "Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "afterCompletion": {
            "type": "redirect",
            "redirect": {"url": "https://yourshop.example/order/{CHECKOUT_SESSION_ID}"},
        },
        "lineItems": [
            {
                "priceData": {
                    "currency": "EGP",
                    "unitAmount": 149900,
                    "productData": {"name": "Test product"},
                },
                "quantity": 1,
            },
        ],
    },
    timeout=10,
)

session = res.json()

الرد بيحمل الجلسة كاملة، وفيها الاعتمادَين اللي الدمج بتاعك هيستخدمهم بعد كده:

{
  "id": "cs_test_AbC123...",
  "object": "checkout.session",
  "status": "open",
  "paymentStatus": "unpaid",
  "url": "https://checkout.xpay.app/c/cs_test_AbC123...",
  "clientSecret": "cs_test_AbC123..._secret_xyz",
  "amountTotal": 149900,
  "currency": "EGP",
  "afterCompletion": { "type": "redirect", "redirect": { "url": "..." } },
  "paymentIntent": null,
  "livemode": false
}

استخدم url علشان تحوّل العميل (الدفع المستضاف، روابط الدفع). واستخدم clientSecret علشان تركّب الـ SDK (Drop-in، Elements). الرمز {CHECKOUT_SESSION_ID} الحرفي في redirect.url بيتستبدل في السيرفر بـ id بتاع الجلسة قبل ما الرابط يتخزّن، فصفحة العودة بتاعتك تقدر تقراه من المسار من غير ما تضطر تمرّره في query parameters.

المجموعة الكاملة من الحقول اللي تقدر تظبطها متجمّعة حسب الغرض. وكل مجموعة ليها صفحتها بالتفصيل حقل بحقل.

الأوضاع

اثنان من الـ enums على الجلسة بيحدّدوا نوع الدفع اللي بتمثّله وإزاي العميل بيتعامل معاها. الاتنين بيتحددوا عند الإنشاء وثابتين طول حياة الجلسة.

mode

mode هو نوع حركة الفلوس. القيمة الافتراضية payment.

القيمةالمعنى
paymentعملية دفع لمرة واحدة. الافتراضي لأغلب عمليات الدمج تقريبًا.
subscriptionفوترة متكرّرة. لسه مش مدعومة على الـ checkout API.
setupجمع طريقة دفع من غير خصم منها. paymentStatus بيستقر على no_payment_required.

uiMode

uiMode هو الطريقة اللي العميل بيوصل بيها للفورم. القيمة الافتراضية hosted. الاختيار مرتبط بنمط الدمج اللي اخترته فوق؛ سلوك الجلسة، واستدعاء الـ SDK اللي بتعمله، والحقول اللي تقدر تظبطها، كلها بتتغير بيه.

القيمةمكان تشغيل الفورمتستخدمها مع
hostedصفحة XPay المستضافة على https://checkout.xpay.app/c/cs_test_.... السيرفر بيرجّع url.الدفع المستضاف، روابط الدفع.
embeddediframe على موقعك، بيفتحه الـ SDK كنافذة منبثقة أو متضمَّن.Drop-in.
customالفورم بتاعك إنت، باستخدام Elements SDK وconfirmPayment().Elements.

كام قاعدة بتطلع من ده:

  • cancelUrl صالح بس لما uiMode: "hosted". ده الـ redirect اللي XPay بيبعت العميل ليه لو فشلت محاولة دفع على الصفحة المستضافة (رفض، أو رفض 3DS، أو انتهاء مهلة وسيلة محلية). مش زر إلغاء للعميل: الصفحة المستضافة ما فيهاش واحد.
  • uiMode: "custom" معناه إن الكود بتاعك هو اللي بيتولّى جمع بيانات العميل. الجلسة بترفض nameCollection، وphoneNumberCollection، وbillingAddressCollection، وshippingAddressCollection، وsubmitType في الوضع ده.
  • بالنسبة لـ embedded وcustom، بتوثّق الـ SDK بـ clientSecret بتاع الجلسة. المفتاح المنشور لوحده مش كفاية؛ الـ secret بيقصر الـ SDK على جلسة واحدة بعينها.

الحقول المتعارضة

شوية أزواج في جسم الإنشاء متعارضة. الـ API بيرفض الطلبات اللي بتبعت الاتنين مع بعض، ونفس القواعد بتنطبق على PATCH.

إماأوليه
customerIdcustomerDetailsسجل العميلAPI الموجود هو مصدر التعبئة المسبقة، أو إنت بتقدّم بيانات خام. مش الاتنين.
customerIdcustomerCreation: "always""أنشئ عميل جديد دايمًا" بيتعارض مع "استخدم العميل الموجود ده".
paymentMethodTypespaymentMethodConfigurationIdاختار قائمة طرق صريحة، أو اشِر لتهيئة محفوظة.
lineItem.pricelineItem.priceDataكل بند فاتورة بيشير لـ PriceAPI موجود بالـ id، أو بيضمّن بيانات المنتج والسعر داخليًا.
بند من نوع CUSTOMallowPromotionCodes، discountsبنود المبلغ المخصّص (العميل بيدخل المبلغ بنفسه) مش متوافقة مع الخصومات.

customerUpdate (اللي بيتحكم في إن الدفع يكتب البيانات المجمَّعة على العميل ولا لأ) ليه معنى بس لما customerId يكون متقدّم. إرساله من غير customerId بيبقى خطأ تحقّق.

إيه اللي يتعدّل

PATCH /checkout/sessions/:id بيحدّث جلسة مفتوحة. بيقبل كل حقل في جسم الإنشاء ما عدا اللي تحت، واللي بيتقفل عند الإنشاء:

  • mode
  • uiMode
  • submitType
  • currency
  • expiresAfterMinutes

lineItems في PATCH هو استبدال كامل: المصفوفة الجديدة بتكتب فوق القديمة. علشان تغيّر كمية بند واحد، ابعت المصفوفة المطلوبة كاملة. الجلسات في حالة complete أو expired بتبقى للقراءة بس وبترفض PATCH.

انتهاء الصلاحية

كل جلسة ليها expiresAt. الافتراضي 24 ساعة بعد الإنشاء؛ والحد الأدنى 30 دقيقة. ظبّط expiresAfterMinutes في جسم الإنشاء علشان تغيّره.

حاجتين بيعلّموا الجلسة كـ expired:

  • expiresAt بيعدّي. الجلسة بقت مش صالحة للدفع؛ والصفحة المستضافة بتعرض حالة نهائية "خلصت كل حاجة هنا".
  • إنت بتستدعي POST /checkout/sessions/:id/expire صراحةً. مفيد لما تكون عايز توقّف قبول الدفع على جلسة مش ناوي تكرّمها، أو تفضّي خانات الاستخدام على كود ترويج لمرة واحدة.

المسارَين الاتنين بيطلّقوا checkout.session.expired webhook.

الـ Webhooks

حدثين بيتطلقوا لدورة حياة الجلسة نفسها. الـ data.object في كل واحد هو جلسة دفعAPI كاملة، نفس شكل اللي بيرجعه GET /checkout/sessions/:id.

الحدثإمتى
checkout.session.completedالدفع نجح. status بقى complete، وpaymentStatus بقى paid (أو no_payment_required لوضع setup).
checkout.session.expiredالجلسة انتهت صلاحيتها بالوقت أو باستدعاء /expire صريح.

عملية دفع ناجحة على الدفع المستضاف بتطلّق أربع أحداث. بتتبعت بالترتيب ده:

  1. payment_intent.created
  2. charge.succeeded
  3. payment_intent.succeeded
  4. checkout.session.completed

تسليم الـ webhook غير متزامن وبيتم مع إعادة محاولة، فـ ما تعتمدش على ترتيب الوصول في المعالِج بتاعك. اربط تنفيذ الطلب بـ checkout.session.completed واعتبر الأحداث الأبكر سياق إضافي. الأحداث الأبكر مفيدة لما تحتاج تتفاعل مع الـ Payment IntentAPI أو الـ ChargeAPI الأساسي مباشرةً. شوف مرجع الأحداث للقائمة الكاملة.

رايح فين بعد كده

في الصفحة دي