تحویل، امضا و دریافتکنندهها
بررسی امضا، مدیریت تلاش دوباره و تکراریها، تضمینهای ترتیب، و پیادهسازی کامل دریافتکننده.
آخرین بازنگری: 2026-08-23
بررسی امضا
هر تحویل هدر X-Cliento-Signature: sha256=<hex> را میآورد - یک HMAC-SHA256 روی بدنهٔ خام درخواست، با کلیدِ رازِ همان اشتراک. پیش از تجزیه بررسیاش کنید و هر چیزی را که رد شد نپذیرید.
- بدنهٔ خام را بهصورت بایت بخوانید.
-
HMAC-SHA256(secret, body)را حساب و به hex تبدیل کنید. - با بخش پس از
sha256=در هدر مقایسه کنید، با مقایسهٔ زمانثابت. - اگر جور نبود، ۴۰۱ برگردانید و هیچ چیزی را پردازش نکنید.
اگر گمان نشت راز دارید، آن را با POST /webhooks/{subscription_id}/rotate-secret بچرخانید. راز تازه بیدرنگ اثر میکند؛ پس اگر تحمل چند تحویل ردشده را ندارید، اول مقدار تازه را روی دریافتکننده مستقر کنید - آنها دوباره تلاش خواهند شد.
تلاش دوباره و تایماوت
تحویلها در صف قرار میگیرند و هرگز همزمان با درخواستی که باعثشان شده فرستاده نمیشوند - پس نشانی کندِ شما هرگز نمیتواند CRM خود فضای کاری را کند کند. یک کارگر تحویلهای سررسیده را برمیدارد، POST میکند و در صورت شکست دوباره زمانبندی میکند.
| رفتار | مقدار |
|---|---|
| تایماوت سوکت | ۱۰ ثانیه |
| عقبنشینی پس از تلاش ۱، ۲، ۳، ۴، ۵ | ۳۰ ثانیه، ۲ دقیقه، ۱۰ دقیقه، ۳۰ دقیقه، ۱ ساعت |
| تلاشها پیش از تسلیم | max_attempts روی اشتراک (پیشفرض ۵) |
| موفقیت یعنی | هر 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"معناشناسی تحویل
سه ویژگی که باید بر پایهٔ آنها طراحی کنید. هیچکدام نقص نیستند؛ هر سه نتیجهٔ ایناند که هیچ تحویلی گم نشود.
- دستکم یکبار، نه دقیقاً یکبار. نشانیای که بار داده را پردازش کند و بعد در راه بازگشت تایماوت شود، همان تحویل را دوباره میبیند. همینطور نشانیای که کارگرش وسط POST مرده باشد، بهمحض انقضای اجاره.
- بدون تضمین ترتیب. تحویلها مستقل از هم تکرار میشوند؛ پس
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را بخوانید - کد وضعیت و بدنهٔ پاسخی را که نشانی شما برگردانده ثبت میکند و این سریعتر از خواندن لاگهای خودتان است. - مسیرهای شکست را عمداً بیازمایید: یک بار ۵۰۰ برگردانید و ببینید تلاش دوباره حدود ۳۰ ثانیه بعد میرسد؛ ۲۰۰ را کند برگردانید (۱۱ ثانیه) و تأیید کنید که بهعنوان شکست ثبت میشود.
- مسیر تکراری را با فرستادن دوبارهٔ همان بدنه و همان امضا بیازمایید - نشانی شما باید هر دو بار ۲۰۰ بدهد و کار را یک بار انجام دهد.
- امضای نادرست را بیازمایید: یک نویسه را عوض کنید و تأیید کنید که ۴۰۱ برمیگردانید و چیزی پردازش نمیکنید.
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"توصیههای امنیتی
- همیشه امضا را بررسی کنید. نشانیای که از این کار بگذرد، هر چیزی را که هر کسی برایش POST کند پردازش میکند.
- در زمان ثابت مقایسه کنید. مقایسهٔ بایتبهبایت افشا میکند چه مقدار از امضای حدسزدهشده درست بوده است.
- راز را بیرون از مخزن نگه دارید و در هر تردیدی بچرخانید.
- نشانی را روی HTTPS با گواهی معتبر سرو کنید. تحویل به گواهی نامعتبر شکست میخورد.
- بار داده را بهعنوان مجوز باور نکنید. میگوید رکوردی تغییر کرده؛ دسترسی به آن رکورد را نمیدهد. با توکن خودتان از راه API بازخوانی کنید.
- آنچه میپذیرید را محدود کنید. بدنههای بزرگتر از انتظار را رد کنید و برای هندلر خودتان تایماوت بگذارید.
- از `masked_fields` استفاده کنید تا دادهای که نباید خارج شود اصلاً وارد تحویل نشود - ماسککردن در مقصد یک سیاست است، ماسککردن در مبدأ یک تضمین.