--- title: "Подписки" url: "https://docs.x-hub.online/subscriptions/" description: "Рекуррентные платежи: термины, общий поток и жизненный цикл подписки." relatedDocuments: - "https://docs.x-hub.online/subscriptions/api/" - "https://docs.x-hub.online/subscriptions/consent-window/" - "https://docs.x-hub.online/subscriptions/manage/" --- # Подписки / Рекуррентные платежи { #subscriptions } Подписки позволяют списывать с клиента деньги **повторно по СБП-мандату** — клиент один раз подтверждает согласие (мандат) в приложении банка, после чего ты инициируешь списания через API, не прося клиента платить каждый раз заново. !!! warning "В боевом режиме подписки сейчас недоступны" В боевом режиме (`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 сообщит об этом. Ниже описано, как подписки работают, когда они доступны. !!! info "Подписки включаются для аккаунта" В боевом режиме подписки работают, только если они включены для твоего аккаунта, — чтобы подключить, напиши команде 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 (период) | Календарный интервал списания (день / неделя / месяц). В одном периоде — одно списание (идемпотентность) | ## Как это работает (общий поток) { #kak-eto-rabotaet } 1. Ты создаёшь подписку: `POST /v1/subscriptions` → получаешь `id`, `status: pending_binding` и `binding_url`. Изредка ссылка ещё не готова в момент ответа — тогда `binding_url: null`, `binding_url_pending: true`, и ссылка приходит следом webhook'ом (см. [Ссылка привязки готовится](./api.md#binding-url-pending)). 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` (как у обычных платежей). !!! danger "Не списывай сам сразу после привязки — клиент заплатит дважды" Подтверждение мандата запускает списание первого периода на стороне платёжной системы. Если ты, увидев `active`, дёрнешь `POST /:id/charges` за тот же период, а списание при привязке уже прошло или ещё в пути, ты рискуешь списать с клиента второй раз. **Списание при привязке не появилось — не повторяй его сам.** Дождись `payment.status_changed` с `payment_method: "recurring"` или проверь `GET /v1/subscriptions/:id/charges`. Если списания нет и дальше — напиши в X-Hub с `id` подписки. Защита на нашей стороне есть — [одно списание на период](./api.md#idempotency-charge): твой `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` (см. [Как отличить списание при привязке от своего](./api.md#raspoznat-binding-charge)). ## Жизненный цикл подписки { #zhiznennyy-tsikl-podpiski } Обычный путь подписки: `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`](./api.md#binding-url-pending)). Твои списания невозможны | | `expired` | Терминальный. Зарезервированный статус — в текущей версии API подписка автоматически в `expired` не переводится | Набор значений `status` закрыт этими пятью — новых мы не добавляем, поэтому жёсткий разбор статуса у тебя не сломается. !!! danger "`past_due` — просрочка по мандату (отдельное поле, не статус)" Если банк не смог списать по мандату, он переходит в **взыскание**. Мандат при этом **остаётся живым** — повторное списание может пройти, — поэтому `status` честно остаётся `active`, а факт просрочки приходит отдельным булевым полем **`past_due`**: ```json { "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`. !!! danger "`rebind_required` — мандат утрачен, нужна новая привязка" Если банк сообщает, что привязка по мандату не найдена (утрачена безвозвратно), повторные списания по этой подписке **не пройдут** — нужна **новая привязка**. В отличие от `past_due` (мандат жив, ретрай уместен), здесь `status` переходит в **`paused`**, а факт приходит отдельным булевым полем **`rebind_required`**: ```json { "id": "sub_550e8400-...", "status": "paused", "rebind_required": true } ``` Поле присутствует **только со значением `true`** (симметрично `past_due`, но противоположно по смыслу) — и в объекте подписки (`GET /subscriptions/:id`), и в webhook'е `subscription.status_changed`. Отличай от обычной паузы (`paused` без `rebind_required`) и от `past_due` (мандат ещё жив). **Что с этим делать.** Не ретрай списание по этой подписке — оно не пройдёт. Предложи клиенту оформить подписку заново (новая привязка мандата); старую можно отменить. !!! warning "`pending_binding` не означает «денег не двигали»" Списание первого периода происходит в момент подтверждения мандата и **гонится** с вебхуком активации подписки. Порядок не гарантирован: платёж-списание может прийти к тебе **раньше**, чем `subscription.status_changed` → `active`, то есть в тот момент, когда `GET /subscriptions/:id` ещё отдаёт `pending_binding`. Поэтому не отбрасывай `payment.status_changed` с `payment_method: "recurring"` только на основании того, что подписка ещё не `active`. Деньги у плательщика уже списаны. То же касается терминальных статусов: если отзыв мандата не сработал на стороне банка, списание может прийти по `cancelled`/`paused` подписке. Такие случаи мы ловим и разбираем вручную — но если увидел такое у себя, напиши нам. !!! note "Идентификация режима" Как и в остальном API, в тестовом режиме ответы подписок содержат `"sandbox": true`; в боевом — поле отсутствует.