شرح Webhooks: الحمولات الموقعة وإعادة المحاولة الموثوقة

Backend2026-11-05TryQuickToolBox

يعمل التكامل الخاص بك في بيئة الاختبار (staging): يطلق مزود الدفع حدثًا، وتستقبله نقطة النهاية الخاصة بك، وتتحول حالة الطلب إلى paid. ثم في بيئة الإنتاج، يدفع العميل مرتين، ويختفي تسليم بصمت أثناء عملية نشر، ويعيد شخص ما إرسال طلب تم التقاطه لتعليم الطلب كمرتجع. كل هذه الإخفاقات الثلاثة تشترك في نفس السبب الجذري: تم التعامل مع webhook كمجرد طلب HTTP POST بسيط بدلاً من قناة تسليم حساسة أمنيًا وذات تسليم مرة واحدة على الأقل.

يغطي هذا الدليل نصفين من تنفيذ webhook الصحيح — التحقق من أن الحمولة أصلية، وجعل التسليم يصمد أمام إعادة المحاولة — مع كود يمكنك تكييفه مع أي تقنية.

ما هو webhook حقًا

webhook هو طلب HTTP من خادم إلى خادم يرسله نظام إلى آخر عند حدوث حدث. إنه يعكس نموذج الاستقصاء المعتاد: بدلاً من أن يسأل تطبيقك "هل هناك أي جديد؟" كل دقيقة، يدفع المنتج الحدث إلى عنوان URL قمت بتسجيله.

خاصيتان تنتجان عن هذا التصميم، وكلاهما مهم:

كل ما يلي هو نتيجة لهاتين الحقيقتين.

توقيع الحمولات باستخدام HMAC

النهج القياسي هو سر مشترك بالإضافة إلى HMAC على جسم الطلب الخام. يحسب المرسل توقيعًا ويضعه في رأس (header)؛ تقوم أنت بإعادة حسابه ومقارنته. إذا تطابقا، فإن الحمولة جاءت من شخص يعرف السر ولم يتم تعديلها أثناء النقل.

ثلاثة تفاصيل تفصل التنفيذ الصحيح عن المعطل:

  1. وقّع البايتات الخام. قم بتحليل JSON فقط بعد التحقق. إعادة تسلسل الجسم يغير المسافات وترتيب المفاتيح، ولن يتطابق التوقيع أبدًا.
  2. استخدم مقارنة ثابتة الزمن. == الساذج يكشف معلومات التوقيت. استخدم المقارنة الآمنة المدمجة في اللغة.
  3. ضمّن طابعًا زمنيًا في السلسلة الموقعة. توقيع timestamp + "." + body يسمح لك برفض الطلبات القديمة، مما يمنع هجمات إعادة التشغيل.

التحقق من التوقيع في Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const signature = headers['x-signature'];   // e.g. "t=1712345678,v1=abc123..."
  const timestamp = headers['x-timestamp'];
  if (!signature || !timestamp) return false;

  // 1. Reject stale requests (replay protection)
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || age > toleranceSeconds) return false;

  // 2. Recompute over the exact bytes received
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 3. Constant-time compare
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

في Express، يتطلب هذا express.raw({ type: 'application/json' }) على مسار webhook حتى يصل الجسم كـ Buffer. إذا تم تشغيل وسيط express.json() العام أولاً، فإن الجسم الخام يختفي وسيفشل التحقق — وهو خطأ شائع جدًا.

التحقق في Python

import hmac, hashlib, time

def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

دائمًا قم بإرجاع 401 عند فشل التحقق وسجّل معرف الطلب — ولكن لا تسجل أبدًا السر أو التوقيع الكامل.

تصميم إعادة المحاولة التي تعمل فعلاً

إعادة المحاولة مفيدة فقط إذا كانت نقطة النهاية الخاصة بك تتصرف بشكل متوقع. العقد بسيط: أعد 2xx بسرعة بمجرد قبول الحدث بشكل دائم، واترك كل شيء آخر لإعادة المحاولة.

استجابتكسلوك المرسلما يجب فعله بدلاً من ذلك
200 / 204تسليم مُعلَّمصحيح — أقرّ بعد الإدراج في قائمة الانتظار
400 / 401عادةً لا إعادة محاولةأصلح التوقيع أو معالجة الحمولة
429 / 503إعادة محاولة مع تراجعأعد عندما تكون محملاً بشكل زائد، وليس 500 عام
500 / مهلةإعادة محاولةانقل العمل البطيء إلى قائمة انتظار

قاعدتان تحافظان على صحة هذا:

اجعل المعالجات متكررة (idempotent)

نظرًا لأن التسليم مرة واحدة على الأقل، يمكن أن يصل نفس معرف الحدث مرتين — مرة بعد مهلة حيث نجح المعالج الخاص بك فعلاً. قم بتخزين معرفات الأحداث المعالجة وتخطي التكرارات:

INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- If 0 rows inserted, this is a duplicate: return 200 and stop.

حيثما أمكن، اجعل العملية التجارية نفسها متكررة — عيّن حالة الطلب إلى paid بدلاً من زيادة عداد، واستخدم معرف الحدث كمفتاح تكرارية عند استدعاء الخدمات اللاحقة.

قائمة تحقق قبل الإطلاق

  1. تحقق من HMAC على البايتات الخام باستخدام مقارنة ثابتة الزمن.
  2. ارفض الطوابع الزمنية الأقدم من بضع دقائق لإيقاف إعادة التشغيل.
  3. احفظ الحدث، ثم أعد 2xx في غضون ثانية أو ثانيتين.
  4. أزل التكرار بناءً على معرف الحدث قبل القيام بأي عمل.
  5. سجّل معرف الحدث والنوع والنتيجة — وليس السر أبدًا.
  6. اعرض طريقة لإعادة تشغيل الأحداث يدويًا من لوحة التحكم أو أداة الإدارة.
  7. اختبر بتوقيعات معطلة عمدًا، وتسليمات مكررة، ومعالجات بطيئة.

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

الأسئلة الشائعة

لماذا يفشل التحقق من التوقيع رغم أن الحمولة تبدو متطابقة؟

دائمًا تقريبًا لأن الجسم تم تحليله وإعادة تسلسله قبل التجزئة. يتغير ترتيب مفاتيح JSON والمسافات وتشفير unicode أثناء التحليل/التسلسل، لذا لم تعد البايتات تطابق ما وقعه المرسل. التقط الجسم الخام وقم بتجزئته.

هل يجب أن أعيد 200 قبل أو بعد معالجة الحدث؟

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

هل HTTPS كافٍ لتأمين نقطة نهاية webhook؟

لا. يحمي TLS البيانات أثناء النقل ولكنه لا يثبت من أرسل الطلب — يمكن لأي شخص إرسال POST إلى عنوان URL عام. توقيع HMAC بسر مشترك يصادق على المرسل، والطابع الزمني الموقّع يمنع إعادة تشغيل الطلبات الملتقطة.

الخلاصة

تعامل مع webhooks مثل أي إدخال غير موثوق: تحقق من التوقيع على البايتات الخام، وافحص الطابع الزمني، وأزل التكرار بواسطة معرف الحدث، وأقرّ فقط بعد تخزين الحدث بأمان. افعل ذلك، وستتوقف إعادة المحاولة عن كونها مصدرًا لرسوم مكررة وتصبح ما كان المقصود منها — شبكة أمان.