التوقيعات والتحقق

تحقّق من ترويسة X-ListingsAPI-Signature، وأجب عن تحدي التحقق من نقطة النهاية، وتجنّب الخلط بين base64 وhex.

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

تستخدم آليتان منفصلتان هذا السر، ومن السهل الخلط بينهما:

الغرضالاتجاهالترميز
توقيع الطلبإثبات صحة كل عملية تسليميُرسل إليك في ترويسةbase64
تحدي التحققإثبات امتلاكك للسر مرة واحدةترسله ردًا في المتنhex

ترويسة التوقيع

تتضمن كل عملية تسليم موقّعة ما يلي:

Text
X-ListingsAPI-Signature: sha256=<digest>   digest = base64( HMAC-SHA256(secret, raw_request_body) )

البادئة sha256= جزء من قيمة الترويسة. أدرجها عند بناء السلسلة المتوقعة، أو قارن الجزء الذي يلي = فقط. لا يُضاف شيء إلى بداية المتن قبل حساب التجزئة: لا طابع زمني، ولا إصدار، ولا سلسلة توقيع مكوّنة من أجزاء موصولة. يشمل HMAC المتن الخام وحده دون أي شيء آخر.

التحقق خطوة بخطوة

  1. اقرأ بايتات متن الطلب الخام كما وردت تمامًا. لا تحلّل JSON ثم تعِد تسلسله. يؤثر ترتيب المفاتيح والمسافات البيضاء واستخدام تسلسلات الإفلات لمحارف Unicode في البايتات، وأي تغيير يجعل HMAC غير صالح. تتطلب معظم أطر العمل إعدادًا للاحتفاظ بالمتن الخام. راجع الأمثلة أدناه.
  2. احسب HMAC-SHA256(your_secret, raw_body)، ورمّزه باستخدام base64، ثم أضف البادئة sha256=.
  3. قارن الناتج بالترويسة باستخدام مقارنة بزمن ثابت (crypto.timingSafeEqual، hmac.compare_digest). يؤدي استخدام == العادي إلى تسريب معلومات زمنية عن عدد البايتات المتطابقة في البداية.
  4. عند عدم التطابق، أعِد 401 ولا تعالج أي شيء.
التحقق من جهة المستقبِل. يعتمد كل شيء على الخطوة الثانية: يُحسب HMAC من البايتات التي استلمتها، وليس من أي شيء أعدت ترميزه.

التحقق من نقطة النهاية

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

تبدأ التحقق من صفحة Webhooks في لوحة التحكم. ترسل المنصة تحديًا موقّعًا وتنتظر الإجابة لمدة تصل إلى 10 ثوانٍ. (تنطبق مهلة 10 ثوانٍ على المصافحة فقط؛ أما عمليات التسليم الفعلية فتنتهي مهلتها بعد 5 ثوانٍ.)

المصافحة. لاحظ الاختلاف بين الطرفين: يُوقّع التحدي باستخدام base64 في ترويسة، وتكون إجابتك خلاصة بترميز hex في متن الاستجابة.

متطلبات التحدي

التحدي طلب موقَّع عادي، يحمل اسم حدث محجوزًا:

JSON
{  "event": "endpoint.verification",  "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c",  "timestamp": "2026-08-25T14:32:10Z"}

يجب على نقطة النهاية:

  1. التحقق من X-ListingsAPI-Signature كما تفعل مع أي عملية تسليم.
  2. التعرّف على event == "endpoint.verification" والانتقال إلى مسار معالجة خاص به قبل المعالجة المعتادة للأحداث.
  3. حساب HMAC-SHA256(secret, nonce) كسلسلة ست عشرية بأحرف صغيرة. يُحسب HMAC باستخدام nonce وحده، وليس جسم الطلب.
  4. إرجاع 200 مع تلك السلسلة الست عشرية في جسم الاستجابة. يُتوقَّع إرجاع سلسلة ست عشرية مجردة؛ وتُقبل أيضًا قيمة ست عشرية محاطة بعلامتي اقتباس أو ممثلة كسلسلة نصية في JSON.

أي نتيجة أخرى (رمز حالة خارج نطاق 2xx، أو قيمة تجزئة خاطئة، أو انتهاء المهلة، أو تعذّر الوصول إلى المضيف) تؤدي إلى فشل التحقق.

ما الذي يُلغي حالة التحقق

التحقق ليس دائمًا. يعود الحساب إلى حالة غير متحقق منه، وتتوقف عمليات التسليم مؤقتًا، كلما حدث ما يلي:

  • تُغيِّر عنوان URL الخاص بالـ webhooks، أو تُعيد توليد السر. لم يعد الإثبات القديم صالحًا للإعدادات الجديدة.
  • تفشل محاولة تحقق: ويشمل ذلك نقطة نهاية كانت تعمل ثم تعذّر حل اسم مضيفها أو توقفت عن الاستجابة. لذلك، تؤدي إعادة محاولة التحقق من نقطة نهاية معطلة إلى إيقاف عمليات التسليم؛ وهذا مقصود.

أعِد التحقق بمجرد أن تعمل نقطة النهاية بصورة سليمة لاستئناف عمليات التسليم.

نماذج مستقبِلات تعمل

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

Node.js (Express)
const crypto = require('crypto');const express = require('express'); const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET; // Keep the raw bytes. express.json() would otherwise leave you// with only the parsed object, and re-serializing it changes the// bytes the HMAC covers.app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),); function signatureIsValid(rawBody, header) {const digest = crypto  .createHmac('sha256', SECRET)  .update(rawBody)  .digest('base64');const expected = 'sha256=' + digest; const a = Buffer.from(header || '', 'utf8');const b = Buffer.from(expected, 'utf8');// timingSafeEqual throws on a length mismatch, so check first.return a.length === b.length && crypto.timingSafeEqual(a, b);} // The only work allowed before the 200: put the raw bytes somewhere durable// and wait for the write to be acknowledged. Swap the INSERT for your real// queue or table write; it must be committed before you respond, because// nothing is retried if your process dies after the 200 goes out.async function storeForProcessing(rawBody, signature) {await db.query(  'INSERT INTO webhook_inbox (signature, body, received_at) VALUES ($1, $2, now())',  [signature, rawBody.toString('utf8')],);} app.post('/webhooks/listingsapi', async (req, res) => {const header = req.get('X-ListingsAPI-Signature');if (!signatureIsValid(req.rawBody, header)) {  return res.status(401).send('bad signature');} const body = req.body; // The verification handshake: answer with HEX, not base64.if (body.event === 'endpoint.verification') {  const answer = crypto    .createHmac('sha256', SECRET)    .update(body.nonce)    .digest('hex');  return res.status(200).send(answer);} // A real event: durable write first, acknowledge second.try {  await storeForProcessing(req.rawBody, header);} catch (err) {  // Nothing was persisted, so do not claim success.  return res.status(500).send('could not persist');}return res.status(200).json({ received: true });});
Python (Flask)
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode() HEADER = "X-ListingsAPI-Signature"  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)  def store_for_processing(raw_body: bytes, signature: str) -> None:  """The only work allowed before the 200: put the raw bytes somewhere  durable and return only once the write is committed. Swap the INSERT for  your real queue or table write - nothing is retried if the process dies  after the response goes out."""  with db.begin() as tx:      tx.execute(          "INSERT INTO webhook_inbox (signature, body) VALUES (%s, %s)",          (signature, raw_body),      )  @app.post("/webhooks/listingsapi")def webhooks():  raw = request.get_data()  # raw bytes, exactly as received  header = request.headers.get(HEADER)  if not signature_is_valid(raw, header):      abort(401)   body = request.get_json(silent=True) or {}   # The verification handshake: answer with HEX, not base64.  if body.get("event") == "endpoint.verification":      nonce = body["nonce"].encode()      return hmac.new(SECRET, nonce, hashlib.sha256).hexdigest(), 200   # A real event: durable write first, acknowledge second.  try:      store_for_processing(raw, header or "")  except Exception:      # Nothing was persisted, so do not claim success.      return {"error": "could not persist"}, 500  return {"received": True}, 200

متجهات الاختبار

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

السر (وهمي بوضوح، للاختبارات فقط):

Text
whsec_test_0123456789abcdef0123456789abcdef

جسم الطلب الخام، بهذه البايتات بالضبط:

Text
{"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"}}

توقيع الطلب المتوقع، بترميز base64، مع البادئة sha256=:

Text
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=

لتحدي التحقق، باستخدام السر نفسه وقيمة nonce التالية:

Text
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c

يجب على نقطة النهاية إرجاع 200 مع جسم الاستجابة التالي بالضبط، وهو سلسلة ست عشرية بأحرف صغيرة:

Text
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145

لإعادة إنتاج النتيجتين، احفظ سطر الجسم أعلاه في body.json من دون محرف سطر جديد في النهاية (printf '%s' '...' > body.json)، ثم:

Python
import base64, hashlib, hmac secret = b"whsec_test_0123456789abcdef0123456789abcdef"body = open("body.json", "rb").read()          # the 209 bytes abovenonce = b"9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c" # request signature: base64, over the raw bodyprint("sha256=" + base64.b64encode(hmac.new(secret, body, hashlib.sha256).digest()).decode())# challenge answer: lowercase hex, over the nonce aloneprint(hmac.new(secret, nonce, hashlib.sha256).hexdigest())

إذا لم يطابق السطر الأول قيمة الترويسة أعلاه، فالخطأ في البايتات لديك، وليس في حساب HMAC. تحقّق من طول الجسم أولًا.

التالي