راهنمای توسعهدهنده · وبهوکها
رویدادهای پشتیبانی، روی سرور خودتان
با وبهوک، سرور سایت شما هر اتفاق میز پشتیبانی را همان لحظه میشنود: گفتوگوی تازه، پیام مشتری، پاسخ پشتیبان، تغییر وضعیت و واگذاری. هر درخواست امضا شده است و اگر سرورتان در دسترس نباشد، تا 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). نام، شماره و ایمیل مشتری در بدنه نیست.
verified فقط وقتی true است که گفتوگو را بازدیدکنندهای شروع کرده باشد که سایت شما با ورود
امضاشده او را همین مشتری معرفی کرده، یا سرور خود شما آن را با API تیکتها
برای همین مشتری باز کرده باشد، و گفتوگو هنوز به همان مشتری پیوند داشته باشد. در این حالت هر پیام
مشتری در آن گفتوگو (created و customer_replied) را همان مشتری نوشته است. اگر false باشد،
پیوند از شماره یا ایمیلی آمده که بازدیدکننده در ابزارک نوشته، از یک کانال، یا از انتخاب پشتیبان
(تیکتی که پشتیبان به مشتری وصل کرده در API تیکتها به او نشان داده میشود، اما باز هم verified
نیست)؛ یعنی هر کسی ممکن است شماره مشتری شما را نوشته باشد. چنین پیوندی را هویت
مشتری ندانید: گفتوگو را به حساب او نچسبانید و چیزی از حسابش را بر پایه آن نشان ندهید یا تغییر
ندهید.
{
"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
}
و یک تغییر وضعیت (بازگشایی):
{
"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 با رمز امضای وبهوک است، روی زمان، یک نقطه و بایتهای خام بدنه:
X-Pixevel-Signature = "v1=" + hex(HMAC_SHA256(secret, timestamp + "." + rawBody))
در ۲۴ ساعت پس از چرخش رمز، سرآیند هر دو امضا را با ویرگول دارد (v1=<رمز قبلی>,v1=<رمز تازه>).
هر کدام از دو رمز که روی سرور شما باشد درخواست را میپذیرد. بعد از آن فقط رمز تازه امضا میکند.
- بدنه را پیش از هر پردازشی، همانطور که رسیده، امضا کنید؛ نه نسخهای که دوباره JSON شده است.
- امضا را با مقایسه زمانثابت بسنجید. اگر چند بخش
v1=آمد، هر بخش را جدا بسنجید و با اولین تطابق بپذیرید. - درخواستی را که زمانش بیش از 5 دقیقه با ساعت سرور شما فاصله دارد رد کنید.
Node.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
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
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 و بدنه زیر:
{"event":"webhook.test","occurredAt":"2026-10-02T10:00:00.000Z","workspaceId":"9c0f42b9-5d2e-4c1a-8f00-0000000000a1","webhookId":"0199a3c0-0000-7000-8000-0000000000b1"}
امضا باید این باشد:
v1=504d4a40d7711cf10c4f3a1a7100845f0c0701744638108b3c32a0a06fa7a278
همه نمونهها
رمز و بدنه هر نمونه، و ساعت سرور در نمونههای بررسی، در webhook-signature-cases.json آمده است. پیادهسازی شما باید این امضاها را بسازد:
| نمونه | زمان | امضا |
|---|---|---|
ascii body | 1759399200 | v1=504d4a40d7711cf10c4f3a1a7100845f0c0701744638108b3c32a0a06fa7a278 |
Persian UTF-8 body | 1759399201 | v1=734f2a61733f79d51e5325d68f7f2ccb270c08f35a5d3751358de63106d5d6dd |
newlines and escaped unicode | 1700000000 | v1=11cfbc14bde0b5995cc9cc34f265050e39526309178aa6decc46a0b7b0d15094 |
و درباره این درخواستها چنین تصمیم بگیرد (مرز پذیرش 300 ثانیه):
| نمونه | چه چیزی | نتیجه |
|---|---|---|
valid | امضای درست؛ ساعت شما 60 ثانیه جلوتر | معتبر |
valid at the edge of the tolerance | امضای درست؛ درست 300 ثانیه اختلاف | معتبر |
uppercase hex is accepted | همان امضا با حروف بزرگ | معتبر |
tampered body | یک حرف بدنه عوض شده است | نامعتبر |
wrong secret | امضا با رمز دیگری ساخته شده است | نامعتبر |
stale timestamp | 301 ثانیه از زمان درخواست گذشته است | نامعتبر |
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 مرتب کنید و رویداد کهنهتر را روی داده تازهتر
ننویسید.
رمز امضا را فقط سرور شما باید بداند؛ آن را در کد صفحههای سایت یا مخزن عمومی نگذارید. اگر فکر میکنید لو رفته است، «چرخش رمز» را بزنید. تا ۲۴ ساعت رمز قبلی هم هنوز قبول است.