Подписки / Рекуррентные платежи
Подписки позволяют списывать с клиента деньги повторно по СБП-мандату — клиент один раз подтверждает согласие (мандат) в приложении банка, после чего ты инициируешь списания через API, не прося клиента платить каждый раз заново.
В боевом режиме подписки сейчас недоступны
В боевом режиме (xh_live_...) новые подписки не создаются и списания не проходят — в том числе по подпискам, которые уже active:
POST /subscriptionsотвечает501 RECURRING_NOT_SUPPORTED, подписка не создаётся;POST /subscriptions/:id/chargesпо активной подписке отвечает503 PROVIDER_NOT_CONFIGURED, платёж не создаётся.
Работают чтение (GET /subscriptions, GET /subscriptions/:id, GET /subscriptions/:id/charges), PATCH /subscriptions/:id, POST /:id/pause, POST /:id/resume и POST /:id/cancel. Песочница (xh_test_...) работает полностью — интеграцию проверяй там.
Повторять эти запросы бессмысленно: ответ не изменится. Когда подписки в боевом режиме снова заработают, X-Hub сообщит об этом. Ниже описано, как подписки работают, когда они доступны.
Подписки включаются для аккаунта
В боевом режиме подписки работают, только если они включены для твоего аккаунта, — чтобы подключить, напиши команде X-Hub. Пока не включены, POST /subscriptions отвечает 501 RECURRING_NOT_SUPPORTED (подписка не создаётся), а GET /subscriptions возвращает пустой список. В песочнице отдельного включения не нужно. Если на любой запрос к /subscriptions приходит 404 NOT_FOUND, подписки сейчас недоступны в API целиком — повтор не поможет, напиши в X-Hub.
Base URL тот же: https://api.x-hub.online/api/v1. Аутентификация — те же заголовки X-Api-Key / X-Api-Secret, что и во всём остальном API. Idempotency-Key (UUID) обязателен для POST /subscriptions и POST /subscriptions/:id/charges; lifecycle-эндпоинты (/pause, /resume, /cancel) его не требуют.
Ключевые термины
| Термин | Что это |
|---|---|
| Subscription (подписка) | Мандат на повторные списания. Создаётся тобой, подтверждается клиентом-плательщиком |
| Binding (привязка мандата) | Шаг подтверждения: клиент переходит по binding_url и разрешает списания в приложении банка. В этот же момент списывается первый период |
| Charge (списание) | Одно списание по активному мандату. Технически — это обычный платёж (Payment) с payment_method: "recurring" |
| Списание при привязке | Списание первого периода, которое инициирует платёжная система в момент подтверждения мандата — не ты. Приходит с external_id: null |
| Cap (потолок) | Лимит суммы: cap_amount_rub — на одно списание, cap_window_rub — суммарно за интервал |
| Period (период) | Календарный интервал списания (день / неделя / месяц). В одном периоде — одно списание (идемпотентность) |
Как это работает (общий поток)
- Ты создаёшь подписку:
POST /v1/subscriptions→ получаешьid,status: pending_bindingиbinding_url. Изредка ссылка ещё не готова в момент ответа — тогдаbinding_url: null,binding_url_pending: true, и ссылка приходит следом webhook'ом (см. Ссылка привязки готовится). - Отправляешь клиента на
binding_url— там он подтверждает мандат в приложении банка. - В момент подтверждения мандата с клиента списывается первый период — на сумму
amount_rubподписки. Это списание инициирует платёжная система, не ты. Оно приходит тебе обычнымpayment.status_changedсpayment_method: "recurring"и видно вGET /v1/subscriptions/:id/charges. Оно может прийти с задержкой — своё списание за этот период всё равно не делай, см. предупреждение ниже. - Подписка переходит в
active— прилетаетsubscription.status_changed(или узнаёшь опросомGET /v1/subscriptions/:id). - По активной подписке ты инициируешь последующие списания:
POST /v1/subscriptions/:id/charges(режим A — списание по твоему триггеру). - Каждое списание — это платёж; его финальный статус приходит webhook'ом
payment.status_changed(как у обычных платежей).
Не списывай сам сразу после привязки — клиент заплатит дважды
Подтверждение мандата запускает списание первого периода на стороне платёжной системы. Если ты, увидев active, дёрнешь POST /:id/charges за тот же период, а списание при привязке уже прошло или ещё в пути, ты рискуешь списать с клиента второй раз.
Списание при привязке не появилось — не повторяй его сам. Дождись payment.status_changed с payment_method: "recurring" или проверь GET /v1/subscriptions/:id/charges. Если списания нет и дальше — напиши в X-Hub с id подписки.
Защита на нашей стороне есть — одно списание на период: твой POST /charges за уже занятый период вернёт 200 и тот же самый платёж (тот, что сделала платёжная система при привязке), а не создаст новый. Но полагаться только на неё нельзя: она работает по календарному периоду, поэтому привязка 31-го числа и твоё списание 1-го числа попадут в разные месяцы — и оба спишутся.
Правильный порядок:
- Создал подписку → отправил клиента на
binding_url. - Дождался
subscription.status_changed→active. - Проверил
GET /v1/subscriptions/:id/charges. Если там уже есть списание — первый период оплачен, своё списание не делай. - Пусто — не списывай за этот период сам: жди вебхука или проверь
GET /v1/subscriptions/:id/chargesещё раз позже. Своё первоеPOST /chargesпланируй на следующий период.
Отличить списание при привязке от своего можно по external_id: у списаний, инициированных платёжной системой, он всегда null (см. Как отличить списание при привязке от своего).
Жизненный цикл подписки
Обычный путь подписки: pending_binding → active, когда плательщик подтвердил мандат. Дальше её можно сколько угодно раз останавливать и возобновлять (POST /pause, POST /resume), а отменить — из любого состояния. cancelled терминальный, и приходят в него двумя разными дорогами: из active или paused по твоему POST /cancel, а из pending_binding — когда мандат отозвали на стороне банка или ссылка привязки так и не выпустилась.
| Статус | Значение |
|---|---|
pending_binding |
Подписка создана, ждём подтверждения мандата клиентом. Твои списания через POST /:id/charges невозможны (409 CHARGE_MANDATE_INACTIVE). Но списание при привязке приходит именно в этом статусе — см. предупреждение ниже |
active |
Мандат подтверждён — можно списывать через POST /:id/charges |
paused |
Мерчант приостановил. Твои списания заблокированы (charge → 409). Возобновляется через /resume |
cancelled |
Терминальный. Мандат отменён мерчантом (/cancel), отозван на стороне банка/плательщика или привязка так и не состоялась — ссылка не была выпущена (см. binding_url_pending). Твои списания невозможны |
expired |
Терминальный. Зарезервированный статус — в текущей версии API подписка автоматически в expired не переводится |
Набор значений status закрыт этими пятью — новых мы не добавляем, поэтому жёсткий разбор статуса у тебя не сломается.
past_due — просрочка по мандату (отдельное поле, не статус)
Если банк не смог списать по мандату, он переходит в взыскание. Мандат при этом остаётся живым — повторное списание может пройти, — поэтому status честно остаётся active, а факт просрочки приходит отдельным булевым полем past_due:
Поле присутствует всегда (у нормальной подписки false) — и в объекте подписки, и в webhook'е subscription.status_changed.
Что с этим делать. past_due: true — сигнал, что клиент фактически не платит, хотя мандат формально активен. Типовая реакция: перевести аккаунт в grace-период, показать клиенту просьбу обновить платёжный способ, а через N дней ограничить сервис. Не полагайся на один только status — по нему такой мандат неотличим от исправного.
Смена past_due присылает subscription.status_changed с тем же status, что и раньше: в таком событии previous_status равен status — это и есть маркер «сменился под-статус, а не статус». Сравнивай past_due, а не только status.
rebind_required — мандат утрачен, нужна новая привязка
Если банк сообщает, что привязка по мандату не найдена (утрачена безвозвратно), повторные списания по этой подписке не пройдут — нужна новая привязка. В отличие от past_due (мандат жив, ретрай уместен), здесь status переходит в paused, а факт приходит отдельным булевым полем rebind_required:
Поле присутствует только со значением true (симметрично past_due, но противоположно по смыслу) — и в объекте подписки (GET /subscriptions/:id), и в webhook'е subscription.status_changed. Отличай от обычной паузы (paused без rebind_required) и от past_due (мандат ещё жив).
Что с этим делать. Не ретрай списание по этой подписке — оно не пройдёт. Предложи клиенту оформить подписку заново (новая привязка мандата); старую можно отменить.
pending_binding не означает «денег не двигали»
Списание первого периода происходит в момент подтверждения мандата и гонится с вебхуком активации подписки. Порядок не гарантирован: платёж-списание может прийти к тебе раньше, чем subscription.status_changed → active, то есть в тот момент, когда GET /subscriptions/:id ещё отдаёт pending_binding.
Поэтому не отбрасывай payment.status_changed с payment_method: "recurring" только на основании того, что подписка ещё не active. Деньги у плательщика уже списаны.
То же касается терминальных статусов: если отзыв мандата не сработал на стороне банка, списание может прийти по cancelled/paused подписке. Такие случаи мы ловим и разбираем вручную — но если увидел такое у себя, напиши нам.
Идентификация режима
Как и в остальном API, в тестовом режиме ответы подписок содержат "sandbox": true; в боевом — поле отсутствует.