پرش به محتوا

راهنمای توسعه‌دهنده · وب‌هوک‌ها

رویدادهای پشتیبانی، روی سرور خودتان

با وب‌هوک، سرور سایت شما هر اتفاق میز پشتیبانی را همان لحظه می‌شنود: گفت‌وگوی تازه، پیام مشتری، پاسخ پشتیبان، تغییر وضعیت و واگذاری. هر درخواست امضا شده است و اگر سرورتان در دسترس نباشد، تا 24 ساعت دوباره فرستاده می‌شود.

راه‌اندازی

مسئول یا مدیر کسب‌وکار در پنل، از «تنظیمات کسب‌وکار ← اتصال به سایت ← وب‌هوک‌ها» یک نشانی اضافه می‌کند و رویدادهایی را که می‌خواهد انتخاب می‌کند. هر کسب‌وکار حداکثر 5 نشانی دارد.

  • فقط https: نشانی باید با https:// شروع شود و روی درگاه استاندارد (443) باشد. نشانی‌های داخلی شبکه پذیرفته نمی‌شوند.
  • رمز امضا یک‌بار نشان داده می‌شود: بعد از ساختن وب‌هوک، رمزی به شکل whsec_… نمایش داده می‌شود. همان لحظه آن را روی سرورتان نگه دارید. اگر گم شد یا لو رفت، «چرخش رمز» رمز تازه‌ای می‌دهد و شناسه وب‌هوک و گزارش ارسال‌ها می‌مانند. تا 24 ساعت هر دو رمز قبول می‌شوند؛ بعد از آن فقط رمز تازه.
  • ارسال آزمایشی: دکمه «ارسال آزمایشی» همان لحظه یک رویداد webhook.test می‌فرستد و نتیجه را نشان می‌دهد. هر کسب‌وکار هر 10 ثانیه یک پیام آزمایشی می‌فرستد، برای هر کدام از نشانی‌هایش که باشد. اگر ارسال به نشانی در مکث باشد (پایین‌تر)، پاسخ موفق به پیام آزمایشی مکث را تمام می‌کند و ارسال‌های در صف همان لحظه فرستاده می‌شوند.

رویدادها

رویدادچه زمانی
conversation.createdگفت‌وگو یا تیکت تازه‌ای شروع شد (همراه پیام اول).
conversation.customer_repliedمشتری پیامی فرستاد.
conversation.staff_repliedپشتیبان یا دستیار پاسخی فرستاد. یادداشت‌های داخلی هرگز فرستاده نمی‌شوند.
conversation.status_changedوضعیت عوض شد: open، pending، resolved یا closed. بازگشایی یعنی تغییر از resolved یا closed به open. وقتی پشتیبان گفت‌وگوی زنده را به تیکت تبدیل می‌کند، همین رویداد با change.field برابر kind (از live به ticket) می‌آید؛ شماره و پیام‌ها همان می‌مانند.
conversation.assignedگفت‌وگو به پشتیبان دیگری سپرده شد یا از او گرفته شد.
conversation.ratedمشتری حل‌شدن گفت‌وگو را امتیاز داد (امروز فقط از راه API تیکت‌ها)؛ بدنه rating دارد.
conversation.message_editedپشتیبان پاسخ خودش را ویرایش کرد؛ message متن تازه را دارد و editedAt.
conversation.message_deletedپشتیبان پاسخ خودش را حذف کرد؛ مشتری دیگر آن را نمی‌بیند. message بدنهٔ خالی، بی‌پیوست و با deletedAt می‌آید.
conversation.mergedپشتیبان این گفت‌وگو را در گفت‌وگوی دیگری از همان مشتری ادغام کرد. پیام‌ها اکنون آنجا هستند و این گفت‌وگو بسته می‌ماند؛ conversation.mergedInto شناسه (id) و شمارهٔ (number) گفت‌وگوی مقصد را دارد تا آن را دنبال کنید.

یک تغییر ممکن است چند رویداد داشته باشد: پاسخ پشتیبانی که گفت‌وگو را به خودش می‌سپارد، هر سه رویداد staff_replied، status_changed و assigned را می‌فرستد.

بدنه درخواست

بدنه یک JSON است با event، occurredAt، workspaceId، وضعیت فعلی گفت‌وگو (conversation)، پیامی که رویداد درباره آن است (message) و آنچه تغییر کرد (change). رویداد conversation.rated هر دو را null دارد و در پایان، امتیاز را در rating می‌آورد: id، score (1 تا 5)، comment، source (api از API تیکت‌ها، widget از ویجت سایت)، resolvedAt (حل‌شدنی که امتیاز گرفت) و createdAt؛ رویدادهای دیگر کلید rating ندارند. مشتری فقط با contact معرفی می‌شود: شناسه ما، شناسه‌ای که خودتان با API برایش فرستاده‌اید (externalId) و اینکه این پیوند ثابت شده است یا نه (verified). نام، شماره و ایمیل مشتری در بدنه نیست.

به contact با verified: false اعتماد نکنید

verified فقط وقتی true است که گفت‌وگو را بازدیدکننده‌ای شروع کرده باشد که سایت شما با ورود امضاشده او را همین مشتری معرفی کرده، یا سرور خود شما آن را با API تیکت‌ها برای همین مشتری باز کرده باشد، و گفت‌وگو هنوز به همان مشتری پیوند داشته باشد. در این حالت هر پیام مشتری در آن گفت‌وگو (created و customer_replied) را همان مشتری نوشته است. اگر false باشد، پیوند از شماره یا ایمیلی آمده که بازدیدکننده در ابزارک نوشته، از یک کانال، یا از انتخاب پشتیبان (تیکتی که پشتیبان به مشتری وصل کرده در API تیکت‌ها به او نشان داده می‌شود، اما باز هم verified نیست)؛ یعنی هر کسی ممکن است شماره مشتری شما را نوشته باشد. چنین پیوندی را هویت مشتری ندانید: گفت‌وگو را به حساب او نچسبانید و چیزی از حسابش را بر پایه آن نشان ندهید یا تغییر ندهید.

json
{
  "event": "conversation.staff_replied",
  "occurredAt": "2026-10-02T10:00:00.000Z",
  "workspaceId": "9c0f42b9-5d2e-4c1a-8f00-0000000000a1",
  "conversation": {
    "id": "0b7e6c2a-1f3d-4e5a-9b8c-000000000042",
    "number": 42,
    "kind": "ticket",
    "channel": "widget",
    "status": "pending",
    "priority": "normal",
    "subject": "سفارش ۱۲۳۴ هنوز نرسیده",
    "categoryId": null,
    "assigneeUserId": "22222222-2222-4222-8222-222222222222",
    "contact": null,
    "createdAt": "2026-10-02T09:41:07.250Z",
    "updatedAt": "2026-10-02T10:00:00.000Z",
    "lastActivityAt": "2026-10-02T10:00:00.000Z"
  },
  "message": {
    "id": "7d1e2f30-0000-4000-8000-0000000000d1",
    "author": "agent",
    "authorUserId": "22222222-2222-4222-8222-222222222222",
    "body": "فردا به دستتان می‌رسد.\nبا تشکر",
    "attachments": [],
    "createdAt": "2026-10-02T10:00:00.000Z"
  },
  "change": null
}

و یک تغییر وضعیت (بازگشایی):

json
{
  "event": "conversation.status_changed",
  "occurredAt": "2026-10-02T10:00:00.000Z",
  "workspaceId": "9c0f42b9-5d2e-4c1a-8f00-0000000000a1",
  "conversation": {
    "id": "0b7e6c2a-1f3d-4e5a-9b8c-000000000042",
    "number": 42,
    "kind": "ticket",
    "channel": "widget",
    "status": "open",
    "priority": "normal",
    "subject": "سفارش ۱۲۳۴ هنوز نرسیده",
    "categoryId": null,
    "assigneeUserId": "22222222-2222-4222-8222-222222222222",
    "contact": {
      "id": "5f1d3c2b-0000-4000-8000-0000000000c1",
      "externalId": "u_123",
      "verified": true
    },
    "createdAt": "2026-10-02T09:41:07.250Z",
    "updatedAt": "2026-10-02T10:00:00.000Z",
    "lastActivityAt": "2026-10-02T10:00:00.000Z"
  },
  "message": null,
  "change": {
    "field": "status",
    "from": "resolved",
    "to": "open"
  }
}

سرآیندها

سرآیندمعنا
X-Pixevel-Eventنام رویداد.
X-Pixevel-Deliveryشناسه این ارسال؛ در تلاش دوباره و ارسال دوباره همین می‌ماند.
X-Pixevel-Timestampزمان همین تلاش، به ثانیه یونیکس.
X-Pixevel-Signatureامضای بدنه: v1= و سپس امضا به صورت hex. در ۲۴ ساعت چرخش رمز، دو امضا با ویرگول: v1=<قبلی>,v1=<تازه>.

بررسی امضا

امضا HMAC-SHA256 با رمز امضای وب‌هوک است، روی زمان، یک نقطه و بایت‌های خام بدنه:

text
X-Pixevel-Signature = "v1=" + hex(HMAC_SHA256(secret, timestamp + "." + rawBody))

در ۲۴ ساعت پس از چرخش رمز، سرآیند هر دو امضا را با ویرگول دارد (v1=<رمز قبلی>,v1=<رمز تازه>). هر کدام از دو رمز که روی سرور شما باشد درخواست را می‌پذیرد. بعد از آن فقط رمز تازه امضا می‌کند.

  • بدنه را پیش از هر پردازشی، همان‌طور که رسیده، امضا کنید؛ نه نسخه‌ای که دوباره JSON شده است.
  • امضا را با مقایسه زمان‌ثابت بسنجید. اگر چند بخش v1= آمد، هر بخش را جدا بسنجید و با اولین تطابق بپذیرید.
  • درخواستی را که زمانش بیش از 5 دقیقه با ساعت سرور شما فاصله دارد رد کنید.

Node.js

js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body as received (a Buffer), before JSON.parse.
export function verifyPixevelWebhook(rawBody, headers, secret) {
  const timestamp = headers["x-pixevel-timestamp"] ?? "";
  const signature = headers["x-pixevel-signature"] ?? "";
  if (!/^\d+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  return signature.split(",").some((part) => {
    if (!part.startsWith("v1=")) return false;
    const got = Buffer.from(part.slice(3).toLowerCase());
    return got.length === expected.length && timingSafeEqual(got, Buffer.from(expected));
  });
}

Go

go
func verifyPixevelWebhook(r *http.Request, body []byte, secret string) bool {
	ts := r.Header.Get("X-Pixevel-Timestamp")
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	want := mac.Sum(nil)
	for _, part := range strings.Split(r.Header.Get("X-Pixevel-Signature"), ",") {
		sig, ok := strings.CutPrefix(part, "v1=")
		if !ok {
			return false
		}
		got, err := hex.DecodeString(sig)
		if err != nil {
			return false
		}
		if hmac.Equal(got, want) {
			return true
		}
	}
	return false
}

PHP

php
function pixevel_verify_webhook(string $rawBody, string $timestamp, string $signature, string $secret): bool
{
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach (explode(',', $signature) as $part) {
        if (strncmp($part, 'v1=', 3) !== 0) {
            return false;
        }
        if (hash_equals($expected, strtolower(substr($part, 3)))) {
            return true;
        }
    }
    return false;
}

نمونه برای آزمودن پیاده‌سازی

با رمز whsec_0123456789abcdef0123456789abcdef01234567، زمان 1759399200 و بدنه زیر:

json
{"event":"webhook.test","occurredAt":"2026-10-02T10:00:00.000Z","workspaceId":"9c0f42b9-5d2e-4c1a-8f00-0000000000a1","webhookId":"0199a3c0-0000-7000-8000-0000000000b1"}

امضا باید این باشد:

text
v1=504d4a40d7711cf10c4f3a1a7100845f0c0701744638108b3c32a0a06fa7a278

همه نمونه‌ها

رمز و بدنه هر نمونه، و ساعت سرور در نمونه‌های بررسی، در webhook-signature-cases.json آمده است. پیاده‌سازی شما باید این امضاها را بسازد:

نمونهزمانامضا
ascii body1759399200v1=504d4a40d7711cf10c4f3a1a7100845f0c0701744638108b3c32a0a06fa7a278
Persian UTF-8 body1759399201v1=734f2a61733f79d51e5325d68f7f2ccb270c08f35a5d3751358de63106d5d6dd
newlines and escaped unicode1700000000v1=11cfbc14bde0b5995cc9cc34f265050e39526309178aa6decc46a0b7b0d15094

و درباره این درخواست‌ها چنین تصمیم بگیرد (مرز پذیرش 300 ثانیه):

نمونهچه چیزینتیجه
validامضای درست؛ ساعت شما 60 ثانیه جلوترمعتبر
valid at the edge of the toleranceامضای درست؛ درست 300 ثانیه اختلافمعتبر
uppercase hex is acceptedهمان امضا با حروف بزرگمعتبر
tampered bodyیک حرف بدنه عوض شده استنامعتبر
wrong secretامضا با رمز دیگری ساخته شده استنامعتبر
stale timestamp301 ثانیه از زمان درخواست گذشته استنامعتبر
timestamp too far in the futureزمان درخواست 301 ثانیه جلوتر از ساعت شماستنامعتبر
missing v1= prefixامضا بدون پیشوند v1=نامعتبر
old secret during a rotationسرآیند دو امضا؛ رمز قبلیمعتبر
new secret during a rotationسرآیند دو امضا؛ رمز تازهمعتبر
neither secret of a rotationسرآیند دو امضا؛ رمز سومنامعتبر

پاسخ‌دادن و تلاش دوباره

  • سریع کد 2xx برگردانید و کار سنگین را بعد از آن انجام دهید؛ هر تلاش حداکثر 10 ثانیه صبر می‌کند و بدنه پاسخ شما خوانده یا نگه داشته نمی‌شود.
  • هر ارسال ممکن است بیش از یک بار برسد. ارسال‌ها با X-Pixevel-Delivery یکتا هستند؛ شناسه‌ای را که قبلاً پردازش کرده‌اید دوباره پردازش نکنید و فقط کد 2xx برگردانید.
  • هر پاسخ دیگری تلاش دوباره است: خطای سرور، تأخیر، نبودن پاسخ، هدایت (3xx، که دنبال نمی‌شود) و کدهای 4xx. فاصله‌ها 1 دقیقه، 2 دقیقه، 4 دقیقه و بیشتر می‌شوند تا هر 6 ساعت، و پس از 24 ساعت ارسال ناموفق می‌ماند. در «گزارش ارسال‌ها» می‌توانید ارسال ناموفق را دوباره بفرستید (پیام آزمایشی را نه؛ برای آن دوباره «ارسال آزمایشی» را بزنید).
  • سرور در دسترس نیست؟ اگر نشانی اصلاً پاسخ ندهد یا با 429، 502، 503 یا 504 پاسخ دهد، ارسال به آن مکث می‌کند: ارسال‌های در صف و رویدادهای تازه منتظر می‌مانند و فقط پس از 1 دقیقه، 2 دقیقه، 4 دقیقه و بیشتر تا هر 6 ساعت یک درخواست فرستاده می‌شود تا سرورتان زیر بار نرود. همین که پاسخ موفق بدهد، صف به ترتیب فرستاده می‌شود، حتی ارسال‌هایی که 24 ساعتشان در این مدت گذشته است. اگر باز هم پاسخ ندهد، همان ارسال‌ها بی‌آنکه دوباره فرستاده شوند ناموفق می‌شوند و از «گزارش ارسال‌ها» می‌توانید دوباره بفرستیدشان. پاسخ موفق به «ارسال آزمایشی» هم مکث را تمام می‌کند. پاسخ 500 و دیگر کدهای 5xx (جز 502، 503 و 504) فقط همان ارسال را به تلاش بعدی می‌برد و بقیه منتظرش نمی‌مانند.
  • کد 410 یعنی «دیگر نفرست»: وب‌هوک غیرفعال می‌شود و ارسال‌های در صفش لغو می‌شوند؛ از پنل دوباره فعالش کنید.

ترتیب رسیدن

ارسال‌های یک نشانی به ترتیب زمانی فرستاده می‌شوند، اما ترتیب رسیدن تضمین نمی‌شود: ارسالی که ناموفق بوده بعداً دوباره فرستاده می‌شود و ممکن است رویدادهای تازه‌تر زودتر برسند. اگر ترتیب برایتان مهم است، با occurredAt و conversation.updatedAt مرتب کنید و رویداد کهنه‌تر را روی داده تازه‌تر ننویسید.

رمز امضا را جای امنی نگه دارید

رمز امضا را فقط سرور شما باید بداند؛ آن را در کد صفحه‌های سایت یا مخزن عمومی نگذارید. اگر فکر می‌کنید لو رفته است، «چرخش رمز» را بزنید. تا ۲۴ ساعت رمز قبلی هم هنوز قبول است.

وب‌هوک‌ها | پیکسول‌چت