Webhooks
Webhooks — push-модель: SupportHub сам отправляет POST-запрос на ваш URL, когда происходит событие. Если у вас нет публичного адреса, используйте long polling.
/api/v1/webhooksЗарегистрировать webhook
{
"url": "https://example.com/hooks/supporthub",
"events": ["ticket.created", "message.created"],
"description": "Sync to CRM"
}{
"id": "9c...",
"url": "https://example.com/hooks/supporthub",
"events": ["ticket.created", "message.created"],
"secret": "Vd9q...long-random...",
"is_active": true,
"description": "Sync to CRM",
"created_at": "2026-04-06T10:00:00"
}Пустой или не переданный events — подписка на все события. Неизвестное название события (опечатка или событие, которого нет) — 422 VALIDATION_ERROR: в detail.unknown перечислены такие названия, в detail.valid — все события, на которые можно подписаться. То же проверяет PATCH. URL при регистрации не проверяется: доступность адреса проверяет тестовая отправка (ниже).
/api/v1/webhooksСписок webhooks
Возвращает подписки проекта без секрета и без счётчика ошибок:
{
"items": [
{ "id": "9c...", "url": "...", "events": [...], "is_active": true, "description": "...", "created_at": "..." }
]
}/api/v1/webhooks/{webhook_id}Изменить webhook
Частичное обновление: передавайте только нужные поля. Главный случай — снова включить webhook после автоотключения: {"is_active": true}.
{
"url": "https://example.com/hooks/v2",
"events": ["ticket.created"],
"description": "Updated subscription",
"is_active": true
}is_active: true заодно обнуляет consecutive_failures. В ответе — подписка вместе с consecutive_failures. Нет такой подписки — 404.
/api/v1/webhooks/{webhook_id}Удалить webhook
Ответ — 204 без тела. Подписка удаляется вместе с историей доставок; запланированные повторы больше не отправляются.
/api/v1/webhooks/{webhook_id}/testТестовая отправка
Сразу отправляет на URL событие webhook.test с той же подписью, заголовками и конвертом, что и настоящие события, и возвращает результат. Одна попытка, без повторов; работает и для выключенного webhook.
{
"delivered": true,
"status_code": 204,
"error": null,
"latency_ms": 184,
"delivery_id": "3f...",
"signature_header": "sha256=5d41..."
}События
| Событие | Когда | Поля data |
|---|---|---|
| ticket.created | Создан тикет (в любом канале, включая API) | ticket_id, status, priority, contact_id, subject |
| ticket.updated | Тикет изменён в дашборде или через PATCH /api/v1/tickets, назначен, или его статус сменился после ответа | ticket_id; остальные поля зависят от источника (после PATCH — только workspace_id), текущее состояние берите через REST |
| ticket.assigned | Тикет назначен оператору, в том числе переназначен другому | ticket_id, operator_id, operator_name, operator_avatar_url, status |
| ticket.transferred | Тикет передан в другой отдел (возможно, сразу конкретному оператору) | ticket_id, department_id, department_name, operator_id, operator_name |
| ticket.force_taken | Оператор забрал тикет, назначенный на другого | ticket_id, operator_id, previous_operator_id |
| ticket.closed | Тикет закрыт | ticket_id, closed_at, resolution_secs |
| ticket.reopened | Тикет переоткрыт | ticket_id, status |
| ticket.rated | Поставлена оценка | ticket_id, rating; comment — если оценку передали через API |
| message.created | Новое сообщение в любом канале, включая внутренние заметки (is_internal) и системные сообщения | id, message_id, ticket_id, sender_type, sender_id, sender_name, content, is_internal, media, created_at |
| message.edited | Сообщение отредактировано | id, message_id, ticket_id, content, is_internal, edited_at |
| message.reaction.added | Посетитель виджета или оператор поставил реакцию | message_id, reactor_kind, reactor_id, emoji |
| message.reaction.removed | Посетитель виджета убрал реакцию | message_id, reactor_id |
| contact.created | Виджет впервые идентифицировал посетителя, и контакт создан при этом | id, internal_id, email, full_name, phone, telegram_id |
| contact.identified | Каждый вызов identify из виджета | id, internal_id, email, full_name, phone, telegram_id |
| visitor.reply_waiting | Оператор ответил в виджете, а посетителя нет в чате (включены «Уведомления посетителю» в конструкторе виджета; не чаще раза в 5 минут на посетителя) | contact_id, external_id, telegram_user_id, ticket_id, ticket_short_id, message_id, operator_name, preview, locale, surface, open_url, replied_at |
| operator.status.changed | Оператор сменил свой статус (online, away, dnd, custom, offline) | user_id, status, status_text, status_emoji |
| billing.payment_received | Пополнение зачислено на баланс проекта | payment_id, order_id, amount, currency, method, transaction_id, new_balance |
| billing.payment_failed | Платёжный шлюз сообщил, что платёж отменён | payment_id, order_id, amount, method, reason |
| billing.plan_renewed | Тариф продлён списанием с баланса | plan, amount, new_balance, expires_at, trigger (auto / manual) |
| billing.plan_changed | Тариф закончился и сменился на выбранный следующий платный (он оплачен с баланса), или пробный период перешёл в платный тариф | old_plan, new_plan, expires_at; при оплате выбранного тарифа ещё amount и new_balance, для пробного периода — reason |
| billing.plan_downgraded | Проект переведён на Starter: не хватило баланса, автопродление выключено без следующего тарифа, или пробный период закончился без оплаты | old_plan, new_plan, expires_at; для пробного периода ещё reason |
| billing.balance_low | Попытка продления: баланса не хватает на цену тарифа | plan, balance, price, shortfall; при ручном продлении ещё trigger |
| billing.subscription_expired | Платный тариф закончился без продления | old_plan, reason (insufficient_balance / auto_renew_disabled), expired_at |
- В
message.createdприходят и внутренние заметки операторов — отсекайте их поis_internal. - Сообщения, отправленные через
POST /api/v1/tickets/{id}/messages, приходят сsender_type: botи id проекта вsender_id— так можно узнать свои же сообщения. media[].urlв событиях — внутренний адрес чата (/api/media/…; у файлов внутренней заметки — ссылка с подписью, которая действует 12 часов). Через REST API файлы скачиваются поGET /api/v1/media/{id}.contact.*шлёт только виджет: контакты из Telegram, email, API и других каналов этих событий не вызывают.
Формат запроса
{
"id": "8c4f1a0b6d2e4f3a9b1c7d5e3f2a1b0c",
"type": "ticket.created",
"timestamp": "2026-05-07T10:00:00.123456+00:00",
"workspace_id": "27744f73-...",
"data": { "ticket_id": "aa...", "status": "new", "priority": "normal", "contact_id": "44e1...", "subject": "..." },
"_links": {
"ticket": "/api/v1/tickets/aa...",
"messages": "/api/v1/tickets/aa.../messages"
}
}id— id события (32 hex-символа). Одинаковый во всех повторах и во всех подписках: по нему удобно отсекать дубли.data— короткие данные события (поля — в таблице выше). Полный объект получайте через REST._links— относительные адреса для GET-запросов с вашим API-ключом, только на существующие эндпоинты: уticket.*иmessage.*—ticketиmessages(если в событии естьticket_id), уcontact.*—contactиtickets(тикеты контакта). У остальных событий ссылок нет.
Content-Type: application/json
X-Webhook-Signature: sha256=<hex digest>
X-Webhook-Event: ticket.created
X-Webhook-Id: 3f... # id доставки: один на подписку, тот же во всех повторах
X-Webhook-Attempt: 1 # номер попытки, 1–6Примеры billing-событий
{
"id": "8c4f1a...",
"type": "billing.payment_received",
"timestamp": "2026-05-07T10:00:00.123456+00:00",
"workspace_id": "27744f73-...",
"data": {
"payment_id": "p1...",
"order_id": "ord_42",
"amount": 99.0,
"currency": "USD",
"method": "heleket",
"transaction_id": "txid_...",
"new_balance": 199.0
},
"_links": {}
}{
"type": "billing.plan_renewed",
"data": {
"plan": "pro",
"amount": 99.0,
"new_balance": 100.0,
"expires_at": "2026-06-07T10:00:00.123456",
"trigger": "auto"
}
}
{
"type": "billing.plan_changed",
"data": {
"old_plan": "pro",
"new_plan": "team",
"expires_at": "2026-06-07T10:00:00.123456"
}
}{
"type": "billing.balance_low",
"data": {
"plan": "pro",
"balance": 12.5,
"price": 99.0,
"shortfall": 86.5
}
}Проверка подписи
Подпись — HMAC-SHA256 от сырого тела запроса с секретом webhook в качестве ключа, в hex, с префиксом sha256=. Считайте её по байтам тела до разбора JSON: после повторной сериализации подпись не сойдётся.
import hmac, hashlib
from flask import Flask, request, abort
SECRET = b"Vd9q...long-random..."
app = Flask(__name__)
@app.post("/hooks/supporthub")
def hook():
sig = request.headers.get("X-Webhook-Signature", "")
expected = "sha256=" + hmac.new(
SECRET, request.get_data(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.headers["X-Webhook-Event"]
payload = request.get_json()
print(event, payload)
return "", 204import express from "express";
import crypto from "node:crypto";
const SECRET = "Vd9q...long-random...";
const app = express();
app.post(
"/hooks/supporthub",
express.raw({ type: "application/json" }),
(req, res) => {
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const sig = req.header("X-Webhook-Signature") || "";
const ok =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = req.header("X-Webhook-Event");
const payload = JSON.parse(req.body.toString());
console.log(event, payload);
res.sendStatus(204);
}
);
app.listen(3000);Доставка и повторы
- Успех — ответ
2xxне позже чем через 10 секунд. Переадресации не выполняются:3xxсчитается ошибкой. - При ошибке (другой код, таймаут, сетевая ошибка) делается до 6 попыток: сразу, через 30 секунд, 2 минуты, 10 минут, 1 час и 6 часов после предыдущей.
- Если все 6 не удались, доставка помечается
failed, а счётчикconsecutive_failuresподписки растёт на 1. После 5 таких доставок подряд webhook выключается (is_active: false); включите его снова через PATCH. Любая успешная доставка обнуляет счётчик. - Выключенному или удалённому webhook запланированные повторы не отправляются.
- Каждая доставка идёт отдельно, порядок не гарантируется: при необходимости упорядочивайте по
timestamp.

