بازگشت به مستندات

تحویل، امضا و دریافت‌کننده‌ها

بررسی امضا، مدیریت تلاش دوباره و تکراری‌ها، تضمین‌های ترتیب، و پیاده‌سازی کامل دریافت‌کننده.

آخرین بازنگری: 2026-08-23

بررسی امضا

هر تحویل هدر X-Cliento-Signature: sha256=<hex> را می‌آورد - یک HMAC-SHA256 روی بدنهٔ خام درخواست، با کلیدِ رازِ همان اشتراک. پیش از تجزیه بررسی‌اش کنید و هر چیزی را که رد شد نپذیرید.

  1. بدنهٔ خام را به‌صورت بایت بخوانید.
  2. ‏HMAC-SHA256(secret, body) را حساب و به hex تبدیل کنید.
  3. با بخش پس از sha256= در هدر مقایسه کنید، با مقایسهٔ زمان‌ثابت.
  4. اگر جور نبود، ۴۰۱ برگردانید و هیچ چیزی را پردازش نکنید.

اگر گمان نشت راز دارید، آن را با POST /webhooks/{subscription_id}/rotate-secret بچرخانید. راز تازه بی‌درنگ اثر می‌کند؛ پس اگر تحمل چند تحویل ردشده را ندارید، اول مقدار تازه را روی دریافت‌کننده مستقر کنید - آن‌ها دوباره تلاش خواهند شد.

تلاش دوباره و تایم‌اوت

تحویل‌ها در صف قرار می‌گیرند و هرگز هم‌زمان با درخواستی که باعثشان شده فرستاده نمی‌شوند - پس نشانی کندِ شما هرگز نمی‌تواند CRM خود فضای کاری را کند کند. یک کارگر تحویل‌های سررسیده را برمی‌دارد، POST می‌کند و در صورت شکست دوباره زمان‌بندی می‌کند.

رفتارمقدار
تایم‌اوت سوکت۱۰ ثانیه
عقب‌نشینی پس از تلاش ۱، ۲، ۳، ۴، ۵۳۰ ثانیه، ۲ دقیقه، ۱۰ دقیقه، ۳۰ دقیقه، ۱ ساعت
تلاش‌ها پیش از تسلیمmax_attempts روی اشتراک (پیش‌فرض ۵)
موفقیت یعنیهر 2xx
شکست یعنیهر وضعیت دیگر، ریدایرکت، تایم‌اوت، خطای اتصال

پس از آخرین تلاش، تحویل exhausted علامت می‌خورد و دیگر تکرار نمی‌شود. ‏GET /webhooks/{subscription_id}/deliveries?status=exhausted جایی است که باید سراغ آنچه از دست رفته بروید؛ آن ردیف وضعیت پاسخ و بدنه‌ای را که نشانی شما برگردانده نگه می‌دارد و معمولاً همین برای فهمیدن دلیل کافی است.

bash·چه چیزی شکست خورد و چرا
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 کلید حذف تکراری است و در تلاش‌های دوبارهٔ همان تحویل ثابت می‌ماند. پیش از اقدام ثبتش کنید و هر چیزی را که قبلاً دیده‌اید رد کنید.

python·الگو، به‌صورت توضیحی
# 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

typescript·server.ts
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

python·receiver.py
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

آزمودن دریافت‌کنندهٔ خودتان

  1. یک اشتراک را به تونلی به‌سمت دستگاه توسعه‌تان نشانه بروید (ngrok، cloudflared) و در برنامه کاری کنید که رویداد را برانگیزد.
  2. ‏GET /webhooks/{subscription_id}/deliveries را بخوانید - کد وضعیت و بدنهٔ پاسخی را که نشانی شما برگردانده ثبت می‌کند و این سریع‌تر از خواندن لاگ‌های خودتان است.
  3. مسیرهای شکست را عمداً بیازمایید: یک بار ۵۰۰ برگردانید و ببینید تلاش دوباره حدود ۳۰ ثانیه بعد می‌رسد؛ ۲۰۰ را کند برگردانید (۱۱ ثانیه) و تأیید کنید که به‌عنوان شکست ثبت می‌شود.
  4. مسیر تکراری را با فرستادن دوبارهٔ همان بدنه و همان امضا بیازمایید - نشانی شما باید هر دو بار ۲۰۰ بدهد و کار را یک بار انجام دهد.
  5. امضای نادرست را بیازمایید: یک نویسه را عوض کنید و تأیید کنید که ۴۰۱ برمی‌گردانید و چیزی پردازش نمی‌کنید.
bash·بازپخش یک تحویل روی دریافت‌کنندهٔ محلی
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` استفاده کنید تا داده‌ای که نباید خارج شود اصلاً وارد تحویل نشود - ماسک‌کردن در مقصد یک سیاست است، ماسک‌کردن در مبدأ یک تضمین.

به کمک فنی نیاز دارید؟

اگر پاسخ پرسش‌تان اینجا نبود، با تیم ما تماس بگیرید.

تماس با ما
تحویل، امضا و دریافت‌کننده‌ها · Cliento Pro