شرح Webhooks: الحمولات الموقعة وإعادة المحاولة الموثوقة
يعمل التكامل الخاص بك في بيئة الاختبار (staging): يطلق مزود الدفع حدثًا، وتستقبله نقطة النهاية الخاصة بك، وتتحول حالة الطلب إلى paid. ثم في بيئة الإنتاج، يدفع العميل مرتين، ويختفي تسليم بصمت أثناء عملية نشر، ويعيد شخص ما إرسال طلب تم التقاطه لتعليم الطلب كمرتجع. كل هذه الإخفاقات الثلاثة تشترك في نفس السبب الجذري: تم التعامل مع webhook كمجرد طلب HTTP POST بسيط بدلاً من قناة تسليم حساسة أمنيًا وذات تسليم مرة واحدة على الأقل.
يغطي هذا الدليل نصفين من تنفيذ webhook الصحيح — التحقق من أن الحمولة أصلية، وجعل التسليم يصمد أمام إعادة المحاولة — مع كود يمكنك تكييفه مع أي تقنية.
ما هو webhook حقًا
webhook هو طلب HTTP من خادم إلى خادم يرسله نظام إلى آخر عند حدوث حدث. إنه يعكس نموذج الاستقصاء المعتاد: بدلاً من أن يسأل تطبيقك "هل هناك أي جديد؟" كل دقيقة، يدفع المنتج الحدث إلى عنوان URL قمت بتسجيله.
خاصيتان تنتجان عن هذا التصميم، وكلاهما مهم:
- نقطة النهاية الخاصة بك عامة. يمكن لأي شخص على الإنترنت إرسال POST إليها، لذا يجب مصادقة كل طلب.
- التسليم مرة واحدة على الأقل. تفشل الشبكات، وتُعاد تشغيل الخوادم، والمهلات الزمنية غامضة — لا يمكن للمرسل معرفة ما إذا كان المعالج الخاص بك قد عمل. لذا يعيد المحاولة، وقد ترى نفس الحدث أكثر من مرة.
كل ما يلي هو نتيجة لهاتين الحقيقتين.
توقيع الحمولات باستخدام HMAC
النهج القياسي هو سر مشترك بالإضافة إلى HMAC على جسم الطلب الخام. يحسب المرسل توقيعًا ويضعه في رأس (header)؛ تقوم أنت بإعادة حسابه ومقارنته. إذا تطابقا، فإن الحمولة جاءت من شخص يعرف السر ولم يتم تعديلها أثناء النقل.
ثلاثة تفاصيل تفصل التنفيذ الصحيح عن المعطل:
- وقّع البايتات الخام. قم بتحليل JSON فقط بعد التحقق. إعادة تسلسل الجسم يغير المسافات وترتيب المفاتيح، ولن يتطابق التوقيع أبدًا.
- استخدم مقارنة ثابتة الزمن.
==الساذج يكشف معلومات التوقيت. استخدم المقارنة الآمنة المدمجة في اللغة. - ضمّن طابعًا زمنيًا في السلسلة الموقعة. توقيع
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 / مهلة | إعادة محاولة | انقل العمل البطيء إلى قائمة انتظار |
قاعدتان تحافظان على صحة هذا:
- أقرّ بسرعة. تحقق، واحفظ الحدث في قائمة انتظار أو جدول، وأعد
200. قم بالعمل الحقيقي بشكل غير متزامن. المعالج الذي يستدعي ثلاث واجهات برمجة تطبيقات قبل الرد سينتهي وقته ويؤدي إلى تسليمات مكررة. - استخدم تراجعًا أسيًا مع تشويش (jitter). إذا أعاد المرسل المحاولة فورًا، يتحول انقطاع قصير إلى قطيع مدوٍ. جدول مثل 10 ثوانٍ، 30 ثانية، دقيقتين، 10 دقائق، ساعة مع تشويش عشوائي يوزع الحمل.
اجعل المعالجات متكررة (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 بدلاً من زيادة عداد، واستخدم معرف الحدث كمفتاح تكرارية عند استدعاء الخدمات اللاحقة.
قائمة تحقق قبل الإطلاق
- تحقق من HMAC على البايتات الخام باستخدام مقارنة ثابتة الزمن.
- ارفض الطوابع الزمنية الأقدم من بضع دقائق لإيقاف إعادة التشغيل.
- احفظ الحدث، ثم أعد
2xxفي غضون ثانية أو ثانيتين. - أزل التكرار بناءً على معرف الحدث قبل القيام بأي عمل.
- سجّل معرف الحدث والنوع والنتيجة — وليس السر أبدًا.
- اعرض طريقة لإعادة تشغيل الأحداث يدويًا من لوحة التحكم أو أداة الإدارة.
- اختبر بتوقيعات معطلة عمدًا، وتسليمات مكررة، ومعالجات بطيئة.
عند تصحيح تكامل فاشل، فإن جسم الطلب الخام هو الشيء الذي ستحدق فيه كثيرًا. لصقه في منسق JSON يجعل كائنات الأحداث المتداخلة قابلة للقراءة دون تغيير البايتات التي التقطتها بالفعل.
الأسئلة الشائعة
لماذا يفشل التحقق من التوقيع رغم أن الحمولة تبدو متطابقة؟
دائمًا تقريبًا لأن الجسم تم تحليله وإعادة تسلسله قبل التجزئة. يتغير ترتيب مفاتيح JSON والمسافات وتشفير unicode أثناء التحليل/التسلسل، لذا لم تعد البايتات تطابق ما وقعه المرسل. التقط الجسم الخام وقم بتجزئته.
هل يجب أن أعيد 200 قبل أو بعد معالجة الحدث؟
بعد تخزينه بشكل دائم، وقبل القيام بالعمل الثقيل. إذا قمت بالمعالجة أولاً، فإن استدعاءات الخدمات اللاحقة البطيئة تسبب مهلًا، ويعيد المرسل المحاولة، وتحصل على تكرارات. ضع الحدث في قائمة انتظار، واستجب فورًا، وعالجه بشكل غير متزامن.
هل HTTPS كافٍ لتأمين نقطة نهاية webhook؟
لا. يحمي TLS البيانات أثناء النقل ولكنه لا يثبت من أرسل الطلب — يمكن لأي شخص إرسال POST إلى عنوان URL عام. توقيع HMAC بسر مشترك يصادق على المرسل، والطابع الزمني الموقّع يمنع إعادة تشغيل الطلبات الملتقطة.
الخلاصة
تعامل مع webhooks مثل أي إدخال غير موثوق: تحقق من التوقيع على البايتات الخام، وافحص الطابع الزمني، وأزل التكرار بواسطة معرف الحدث، وأقرّ فقط بعد تخزين الحدث بأمان. افعل ذلك، وستتوقف إعادة المحاولة عن كونها مصدرًا لرسوم مكررة وتصبح ما كان المقصود منها — شبكة أمان.