Webhooks
Для разработчиков

Webhooks

Webhooks — push-модель: SupportHub сам отправляет POST-запрос на ваш URL, когда происходит событие. Если у вас нет публичного адреса, используйте long polling.

POST/api/v1/webhooks

Зарегистрировать webhook

body
{
  "url": "https://example.com/hooks/supporthub",
  "events": ["ticket.created", "message.created"],
  "description": "Sync to CRM"
}
201 Created
{
  "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"
}
secret возвращается только в этом ответе. Сохраните его: он нужен для проверки подписи.

Пустой или не переданный events — подписка на все события. Неизвестное название события (опечатка или событие, которого нет) — 422 VALIDATION_ERROR: в detail.unknown перечислены такие названия, в detail.valid — все события, на которые можно подписаться. То же проверяет PATCH. URL при регистрации не проверяется: доступность адреса проверяет тестовая отправка (ниже).

GET/api/v1/webhooks

Список webhooks

Возвращает подписки проекта без секрета и без счётчика ошибок:

{
  "items": [
    { "id": "9c...", "url": "...", "events": [...], "is_active": true, "description": "...", "created_at": "..." }
  ]
}
PATCH/api/v1/webhooks/{webhook_id}

Изменить webhook

Частичное обновление: передавайте только нужные поля. Главный случай — снова включить webhook после автоотключения: {"is_active": true}.

body
{
  "url": "https://example.com/hooks/v2",
  "events": ["ticket.created"],
  "description": "Updated subscription",
  "is_active": true
}

is_active: true заодно обнуляет consecutive_failures. В ответе — подписка вместе с consecutive_failures. Нет такой подписки — 404.

DELETE/api/v1/webhooks/{webhook_id}

Удалить webhook

Ответ — 204 без тела. Подписка удаляется вместе с историей доставок; запланированные повторы больше не отправляются.

POST/api/v1/webhooks/{webhook_id}/test

Тестовая отправка

Сразу отправляет на URL событие webhook.test с той же подписью, заголовками и конвертом, что и настоящие события, и возвращает результат. Одна попытка, без повторов; работает и для выключенного webhook.

200 OK
{
  "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 и других каналов этих событий не вызывают.

Формат запроса

POST body
{
  "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-событий

billing.payment_received
{
  "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": {}
}
billing.plan_renewed / billing.plan_changed (data)
{
  "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"
  }
}
billing.balance_low (data)
{
  "type": "billing.balance_low",
  "data": {
    "plan": "pro",
    "balance": 12.5,
    "price": 99.0,
    "shortfall": 86.5
  }
}

Проверка подписи

Подпись — HMAC-SHA256 от сырого тела запроса с секретом webhook в качестве ключа, в hex, с префиксом sha256=. Считайте её по байтам тела до разбора JSON: после повторной сериализации подпись не сойдётся.

server.py (Flask)
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 "", 204
server.mjs (Node.js / Express)
import 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.
Была ли страница полезной?