التسليم والتواقيع والمستقبِلات
التحقق من التوقيع، ومعالجة الإعادات والتكرار، وضمانات الترتيب، وتنفيذات كاملة للمستقبِل.
آخر مراجعة: 2026-08-23
التحقق من التوقيع
كل تسليم يحمل X-Cliento-Signature: sha256=<hex> - وهو HMAC-SHA256 على جسم الطلب الخام، بمفتاح سرّ الاشتراك. تحقّق منه قبل التحليل، وارفض كل ما يفشل.
- اقرأ الجسم الخام بايتات.
- احسب
HMAC-SHA256(secret, body)ورمّزه بالنظام الستعشري. - قارِنه بما بعد
sha256=في الترويسة، بمقارنة ثابتة الزمن. - عند عدم التطابق أعد 401 ولا تعالج شيئًا.
دوِّر السر عبر POST /webhooks/{subscription_id}/rotate-secret عند الاشتباه بتسريبه. يسري السر الجديد فورًا، فإن كنت لا تحتمل رفض بضعة تسليمات فانشر القيمة الجديدة على مستقبِلك أولًا - وستُعاد تلك التسليمات على أي حال.
الإعادات والمهل
التسليمات تُصفّ ولا تُرسل أبدًا بالتزامن مع الطلب الذي سبّبها - فلا تستطيع نقطتك البطيئة أن تُبطئ نظام مساحة العمل نفسه. يلتقط عاملٌ التسليماتِ المستحقّة ويرسلها ويعيد جدولتها عند الإخفاق.
| السلوك | القيمة |
|---|---|
| مهلة المقبس | 10 ثوانٍ |
| التراجع بعد المحاولة 1، 2، 3، 4، 5 | 30 ث، دقيقتان، 10 دقائق، 30 دقيقة، ساعة |
| المحاولات قبل التخلّي | max_attempts على الاشتراك (الافتراضي 5) |
| يُحتسب نجاحًا | أي 2xx |
| يُحتسب إخفاقًا | أي حالة أخرى، أو إعادة توجيه، أو انتهاء مهلة، أو خطأ اتصال |
بعد المحاولة الأخيرة يُوسم التسليم exhausted ولا يُعاد. وGET /webhooks/{subscription_id}/deliveries?status=exhausted هو المكان الذي تبحث فيه عمّا فاتك؛ فالصف يحتفظ برمز الحالة وجسم الرد اللذين أعادتهما نقطتك، وهذا يكفي عادة لمعرفة السبب.
curl -sS "$CLIENTO_HOST/api/v1/webhooks/7/deliveries?status=exhausted" \
-H "Authorization: Bearer $CLIENTO_TOKEN"دلالات التسليم
ثلاث خصائص صمِّم على أساسها. ولا واحدة منها عيب؛ بل كلها نتيجة ألّا يضيع أي تسليم.
- مرة واحدة على الأقل، لا مرة واحدة بالضبط. النقطة التي تعالج الحمولة ثم تنتهي مهلتها في طريق العودة سترى التسليم نفسه ثانية. وكذلك التي مات عاملها في منتصف الإرسال، بمجرد انتهاء عقد الحجز.
- لا ضمان للترتيب. تُعاد التسليمات مستقلة، فقد يصل
ContactUpdatedقبلContactCreatedالذي تلاه. رتِّب حسبpayload.occurred_atإن كان التسلسل يهمّك، وعامِل تحديثًا لسجل لم تره كأمر بجلبه. - الحمولة لقطة لحدث لا لسجل. فقد يكون السجل تغيّر ثانية حين تعالجها. اجلب السجل عبر الـ API متى احتجت حالته الحالية.
معالجة التكرار
delivery_id هو مفتاح إزالة التكرار، وهو ثابت عبر إعادات التسليم نفسه. سجّله قبل أن تتصرّف وتجاوز كل ما رأيته سابقًا.
# 1. verify the signature
# 2. INSERT delivery_id into a table with a UNIQUE constraint
# -> conflict means "already handled", so return 200 and stop
# 3. enqueue the work
# 4. return 200
# Doing (2) before (3) is what makes the whole thing idempotent: the
# uniqueness constraint is the lock, so two concurrent deliveries of the
# same id produce exactly one unit of work.إن تعذّر عليك تخزين معرّفات التسليم، فاجعل *الأثر* نفسه متماثلًا - فعملية upsert بمفتاح payload.entity_type + entity_id تعطي النتيجة نفسها سواء نُفِّذت مرة أو خمسًا.
المستقبِل: Node / Express
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.CLIENTO_WEBHOOK_SECRET!;
// The raw body is required for signature verification, so JSON parsing is
// deliberately NOT applied to this route - `express.json()` would hand us a
// re-serialized object whose bytes no longer match what was signed.
app.post(
"/cliento",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.header("X-Cliento-Signature") ?? "";
if (!verify(req.body, signature)) {
// Nothing is parsed and nothing is processed. 401 also stops Cliento
// from treating this as delivered.
return res.status(401).send("bad signature");
}
const envelope = JSON.parse(req.body.toString("utf8")) as {
delivery_id: number;
event_type: string;
timestamp: string;
payload: {
entity_type: string;
entity_id: number;
occurred_at: string;
metadata: Record<string, unknown>;
};
};
// Deduplicate BEFORE doing the work. `claim` inserts the id behind a
// unique constraint and returns false if it was already there.
if (!(await claim(envelope.delivery_id))) {
return res.status(200).send("duplicate");
}
// Answer first, work afterwards. The socket timeout is 10 seconds, and
// anything slower is recorded as a failed attempt even if it succeeds.
res.status(200).send("ok");
await enqueue(envelope);
},
);
function verify(body: Buffer, header: string): boolean {
const presented = header.startsWith("sha256=") ? header.slice(7) : header;
const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
// Constant-time: a plain === leaks how much of the signature was correct.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(presented, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
declare function claim(deliveryId: number): Promise<boolean>;
declare function enqueue(envelope: unknown): Promise<void>;
app.listen(3000);المستقبِل: Python / Flask
import hashlib
import hmac
import json
import os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["CLIENTO_WEBHOOK_SECRET"].encode()
def verify(body: bytes, header: str | None) -> bool:
"""Constant-time HMAC check over the RAW body."""
if not header:
return False
presented = header.split("=", 1)[1] if "=" in header else header
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
# compare_digest, never ==: a plain comparison leaks how much matched.
return hmac.compare_digest(expected, presented)
@app.post("/cliento")
def receive():
# `get_data()` gives the raw bytes. Do not use `request.json` before
# verifying - re-serializing changes the bytes and breaks the signature.
body = request.get_data()
if not verify(body, request.headers.get("X-Cliento-Signature")):
return {"error": "bad signature"}, 401
envelope = json.loads(body)
delivery_id = envelope["delivery_id"]
# Deduplicate before doing anything. `claim` inserts the id behind a
# UNIQUE constraint and returns False when it was already there, so two
# concurrent copies of one delivery produce exactly one unit of work.
if not claim(delivery_id):
return {"status": "duplicate"}, 200
# Queue the work rather than doing it here: the socket timeout is 10
# seconds, and a slower reply is recorded as a failed attempt even if the
# processing eventually succeeds - which is how a slow consumer turns into
# a stream of duplicate deliveries.
enqueue(envelope["event_type"], envelope["payload"])
return {"status": "ok"}, 200اختبار مستقبِلك
- وجّه اشتراكًا إلى نفق نحو جهاز التطوير لديك (
ngrok،cloudflared) ثم افعل في التطبيق ما يُطلق الحدث. - اقرأ
GET /webhooks/{subscription_id}/deliveries- فهو يسجّل رمز الحالة وجسم الرد اللذين أعادتهما نقطتك، وهو أسرع من مراجعة سجلاتك. - اختبر مسارات الإخفاق عمدًا: أعد 500 مرة وراقب وصول الإعادة بعد نحو 30 ثانية؛ وأعد 200 ببطء (11 ثانية) وتأكّد أنها تُسجَّل إخفاقًا.
- اختبر مسار التكرار بإعادة إرسال الجسم والتوقيع نفسيهما مرتين - يجب أن تجيب نقطتك 200 في المرتين وتنفّذ العمل مرة واحدة.
- اختبر توقيعًا خاطئًا: غيّر حرفًا واحدًا وتأكّد أنك تعيد 401 ولا تعالج شيئًا.
BODY='{"delivery_id":1,"event_type":"ContactCreated","timestamp":"2026-08-23T10:21:07Z","payload":{"entity_type":"contact","entity_id":4182}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$CLIENTO_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -sS -i -X POST http://localhost:3000/cliento \
-H "Content-Type: application/json" \
-H "X-Cliento-Signature: sha256=$SIG" \
-H "X-Cliento-Delivery-Id: 1" \
-H "X-Cliento-Event-Type: ContactCreated" \
--data "$BODY"توصيات أمنية
- تحقّق من التوقيع دائمًا. فالنقطة التي تتخطّاه ستعالج كل ما يرسله إليها أي أحد.
- قارِن بزمن ثابت. فالمقارنة بايتًا ببايت تكشف كم من التوقيع المُخمَّن كان صحيحًا.
- أبقِ السر خارج المستودع ودوِّره عند أي اشتباه.
- قدّم النقطة عبر HTTPS بشهادة صالحة. فالتسليم إلى شهادة غير صالحة يفشل.
- لا تعتبر الحمولة تصريحًا. هي تقول إن سجلًا تغيّر، ولا تمنح الوصول إليه. اقرأه عبر الـ API برمزك أنت.
- حُدّ ما تقبله. ارفض الأجسام الأكبر من المتوقّع، وامنح معالجك مهلة خاصة به.
- استخدم `masked_fields` حتى لا تدخل البيانات التي يجب ألا تغادر في التسليم أصلًا - فالتقنيع في الوجهة سياسة، أما في المصدر فضمان.