Вебхуки бота подписок: уведомления о подписках на ваш сервер
Бот подписок может отправлять на ваш сервер HTTP-уведомление каждый раз, когда у пользователя
появляется новая запись подписки или меняется её статус: оформил заказ, оплатил, продлил,
истёк доступ, сработало или не сработало автосписание. Так вы синхронизируете доступы со своей
CRM, начисляете бонусы, ведёте аналитику или подключаете пользователя к сторонним сервисам.
Документ для разработчика принимающей стороны: когда приходит запрос, что в нём, как ответить.
1. Как это работает
событие с подпиской (оплата, продление, автосписание, истечение, отказ)
│
▼
запись подписки создана / сменила статус
│
▼ очередь (обычно 1–3 секунды)
POST на ваш Webhook URL ──► 2xx — доставлено
└► ошибка / нет ответа — повтор (до 3 попыток)
| Что | Как |
|---|---|
| Когда срабатывает | При создании записи подписки и при каждом изменении её статуса — для любых источников: покупка в боте, выдача доступа админом в ЛК или через API, автосписание, истечение срока, отказ платёжной системы |
| Куда идёт запрос | На Webhook URL из настроек бота подписок, методом POST |
| Формат тела | application/x-www-form-urlencoded (обычная HTML-форма), плоский набор полей |
| Что считается успехом | HTTP-ответ с кодом 2xx. Тело ответа не разбирается |
| Повторы | До 3 попыток с паузой 0,5 / 1 с при сетевой ошибке, таймауте или ответе 4xx/5xx |
| Таймауты | 5 с на соединение, 20 с на ответ |
| Редиректы | Не выполняются — указывайте конечный адрес |
| Порядок | Не гарантируется: при быстрой смене статуса два уведомления могут прийти в любом порядке. Ориентируйтесь на status и old_status, а не на порядок получения |
| Дубли | Возможны при повторе после неполученного ответа. Делайте обработку идемпотентной (ключ: subscription_id + event + old_status + status) |
2. Настройка в личном кабинете
Бот подписок → Панель управления → Настройки бота подписки → поле «Webhook URL подписок».
| Правило | Пояснение |
|---|---|
| Схема | http:// или https:// (рекомендуем https://) |
| Адрес | Публичный домен или IP. Локальные и приватные адреса (localhost, 127.0.0.1, 10.x, 192.168.x, 172.16–31.x и т.п.) отклоняются при сохранении |
| Логин/пароль в URL | Не поддерживаются (https://user:pass@host/…). Используйте секрет в пути или query-параметре: https://example.com/hooks/bott?key=… |
| Выключить | Очистите поле и сохраните |
3. События
event | Когда | old_status |
|---|---|---|
subscription.created | Создана новая запись подписки. Это происходит при: оформлении заказа пользователем (ещё не оплачен), выдаче доступа администратором, автоматическом продлении (новый период), создании ожидающего платежа автосписания, временном периоде между попытками автосписания | null |
subscription.status_changed | У записи изменился статус (см. раздел 4) | предыдущий статус |
Одна подписка пользователя — это цепочка записей: каждый оплаченный период, каждая попытка
автосписания — отдельная запись со своим subscription_id. Пользователя и ресурс связывают поля
user_id / telegram_id и resource_id.
4. Статусы записи
status | Название | Что означает |
|---|---|---|
1 | Не оплачена | Заказ создан, оплата не поступила. Доступ не выдан |
2 | Активна | Оплачено, доступ действует с paid_at до end_paid_at |
3 | Завершена | Период закончился: истёк срок, открыт новый период, оплата отклонена или автосписание не удалось |
4 | Ожидание | Устаревший статус, в новых записях не используется |
Типовые переходы:
| Переход | Что произошло |
|---|---|
1 → 2 | Оплата прошла, доступ выдан (в том числе успешное автосписание) |
2 → 3 | Доступ закончился или на том же ресурсе открыт новый период (старая запись закрывается) |
1 → 3 | Заказ не оплачен: отказ платёжной системы, ожидаемый платёж автосписания не подтверждён |
5. Поля запроса
Все поля приходят строками (особенность x-www-form-urlencoded). Пустые значения (null) передаются пустой строкой.
| Поле | Тип | Описание |
|---|---|---|
event | string | subscription.created или subscription.status_changed |
subscription_id | int | ID записи подписки. Уникален для каждой записи/периода |
bot_id | int | ID вашего бота в BOT-T |
subscriber_id | int | ID модуля подписок бота (служебное) |
resource_id | int | ID группы ресурсов (канал/чат или набор), к которой относится подписка |
tariff_id | int | null | ID тарифа. Пусто, если тариф удалён |
user_id | int | ID пользователя в BOT-T (внутренний, не Telegram) |
telegram_id | int | null | Telegram ID пользователя |
status | int | Текущий статус записи (раздел 4) |
old_status | int | null | Предыдущий статус. Только для subscription.status_changed |
paid_at | int | null | Начало доступа, Unix timestamp (UTC). Пусто, пока не оплачено |
end_paid_at | int | null | Окончание доступа, Unix timestamp (UTC) |
price | int | Сумма записи в минимальных единицах валюты (копейки, центы): 99000 = 990,00 |
currency_id | int | ID валюты в BOT-T (например, 1 — RUB) |
balance_type_id | int | null | Способ оплаты / тип баланса: 5 — выдано администратором, 6 — купон, 7 — внутренний баланс, иные значения — платёжные системы. Пусто, пока способ не выбран |
item_id | int | null | ID подключённого способа оплаты (payment_item), если платёж шёл через платёжную систему |
is_recurrent | 0 / 1 | 1 — к записи привязано автопродление (сохранён токен рекуррентного платежа) |
autopayment_counter | int | null | Номер попытки автосписания для записей, созданных логикой автопродления. Пусто для обычных покупок |
bot_clone_id | int | null | ID копии бота, через которую оформлена запись. Пусто — основной бот |
created_at | int | Время создания записи, Unix timestamp (UTC) |
6. Примеры
Пользователь оформил заказ (ещё не оплатил)
POST /hooks/bott HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
event=subscription.created&subscription_id=501&bot_id=77&subscriber_id=9&resource_id=10
&tariff_id=12&user_id=3345&telegram_id=123456789&status=1&old_status=&paid_at=&end_paid_at=
&price=99000¤cy_id=1&balance_type_id=&item_id=&is_recurrent=0&autopayment_counter=
&bot_clone_id=&created_at=1730361600
Оплата прошла, доступ выдан
event=subscription.status_changed&subscription_id=501&bot_id=77&subscriber_id=9&resource_id=10
&tariff_id=12&user_id=3345&telegram_id=123456789&status=2&old_status=1
&paid_at=1730361650&end_paid_at=1732953650&price=99000¤cy_id=1&balance_type_id=74
&item_id=15&is_recurrent=1&autopayment_counter=&bot_clone_id=&created_at=1730361600
Срок истёк, автосписание прошло успешно
Придут три уведомления (порядок не гарантирован):
status_changed,subscription_id=501,old_status=2,status=3— старый период закрыт.created,subscription_id=640,status=1,autopayment_counter=— создан ожидающий платёж автосписания.status_changed,subscription_id=640,old_status=1,status=2— платёж подтверждён, новый период активен.
Автосписание не удалось, назначена повторная попытка
status_changed,subscription_id=640,old_status=1,status=3— платёж не подтверждён.created,subscription_id=641,status=1, затемstatus_changed1 → 2сautopayment_counter=1—
временный период до следующей попытки (доступ сохраняется,end_paid_at— время следующей попытки).
Когда попытки исчерпаны, последняя запись переходит в 3 без создания новой, и пользователь удаляется из ресурса.
Администратор выдал доступ из ЛК
created (status=1, balance_type_id=) и сразу status_changed 1 → 2 с balance_type_id=5.
7. Как обработать на своей стороне
<?php
// Пример на PHP: минимальный приёмник
if (($_GET['key'] ?? '') !== 'ВАШ_СЕКРЕТ') {
http_response_code(403);
exit;
}
$event = $_POST['event'] ?? '';
$id = (int)($_POST['subscription_id'] ?? 0);
$status = (int)($_POST['status'] ?? 0);
$old = $_POST['old_status'] === '' ? null : (int)$_POST['old_status'];
// Идемпотентность: один и тот же переход обрабатываем один раз
$dedupKey = "$id:$event:$old:$status";
if (alreadyProcessed($dedupKey)) {
http_response_code(200);
exit;
}
if ($event === 'subscription.status_changed' && $status === 2) {
grantAccess((int)$_POST['telegram_id'], (int)$_POST['resource_id'], (int)$_POST['end_paid_at']);
}
if ($event === 'subscription.status_changed' && $status === 3 && $old === 2) {
// Доступ закончился. Проверьте, нет ли у пользователя другой активной записи на этот resource_id,
// прежде чем отзывать доступ у себя: при продлении старая запись закрывается одновременно с открытием новой.
maybeRevokeAccess((int)$_POST['telegram_id'], (int)$_POST['resource_id']);
}
markProcessed($dedupKey);
http_response_code(200);
Рекомендации:
- Отвечайте быстро (до 20 с) и кодом 200. Тяжёлую работу выполняйте после ответа или в фоне.
- Не доверяйте URL без секрета. Подписи у запросов нет — защищайте адрес случайным ключом в пути или query-параметре и проверяйте его.
- Не считайте
2 → 3отзывом доступа само по себе. При продлении старый период закрывается, а новый открывается — у пользователя может быть другая активная запись на тот жеresource_id. - Храните
subscription_idкаждой записи: по нему удобно сопоставлять уведомления с историей платежей в ЛК.
8. Где смотреть доставку
Журнал каждой отправки пишется в лог записи подписки (ЛК → Бот подписок → Пользователи → карточка подписки, блок «Лог») и в лог способа оплаты, событие «Webhook подписки»:
| Результат | Что значит |
|---|---|
sent | Доставлено; в деталях — HTTP-код, номер попытки и первые 2 КБ ответа |
failed | Все 3 попытки неудачны; в деталях — текст последней ошибки |
bad_url | Адрес не прошёл проверку безопасности в момент отправки (например, домен стал указывать на приватный IP) |
9. Частые вопросы
Приходят ли уведомления о рекуррентных (автоматических) платежах?
Да, всегда. Автосписание создаёт новые записи и меняет статусы точно так же, как ручная оплата — вы получите created и status_changed по каждому шагу (раздел 6).
Почему на одно продление приходит несколько уведомлений?
Потому что продление — это закрытие старого периода и открытие нового. Каждое изменение — отдельное событие.
Можно ли получать JSON вместо формы?
Нет, тело всегда application/x-www-form-urlencoded. Любой веб-фреймворк разбирает его как обычную POST-форму.
Уведомление об удалении записи?
Не отправляется. Удаление подписки администратором или отмена неоплаченного заказа пользователем убирает запись без вебхука.
Что если мой сервер был недоступен?
После 3 неудачных попыток уведомление помечается failed и больше не повторяется. Актуальное состояние подписок всегда можно получить через REST API бота подписок.