الاستجابة للأحداث باستخدام webhooks
اضبط عنوان URL الخاص بـ webhooks، واجتز تحدي التحقق، وتحقق من كل توقيع، وأكّد الاستلام خلال 5 ثوانٍ، واسترجع الأحداث الفائتة بالاستقصاء الدوري.
ترسل webhooks طلب HTTP POST إلى خادمك عندما يتغير شيء في حسابك: يصل تقييم، أو يصبح إدراج نشاط تجاري متاحًا للعامة، أو يُنشر منشور. يوجد عنوان URL واحد لكل حساب، ولا توجد اشتراكات مخصصة لكل حدث، لذا يتلقى مستقبِل واحد جميع الأحداث الموثقة البالغ عددها 18 حدثًا، ويحدد مسار المعالجة بناءً على الحقل event.
1. اضبط عنوان URL والسر في لوحة التحكم
أنشئ أولًا المستقبِل الموضح في الخطوتين 2 و3. يؤدي إنشاء سر إلى جعل التوقيع إلزاميًا وإيقاف التسليم مؤقتًا حتى تجتاز نقطة النهاية التحقق، لذا فإن نقطة النهاية غير الجاهزة تمنع تسليم جميع الأحداث.
- انشر نقطة نهاية HTTPS. تُرفض العناوين الخاصة، ولا تُتبع عمليات إعادة التوجيه، لذا احفظ عنوان URL النهائي دون أن يسبقه
wwwأو إعادة توجيه لإضافة شرطة مائلة في نهايته. 2. احفظ عنوان URL في صفحة Webhooks في لوحة التحكم. 3. أنشئ سر توقيع. يُعرض مرة واحدة. خزّنه باسمLISTINGSAPI_WEBHOOK_SECRETعلى خادمك. 4. انقر على التحقق من نقطة النهاية. ترسل المنصة التحدي الموضح في الخطوة 2 وتنتظر لمدة تصل إلى 10 ثوانٍ.
يؤدي تغيير عنوان URL أو إعادة إنشاء السر إلى إعادة تعيين حالة التحقق، لذا انشر السر الجديد أولًا، ثم أعد التحقق. تتوفر التفاصيل في نظرة عامة على webhooks.
2. أجب عن تحدي التحقق
التحدي هو POST موقّع يتضمن اسم حدث محجوزًا وnonce. أرسل ردًا بالحالة 200 يتضمن قيمة HMAC-SHA256 المحسوبة لقيمة nonce وحدها، بترميز سداسي عشري بأحرف صغيرة.
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}3. تحقق من التوقيع عند كل عملية تسليم
يحمل كل طلب موقّع X-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>. احسب البصمة نفسها باستخدام البايتات التي استلمتها كما هي تمامًا، وقارن بزمن ثابت، وأرجع 401 عند عدم التطابق. لا تحلل JSON ثم تعيد تسلسله أولًا؛ فهذا يغيّر البايتات. يحتفظ كلا المستقبِلين أدناه بجسم الطلب الخام، ويتحققان من التوقيع، ويجيبان عن التحدي، ويخزّنان الحدث تخزينًا دائمًا قبل الرد.
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode() def signature_is_valid(raw_body: bytes, header: str | None) -> bool: digest = hmac.new(SECRET, raw_body, hashlib.sha256).digest() expected = "sha256=" + base64.b64encode(digest).decode() return hmac.compare_digest(header or "", expected) @app.post("/webhooks/listingsapi")def webhooks(): raw = request.get_data() # raw bytes, exactly as received if not signature_is_valid(raw, request.headers.get("X-ListingsAPI-Signature")): abort(401) body = request.get_json(silent=True) or {} # One-time handshake: hex digest of the nonce, not the body. if body.get("event") == "endpoint.verification": return hmac.new(SECRET, body["nonce"].encode(), hashlib.sha256).hexdigest(), 200 # Real event: durable write first, then acknowledge. Swap for your queue. inbox.put(raw) # must be committed before the 200 goes out return {"received": True}, 200import crypto from 'crypto';import express from 'express'; const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET as string; // express.raw keeps req.body as the untouched Buffer the HMAC covers.app.post('/webhooks/listingsapi', express.raw({ type: '*/*' }), async (req, res) => {const raw: Buffer = req.body;const digest = crypto.createHmac('sha256', SECRET).update(raw).digest('base64');const expected = Buffer.from('sha256=' + digest);const given = Buffer.from(req.get('X-ListingsAPI-Signature') ?? '');if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) { return res.status(401).send('bad signature');} const body = JSON.parse(raw.toString('utf8')); // One-time handshake: hex digest of the nonce, not the body.if (body.event === 'endpoint.verification') { const answer = crypto.createHmac('sha256', SECRET).update(body.nonce).digest('hex'); return res.status(200).send(answer);} // Real event: durable write first, then acknowledge. Swap for your queue.await inbox.put(raw); // must be committed before the 200 goes outreturn res.status(200).json({ received: true });});إذا لم يسبق لحساب أن أنشأ سرًا، فستصل طلبات التسليم دون توقيع. لا تبنِ تكاملك على هذا النمط؛ أنشئ السر بمجرد تشغيل المستقبِل الموضح أعلاه.
4. أكّد الاستلام بسرعة وعالج الأحداث بصورة غير متزامنة
تتكون عملية التسليم من POST واحد بمهلة اتصال قدرها 5 ثوانٍ ومهلة قراءة قدرها 5 ثوانٍ، وتُجرى محاولتها مرة واحدة بالضبط. لا توجد إعادة محاولة، ولا تأخير تدريجي بين المحاولات، ولا إعادة إرسال. إذا استغرق معالج الطلبات لديك 6 ثوانٍ، أو توقف خادمك عن العمل لمدة دقيقتين، فستفقد تلك الأحداث. لا يُعدّ التسليم ناجحًا إلا عند إرجاع 2xx؛ أما 3xx و4xx و5xx فتُعدّ حالات فشل.
لذلك ينفّذ مسار معالجة الطلب 3 أمور فقط: التحقق، وكتابة جسم الطلب الخام في قائمة انتظار أو جدول وانتظار تثبيت الكتابة، وإرجاع 200. تُنفّذ عمليات إثراء البيانات والاستدعاءات اللاحقة إلى REST API وأي عمليات تتعامل مع طرف ثالث انطلاقًا من قائمة الانتظار. تتشارك جميع الأحداث، باستثناء listing.submission، بنية حمولة واحدة:
{ "event": "connection.location_connected", "timestamp": "2026-08-25T14:32:10Z", "account_id": 11073, "location_id": "279381", "data": { "platform": "google", "connected_account_id": "ba03bc4b-9f8c-4d3b-8e93-9628034c63cc" }}location_id سلسلة نصية رقمية، باستثناء أحداث interaction.* التي تُمرَّر فيها قيمته دون توحيد صيغتها، وقد تكون null. لا يحتوي listing.submission على timestamp أو account_id، ويضع status وlive_link في المستوى الأعلى. تُحذف الحقول الاختيارية في data بدلًا من إرسالها بقيمة null. أرجع 200 لأي event لا تتعرف عليه؛ فمجموعات الأحداث غير الموثقة تستخدم عنوان URL نفسه.
5. أزل التكرارات وتعامل مع التسليم بغير ترتيب
قد تتلقى الحدث نفسه مرتين، وستتلقى id المورد نفسه مرات عديدة: يُطلَق interaction.review مجددًا مع id نفسه للتفاعل في كل مرة يتغير فيها التقييم، ولا تتضمن أي حمولة رقم إصدار. لذا لا تتجاهل حدثًا أبدًا بناءً على الجمع بين id واسم الحدث، وإلا فستفقد تعديلات فعلية. اكتشف التكرارات المطابقة تمامًا بحساب تجزئة جسم الطلب الخام وتجاهل أي تكرار خلال فترة زمنية قصيرة، ثم استخدم id المورد مفتاحًا لعملية الكتابة حتى ينتج عن التكرار النتيجة نفسها.
import hashlibimport time SEEN: dict[str, float] = {} # use Redis with a TTL in productionWINDOW_SECONDS = 600 def is_duplicate(raw_body: bytes) -> bool: key = hashlib.sha256(raw_body).hexdigest() now = time.time() for k, seen_at in list(SEEN.items()): if now - seen_at > WINDOW_SECONDS: del SEEN[k] if key in SEEN: return True SEEN[key] = now return Falseالترتيب غير مضمون أيضًا. تأتي الأحداث من مسارات معالجة مستقلة، لذا قد يصل local_post.published قبل local_post.created، ويُسجَّل الطابع الزمني timestamp عند إعداد التسليم، وليس عند حدوث التغيير. عندما يكون الترتيب مهمًا، أعد قراءة المورد من REST API واعتمد على نتيجة تلك القراءة. كذلك، يُقيَّد profile.updated بحدث واحد لكل موقع كل 60 ثانية، لذا لا تُظهر إعادة القراءة عند وصول الحدث إلا التعديل الأول؛ أعد القراءة بعد انتهاء هذه الفترة.
6. اربط الأحداث بالأدلة الأخرى
فرّع التنفيذ بناءً على event، ومرّر كل حدث إلى الشيفرة التي كتبتها للدليل المطابق. يحدد webhook متى حدث التغيير؛ وتحدد نقطة النهاية في ذلك الدليل ما الذي تغيّر.
توجد جداول الحقول وعينة حمولة لكل حدث في مرجع الأحداث.
7. تدارك حدث فائت باستخدام الاستقصاء الدوري
نظرًا إلى عدم وجود إعادة محاولة، تحتاج كل مجموعة إلى عملية قراءة موثوقة يمكنك الرجوع إليها. طابق البيانات وفق جدول زمني، وليس فقط عندما يبدو أن هناك خطأ، ولا تكتب أبدًا قيمة صفر أو «لا تغيير» لمجرد عدم وصول حدث.
تسجل لوحة التحكم كل محاولة مع حالتها. تعني success أن التسليم نجح؛ ويعني رمز HTTP أو timeout أو network_error أو error أن الطلب وصل إلى نقطة النهاية لديك وفشلت المحاولة؛ وتعني blocked_url وendpoint_unverified وrate_limited أنه لم يُرسَل أي طلب على الإطلاق، لذا يكون الإصلاح في الإعدادات أو الخطة، وليس في الشيفرة. توجد القائمة الكاملة في سلوك التسليم.
الخطوات التالية
- جلب التقييمات: أول مجموعة وظائف تنفذها معظم عمليات التكامل، مع استخدام المسح التجميعي كآلية احتياطية لها.
- التحقق من حالة إدراج النشاط التجاري والروابط المنشورة: ما يعلنه
listing.submission. - المراجع: نظرة عامة على Webhooks، التوقيعات والتحقق، سلوك التسليم، مرجع الأحداث.