تخطٍ إلى المحتوى
4 سبتمبر 2026 · المدفوعات

تحليل تفصيلي: حمولة Webhook من Stripe، حقلًا بحقل

يصف هذا المقال المنتج وقت نشره. راجع منشئ الذكاء الاصطناعي وفرق الوكلاء للاطلاع على القدرات الحالية.

تحليل تفصيلي: حمولة Webhook من Stripe، حقلًا بحقل

إليك حمولة webhook أرسلتها Stripe فعليًا، مُختصرة ومُنقّحة لكنها سليمة من ناحية البنية — checkout.session.completed حدث، ذلك الذي يُطلق عند انتهاء شخص من الدفع في صفحة Checkout. معظم المطورين يلقون نظرة سريعة على هذا مرة واحدة، ويأخذون data.object.customer و data.object.amount_total، ثم يمضون قدمًا. هذا عادةً مقبول في عرض توضيحي. لكنه سبب ازدواج تلبية الطلبات في التطبيقات، وفوات تجديدات الاشتراكات، وأول بريد دعم غاضب بخصوص استرداد "لم يتم". لنفكك الأمر بالكامل.

{
  "id": "evt_1P8xQ2K7z3n9lWqA00Ff2gLm",
  "object": "event",
  "api_version": "2024-06-20",
  "created": 1725456000,
  "type": "checkout.session.completed",
  "livemode": true,
  "pending_webhooks": 1,
  "request": { "id": "req_9F2mA1", "idempotency_key": null },
  "data": {
    "object": {
      "id": "cs_live_a1B2c3D4",
      "object": "checkout.session",
      "customer": "cus_Q3fZ8xLmN2",
      "customer_details": {
        "email": "[email protected]",
        "tax_ids": []
      },
      "payment_status": "paid",
      "payment_intent": "pi_3P8xQ2K7z3n9lWqA1gH4iJ5k",
      "subscription": "sub_1P8xQaK7z3n9lWqA",
      "amount_total": 2900,
      "currency": "usd",
      "metadata": {
        "app_user_id": "usr_38821",
        "plan": "pro_monthly"
      }
    }
  }
}

الحقل id ومشكلة التطابق المُتكرر (idempotency) التي ترثها مجانًا

evt_1P8xQ2K7z3n9lWqA00Ff2gLm يبدو وكأنه ضوضاء حتى تدرك أنه الشيء الوحيد الذي يقف بينك وبين طلب مكرر. تُسلّم Stripe الـ webhooks مرة واحدة على الأقل، وليس بالضبط مرة واحدة. إذا قبل خادمك الطلب لكنه انتهت مهلته قبل أن يتمكن من إرسال 200 استجابة، فستفترض Stripe الفشل وتعيد إرسال نفس الحدث، بنفس id، أحيانًا بعد دقائق، وأحيانًا في اليوم التالي. إذا كان معالجك يمنح اشتراكًا في كل مرة يرى فيها checkout.session.completed دون التحقق مما إذا كان قد عالج هذا الـ idبالضبط من قبل، فستمنحه في النهاية مرتين. الحل هو سطر واحد في قاعدة بياناتك: قيد فريد (unique constraint) على حقل processed_webhook_events جدول مُفهرس بهذا الحقل، يُتحقق منه قبل فعل أي شيء آخر. إنه أقل سطر برمجي إثارةً في كل التكامل، وهو أيضًا السطر الذي يهم فعلًا.

رأس التوقيع الذي لا يوجد في نص الطلب أصلًا

الحمولة أعلاه هي ما ترسله Stripe كنص الطلب. ما لا تُظهره هو رأس Stripe-Signature المرافق له — طابع زمني وتوقيع HMAC-SHA256 مُحسوب باستخدام سر توقيع webhook الخاص بك. تخطَّ التحقق منه، وستصبح نقطة نهاية webhook لديك مسارًا عامًا لطلبات POST يمكن لأي شخص على الإنترنت إصابته بحمولة JSON مُصنّعة يدويًا تحمل "نجاح الدفع" لفتح مستواك المدفوع مجانًا. هذا ليس هجومًا نظريًا؛ فروابط نقاط النهاية تتسرب في كود JS من جانب العميل، وفي السجلات، وفي لصقات Slack، والماسحات تبحث بالضبط عن هذا النوع من المسارات.

رأيت هذا الخطأ بعينه في بيئة الإنتاج مرتين — وفي المرتين كان الإصلاح يستغرق خمس دقائق، وفي المرتين كان قد ظل موجودًا لأشهر قبل أن ينتبه أحد لحسابات "pro" المجانية في قاعدة البيانات.

التحقق يكلفك ثلاثة أسطر باستخدام حزمة Stripe SDK (stripe.webhooks.constructEvent(body, sig, secret)) ويجب أن يعمل على نص الطلب الخام غير المُحلَّل — إذا كانت وسيطة JSON في إطار العمل قد حلّلته بالفعل إلى كائن قبل أن يصل إلى معالجك، فسيفشل فحص التوقيع بسبب عدم تطابق على مستوى البايت لا علاقة له بأي هجوم حقيقي. هذا هو الخطأ الأكثر شيوعًا "لماذا يعيد webhook دائمًا الرمز 400" المُبلَّغ عنه في منتديات Stripe نفسها.

data.object.customer مقابل data.object.customer_details

هذان الحقلان يبدوان متكررين، لكنهما ليسا كذلك. customer هو معرّف عميل Stripe — ثابت وقابل لإعادة الاستخدام، وهو ما تخزّنه كمفتاح خارجي. customer_details هو لقطة لما كتبه المشتري في نموذج الدفع في تلك اللحظة — البريد الإلكتروني، الرقم الضريبي، وأحيانًا الاسم — وقد يكون موجودًا حتى عندما يكون customer فارغًا (null)، وهو ما يحدث في جلسات Checkout ذات المرة الواحدة عندما لا تطلب من Stripe إنشاء كائن Customer. إذا كان منطق الإعداد لديك يقرأ customer بافتراض أنه مملوء دائمًا، فإن عمليات الدفع كضيف تكسره بصمت.

metadata: الحقلان الوحيدان اللذان وضعتهما بنفسك فعليًا

app_user_id و plan ليست حقول تابعة لـ Stripe — بل هي أي شيء أرفقته أنت عند إنشاء جلسة Checkout. هذا هو القرار التصميمي الأهم في التكامل بأكمله، ومن السهل تخطيه لأن دليل Stripe السريع لا يتوقف عنده طويلًا. بدون معرّف المستخدم الخاص بك في metadata، فإن الطريقة الوحيدة لربط هذه الدفعة بصف في قاعدة بياناتك هي المطابقة بالبريد الإلكتروني، والبريد الإلكتروني يتغير أو يُكتب بشكل خاطئ أو يخص شخصًا يدفع نيابة عن زميل. كل معالج webhook كتبته بشكل سيئ، بأثر رجعي، كان معالجًا حاول إعادة بناء الهوية من customer_details.email بدلاً من الوثوق بالبيانات الوصفية (metadata) التي حدده هو نفسه قبل ثلاث خطوات.

payment_status: قيمة paid ليست الوحيدة الممكنة

من المغري أن تعامل مجرد وجود هذا الحدث كدليل على الدفع. ولكنه ليس كذلك دائمًا — payment_status يمكن أن يكون أيضًا unpaid (اكتملت الجلسة لكن طريقة دفع متأخرة مثل الخصم البنكي لم تُصفَّ بعد) أو no_payment_required (عملية دفع مخفّضة بالكامل، أو تجربة مجانية دون خصم بطاقة بعد). إتمام الطلب بناءً على checkout.session.completed دون التحقق من هذا الحقل يعني شحن المنتج قبل تأكيد المال فعليًا. لأي مبلغ يتجاوز بضعة دولارات، انتظر payment_status: "paid" أو الأفضل، اربط عملية الإتمام بـ invoice.paid / payment_intent.succeeded بدلاً من حدث الجلسة.

نوع الحدثيُطلق عندما ينبغي فعله به
checkout.session.completedإنهاء المشتري لنموذج Checkoutسجّله، لكن تحقق من payment_status قبل الإتمام
payment_intent.succeededتأكيد استلام المال فعليًانقطة آمنة لتنفيذ عملية شراء لمرة واحدة
invoice.paidسداد فاتورة اشتراك (سواء الأولى أو التجديد)تمديد الوصول، وإعادة ضبط عدّادات الاستخدام
customer.subscription.updatedتغيير الخطة، أو الكمية، أو تفعيل خيار الإلغاء عند نهاية الفترةمزامنة الصلاحيات، ولا تفترض أن هذا يعني الإلغاء
charge.refundedأنت أو بنك المشتري يقوم بعكس عملية الدفعإلغاء الوصول — هذا هو ما يُنسى غالبًا

الحقل غير الموجود في هذه الحمولة: ماذا يحدث بعد ذلك

لا شيء في هذا الـ JSON يخبرك أن Stripe ستعيد محاولة التسليم الفاشل وفق جدول تراجعي (backoff) لمدة تصل إلى ثلاثة أيام، أو أنها بعد عدد كافٍ من الإخفاقات المتتالية ستُعطّل نقطة النهاية وترسل لك بريدًا إلكترونيًا بذلك. هذا السلوك موجود في إعدادات لوحة التحكم لديك، وليس في الحمولة، وهو الجزء الذي يكتشفه معظم المطورين فقط بعد أن تكون نقطة النهاية لديهم معطّلة بصمت لأسبوع كامل بسبب تغيير في مسار الرابط أثناء نشر جديد.

٧٢ ساعة
المدة التي تستمر خلالها Stripe في إعادة محاولة إرسال webhook فاشل قبل التوقف

اربط فحص مراقبة بمعدل نجاح نقطة نهاية webhook لديك في نفس يوم ربطها، لا بعد فوات أول تجديد. الحمولة تُعلّمك ما حدث. لكنها لا تحذرك عندما يتوقف معالجك بصمت عن الاستماع.

المدفوعات
مشاركةXLinkedInFacebookRedditQuoraواتسابتيليجرامالبريد الإلكتروني
← جميع المقالات