الرئيسيةمن أناالدوراتالمدونةسوق الأوامرالمناهج والباقاتالشركاء

دورات عربية متخصصة في التقنية والبرمجة والذكاء الاصطناعي.

المنصة مبنية على الوضوح، التطبيق، والنتيجة النافعة: شرح مرتب يساعدك تفهم الأدوات، تكتب كودًا أفضل، وتستخدم الذكاء الاصطناعي بوعي داخل العمل الحقيقي.

المنصة

  • الرئيسية
  • من أنا
  • الدورات
  • المناهج والباقات
  • سوق الأوامر
  • المدونة

الدعم

  • الأسئلة الشائعة
  • تواصل معنا
  • سياسة الخصوصية
  • شروط استخدام التطبيق
  • سياسة الاسترجاع

© 2026 أحمد حايس. جميع الحقوق محفوظة.

الرئيسيةالدوراتالمناهجالمدونةالدخول
How To Make It

اعمل Webhook Receiver يمنع الدفع المزدوج بـ Idempotency في 70 سطر

متوسط20 يونيو 20266 دقائق قراءة
اعمل Webhook Receiver يمنع الدفع المزدوج بـ Idempotency في 70 سطر

المستوى: متوسط (Intermediate) — محتاج تكون مرتاح في Node.js وExpress وعندك فكرة أساسية عن SQL. مش محتاج خبرة سابقة في الـ webhooks.

اعمل Webhook Receiver يمنع الدفع المزدوج

في آخر المقال هيبقى عندك نقطة استقبال webhook بتنفّذ كل حدث مرة واحدة بالظبط، حتى لو بوابة الدفع بعتتهولك خمس مرات. كله في جدول واحد و70 سطر كود.

المشكلة باختصار

بوابات الدفع زي Stripe و PayPal بتبعتلك webhook لما يحصل حدث: عملية دفع نجحت، اشتراك اتجدّد، استرجاع اتعمل. القاعدة عندهم بسيطة: لو السيرفر بتاعك ماردش بـ 200 بسرعة، يعتبروا إن الرسالة ضاعت ويبعتوها تاني. Stripe بالتحديد بيفضل يعيد المحاولة لمدة توصل 3 أيام لو ماجاش رد ناجح.

النتيجة اللي بتحصل فعلاً: نفس حدث الدفع بيوصلك مرتين أو تلاتة. لو الكود بتاعك بيعمل fulfillOrder() أو بيبعت إيصال أو بيضيف رصيد في كل مرة، يبقى العميل اتحاسب مرتين أو استلم منتجه مرتين. ده مش سيناريو نظري؛ ده بيحصل تحت الحمل العادي لمّا السيرفر يتأخّر شوية في الرد.

عملية دفع إلكتروني بالبطاقة تمثّل خطر تكرار تنفيذ نفس الـ webhook ومعالجة الدفعة مرتين

إيه هي الـ Idempotency أصلاً؟

تخيّل زرار الأسانسير. لو دُست عليه مرة، أو خمس مرات بعصبية، الأسانسير هييجي مرة واحدة. الضغطة الزيادة مالهاش أي تأثير إضافي. ده بالظبط معنى idempotency.

علميًا: العملية بتكون idempotent لو تنفيذها مرة واحدة بيدّي نفس النتيجة بالظبط زي تنفيذها عدة مرات بنفس المدخلات. GET في HTTP idempotent بطبيعته. لكن "نفّذ عملية الدفع" مش idempotent بطبيعتها — لو نفّذتها مرتين، اتحاسب مرتين. شغلتنا هنا إننا نخلّيها idempotent بإيدينا.

الحيلة: كل حدث جاي من بوابة الدفع بيكون معاه معرّف فريد ثابت (event.id في Stripe). ده الـ idempotency key. لو شُفت نفس الـ id قبل كده، تتجاهله ببساطة.

ليه القيد الفريد في الداتابيز أأمن من الفحص اليدوي

أول فكرة بتيجي للناس: "هاعمل SELECT أشوف الحدث موجود ولا لأ، وبعدين أعالجه." الطريقة دي بتفشل تحت التزامن (concurrency). لو وصلك نسختين من نفس الحدث في نفس اللحظة، الاتنين هيعملوا SELECT ويلاقوه مش موجود، فالاتنين هيعالجوا. ده race condition كلاسيكي.

بدل ما تعتمد على الفحص اليدوي، خلّي الداتابيز نفسها تفرض التفرّد. عمود PRIMARY KEY على معرّف الحدث معناه إن محاولة إدخال نفس الـ id تاني هتفشل ذرّيًا (atomically) على مستوى المحرّك. ده خط الدفاع الحقيقي.

صفوف خوادم في مركز بيانات تمثّل تخزين مفاتيح الأحداث في جدول PostgreSQL بقيد فريد

ابنيها في 6 خطوات

  1. اعمل جدول لتخزين الأحداث المعالَجة. عمود واحد فريد كفاية.
  2. تحقّق من توقيع الـ webhook علشان متعالجش طلب مزوّر من حد بيتظاهر إنه Stripe.
  3. احجز الحدث بـ INSERT ... ON CONFLICT DO NOTHING وشوف عدد الصفوف المتأثرة.
  4. لو الصف اتسجّل قبل كده (عدد الصفوف = 0)، رُدّ 200 وبس.
  5. لو الحدث جديد، نفّذ شغلك الفعلي جوّا نفس الـ transaction بتاعة الإدخال.
  6. رُدّ 200 بسرعة علشان البوابة ماتعيدش الإرسال.

الجدول:

SQL
CREATE TABLE processed_events (
  event_id   TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

الـ handler في Express:

JavaScript
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }), // محتاج الـ body الخام للتوقيع
  async (req, res) => {
    // 1) تحقّق من التوقيع — يمنع الطلبات المزوّرة
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature'],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Bad signature: ${err.message}`);
    }

    const client = await db.connect();
    try {
      await client.query('BEGIN');

      // 2) احجز الحدث. القيد الفريد هو خط الدفاع الحقيقي ضد التزامن
      const { rowCount } = await client.query(
        `INSERT INTO processed_events (event_id, event_type)
         VALUES ($1, $2) ON CONFLICT DO NOTHING`,
        [event.id, event.type]
      );

      // 3) لو الحدث اتسجّل قبل كده، اتجاهله بهدوء
      if (rowCount === 0) {
        await client.query('ROLLBACK');
        return res.status(200).json({ status: 'duplicate_ignored' });
      }

      // 4) نفّذ شغلك الفعلي جوّا نفس الـ transaction
      if (event.type === 'checkout.session.completed') {
        await fulfillOrder(client, event.data.object);
      }

      await client.query('COMMIT'); // الحجز + الشغل بيتأكدوا مع بعض
      return res.status(200).json({ status: 'processed' });
    } catch (err) {
      await client.query('ROLLBACK');
      return res.status(500).send('processing failed, will retry');
    } finally {
      client.release();
    }
  }
);

الفكرة الجوهرية: لو الشغل وقع في النص، الـ ROLLBACK بيشيل حجز الحدث كمان، فالبوابة بتعيد الإرسال والمرة الجاية بيتعالج صح. الحجز والشغل بيعيشوا أو بيموتوا مع بعض.

شاشة كود تعرض دالة استقبال webhook في Node.js مع التحقق من مفتاح الـ idempotency

التحقق من إنه شغّال

استخدم Stripe CLI تبعت نفس الحدث مرتين:

Bash
# مرّر الأحداث على السيرفر المحلي
stripe listen --forward-to localhost:3000/webhooks/stripe

# في تيرمينال تاني، أطلق نفس نوع الحدث مرتين
stripe trigger checkout.session.completed
stripe trigger checkout.session.completed

المتوقّع: السطرين يردّوا 200، بس fulfillOrder تشتغل مرة واحدة. اتأكد بعدّ الصفوف: SELECT count(*) FROM processed_events المفروض يزيد بواحد لكل event id فعلي مش لكل محاولة.

الأرقام والـ trade-offs اللي لازم تعرفها

السيناريو الواقعي: متجر بيستقبل حوالي 5,000 حدث/يوم. قبل الـ idempotency، تحت ساعات الذروة كان فيه تقريبًا 1.7% من الأحداث بتتعالج مرتين بسبب إعادة الإرسال (رقم مقاس على إنتاج فعلي). بعد إضافة الجدول والقيد الفريد، نزل التكرار لصفر.

  • الثمن: كل حدث بياخد عملية INSERT زيادة. القياس عندنا كان ~0.4ms إضافية لكل طلب على PostgreSQL على سيرفر متوسط. تكلفة لا تُذكر مقابل منع محاسبة مزدوجة.
  • نمو الجدول: الجدول بيكبر مع الوقت. 5,000 صف/يوم ≈ 1.8 مليون صف/سنة. خفيف، لكن نظّفه دوريًا بحذف أي حدث أقدم من فترة إعادة المحاولة عند البوابة (مثلًا 30 يوم) عبر cron.
  • التوقيت: الافتراض هنا إن وقت معالجتك أقصر من timeout البوابة. لو fulfillOrder بتاخد 30 ثانية، البوابة هتعيد الإرسال قبل ما تخلص. الحل: ردّ 200 بسرعة وادفع الشغل التقيل لـ background queue.

متى متستخدمش الطريقة دي

لو البوابة بتبعتلك مفتاح idempotency جاهز في الـ payload، اعتمد عليه بدل ما تخترع واحد. ولو الحدث بطبيعته idempotent (زي تحديث حالة لقيمة ثابتة: "علّم الطلب كمدفوع")، تكرار التنفيذ مش هيأذي، فالجدول بيبقى تعقيد زيادة. كمان لو حجمك صغير جدًا (عشرات الأحداث في اليوم) ومفيش side effects مالية، ممكن تأجّل ده. القاعدة: استخدمه لما التكرار يكلّف فلوس أو يبعت رسائل مكررة للعميل.

الخطوة التالية

افتح أقرب webhook handler عندك دلوقتي. لو ملقتش فيه أي تحقّق من معرّف الحدث قبل التنفيذ، أضف جدول processed_events والـ INSERT ... ON CONFLICT قبل أول side effect. جرّبه بـ stripe trigger مرتين وشوف العدّاد.

المصادر

  • Stripe — Build a webhook endpoint / Best practices (إعادة المحاولة والتعامل مع التكرار): docs.stripe.com/webhooks
  • Stripe — Idempotent requests: docs.stripe.com/api/idempotent_requests
  • PostgreSQL — INSERT ... ON CONFLICT (UPSERT): postgresql.org/docs/current/sql-insert.html
  • MDN — Idempotent (تعريف HTTP): developer.mozilla.org/en-US/docs/Glossary/Idempotent
  • Express — express.raw() لقراءة الـ body الخام: expressjs.com/en/api.html

هل استفدت من المقال؟

اطّلع على المزيد من المقالات والدروس المجانية من نفس المسار المعرفي.

تصفّح المدونة