Перейти к содержанию

Подписки / Рекуррентные платежи

Подписки позволяют списывать с клиента деньги повторно по СБП-мандату — клиент один раз подтверждает согласие (мандат) в приложении банка, после чего ты инициируешь списания через 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 (период) Календарный интервал списания (день / неделя / месяц). В одном периоде — одно списание (идемпотентность)

Как это работает (общий поток)

  1. Ты создаёшь подписку: POST /v1/subscriptions → получаешь id, status: pending_binding и binding_url. Изредка ссылка ещё не готова в момент ответа — тогда binding_url: null, binding_url_pending: true, и ссылка приходит следом webhook'ом (см. Ссылка привязки готовится).
  2. Отправляешь клиента на binding_url — там он подтверждает мандат в приложении банка.
  3. В момент подтверждения мандата с клиента списывается первый период — на сумму amount_rub подписки. Это списание инициирует платёжная система, не ты. Оно приходит тебе обычным payment.status_changed с payment_method: "recurring" и видно в GET /v1/subscriptions/:id/charges. Оно может прийти с задержкой — своё списание за этот период всё равно не делай, см. предупреждение ниже.
  4. Подписка переходит в active — прилетает subscription.status_changed (или узнаёшь опросом GET /v1/subscriptions/:id).
  5. По активной подписке ты инициируешь последующие списания: POST /v1/subscriptions/:id/charges (режим A — списание по твоему триггеру).
  6. Каждое списание — это платёж; его финальный статус приходит 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-го числа попадут в разные месяцы — и оба спишутся.

Правильный порядок:

  1. Создал подписку → отправил клиента на binding_url.
  2. Дождался subscription.status_changed → active.
  3. Проверил GET /v1/subscriptions/:id/charges. Если там уже есть списание — первый период оплачен, своё списание не делай.
  4. Пусто — не списывай за этот период сам: жди вебхука или проверь 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:

{ "id": "sub_550e8400-...", "status": "active", "past_due": true }

Поле присутствует всегда (у нормальной подписки 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:

{ "id": "sub_550e8400-...", "status": "paused", "rebind_required": true }

Поле присутствует только со значением 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; в боевом — поле отсутствует.