--- title: "Управление подпиской" url: "https://docs.x-hub.online/subscriptions/manage/" description: "Пауза, возобновление, отмена, уведомления и песочница по подпискам." relatedDocuments: - "https://docs.x-hub.online/subscriptions/" - "https://docs.x-hub.online/subscriptions/api/" - "https://docs.x-hub.online/subscriptions/consent-window/" - "https://docs.x-hub.online/webhooks/" - "https://docs.x-hub.online/start/sandbox/" - "https://docs.x-hub.online/payments/" --- # Пауза / возобновление / отмена Три lifecycle-перехода. Все требуют только заголовки авторизации (тело не нужно). Возвращают обновлённый объект подписки. ```bash # Пауза: active → paused curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../pause \ -H "X-Api-Key: ..." -H "X-Api-Secret: ..." # Возобновление: paused → active curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../resume \ -H "X-Api-Key: ..." -H "X-Api-Secret: ..." # Отмена: active|paused → cancelled (терминально) curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../cancel \ -H "X-Api-Key: ..." -H "X-Api-Secret: ..." ``` | Действие | Переход | Правила | |---|---|---| | `POST /:id/pause` | `active` → `paused` | Только из `active`. Иначе `409 INVALID_STATE` | | `POST /:id/resume` | `paused` → `active` | Только из `paused`. Иначе `409 INVALID_STATE` | | `POST /:id/cancel` | `active` / `paused` → `cancelled` | **Терминально.** Из `cancelled` / `expired` → `409 INVALID_STATE`. Отменить `pending_binding` через этот эндпоинт нельзя (только `active`/`paused`) | Ошибки переходов: | HTTP | Code | Причина | |---|---|---| | 404 | `SUBSCRIPTION_NOT_FOUND` | Подписка не найдена / чужая / другого режима | | 409 | `INVALID_STATE` | Переход недопустим из текущего статуса | | 409 | `CONFLICT` | Подписку одновременно меняли — повтори запрос | # Webhooks по подпискам **Списания.** Каждое списание — это платёж, поэтому его изменения приходят обычным событием **`payment.status_changed`** (тот же payload и та же проверка подписи, что у платежей — см. [Webhooks](../webhooks/index.md#webhooks), [Verify signature](../webhooks/index.md#verify-signature) и [полный пример запроса](../webhooks/index.md#polnyy-primer-zaprosa)). Отличить списание можно по `payment_method: "recurring"`; сопоставить с подпиской — по полю `subscription_id` в самом payload'е (`"sub_..."`), а с периодом — по `period_ref` (`"2026-07"`), без обращения к API. Свой `external_id` для этого больше не нужен. ```json { "event": "payment.status_changed", "payment_id": "pay_660e8400-...", "external_id": "sub-charge-2026-07", "status": "paid", "previous_status": "pending", "amount_rub": "500.00", "actual_amount_rub": "500.00", "amount_usdt": null, "payment_method": "recurring", "subscription_id": "sub_550e8400-...", "period_ref": "2026-07", "refund_pending": false, "timestamp": "2026-07-18T10:00:12Z", "metadata": null } ``` **Изменения статуса самой подписки.** Когда подписка меняет статус — подтверждение мандата (`active`), пауза (`paused`), отмена мерчантом или отзыв на стороне банка/плательщика (`cancelled`) — прилетает событие **`subscription.status_changed`** (та же проверка подписи, что у остальных webhook'ов). ```json { "event": "subscription.status_changed", "subscription_id": "sub_550e8400-...", "external_id": "sub-order-12345", "status": "active", "past_due": false, "binding_url": "https://sub.nspk.ru/abc-def-ghi", "binding_url_pending": false, "previous_status": "pending_binding", "timestamp": "2026-07-19T10:00:00Z" } ``` Это же событие приходит, когда меняется под-статус **`past_due`** (банк начал или прекратил взыскание по мандату). В таком случае `status` не меняется, и `previous_status` равен `status` — читай `past_due`. Подробнее — [про `past_due`](./index.md#zhiznennyy-tsikl-podpiski). Тем же событием приезжает **ссылка привязки**, если на момент ответа `POST /subscriptions` она ещё не была готова: `status` остаётся `pending_binding` (и `previous_status` равен ему), а в payload'е приходит заполненный `binding_url` и `binding_url_pending: false`. Новых типов событий мы для этого не заводили — набор событий закрыт, и обработчик, который уже слушает `subscription.status_changed`, получает ссылку без единой правки маршрутизации. Подробнее — [`binding_url_pending`](./api.md#binding-url-pending). Это удобно, чтобы узнавать об активации мандата (клиент подтвердил привязку), о просрочке и об отзыве подписки на стороне банка/плательщика — без опроса. Как fallback (или для проверки текущего состояния) всегда доступен `GET /subscriptions/:id`. !!! warning "Порядок событий при привязке не гарантирован" Подтверждение мандата порождает **два** независимых события: активацию подписки и списание первого периода. Они приходят разными webhook'ами и могут прийти в любом порядке — `payment.status_changed` (`payment_method: "recurring"`, `external_id: null`) вполне может опередить `subscription.status_changed` → `active`. Обработчик должен переживать оба порядка: не отбрасывай списание из-за того, что подписка у тебя ещё числится `pending_binding`, и не считай отсутствие списаний на момент активации доказательством того, что первый период не оплачен. Правда о списаниях — в `GET /subscriptions/:id/charges`. **Неуспешные списания (dunning).** Отдельного `subscription.charge_failed` нет — но это и не нужно: **каждое** списание это платёж, и его провал приходит обычным `payment.status_changed` со `status: "failed"` и `payment_method: "recurring"` (см. выше). Если банк перевёл мандат во взыскание, дополнительно поднимается флаг `past_due` у подписки (и приходит `subscription.status_changed`) — это ловит и те случаи, когда неудачное списание инициировала сама платёжная система, а не ты. Подписка и период указаны в том же payload'е — поля `subscription_id` и `period_ref`; свой `external_id` для сопоставления не нужен. # Sandbox { #sandbox-podpiski } Подписки полностью работают в sandbox (ключи `xh_test_...`): ответы содержат `"sandbox": true`, реальные СБП-мандаты не создаются, внешняя платёжная система не вызывается. `binding_url` — ссылка вида `https://app.x-hub.online/sandbox/bind/sbx_sub_`. Открывать её не нужно: реального плательщика в песочнице нет, мандат подтверждается сам. Если открыть — страница честно об этом скажет («мандат уже подтверждён»), никаких действий она не предлагает и статус не меняет. Текущее состояние подписки читай через `GET /api/v1/subscriptions/:id`. | | Sandbox | Production | |---|---|---| | Статус сразу после `POST /subscriptions` | **`active`** | `pending_binding` | | Шаг привязки клиентом | нет — мандат подтверждается сам | клиент подтверждает мандат по `binding_url` | | `binding_url` | заглушка на `app.x-hub.online` | реальная ссылка СБП | | `binding_url_pending` | всегда `false` — заглушка готова сразу | обычно `false`, но изредка `true`: ссылка выпускается асинхронно (см. [`binding_url_pending`](./api.md#binding-url-pending)) | | **Списание при привязке** | **не происходит** | происходит: первый период списывает платёжная система, может прийти с задержкой | | `subscription.status_changed` (`pending_binding` → `active`) | приходит | приходит | | `POST /:id/charges` | работает сразу после создания | работает после активации мандата | !!! danger "Главное расхождение песочницы с боем — списание при привязке" В песочнице нет плательщика, поэтому **дебета при подтверждении мандата не происходит**: сразу после `POST /subscriptions` список `GET /:id/charges` пуст, и твой первый `POST /charges` создаёт первое и единственное списание периода. В бою иначе: списание за текущий период делает платёжная система при подтверждении мандата, и оно может прийти с задержкой. Интеграция, которую ты отладил в песочнице по схеме «создал подписку → сразу списал», в бою рискует **двойным дебетом клиента** (либо, если период совпал, вернёт `200` с чужим `id` платежа). Поэтому сценарий «первое списание» песочницей **не проверяется**. Обязательно предусмотри в коде ветку из раздела [Как это работает](./index.md#kak-eto-rabotaet): проверить `GET /v1/subscriptions/:id/charges` перед первым собственным списанием и, если там пусто, **не списывать за этот период самому**, а дождаться `payment.status_changed` — даже если в песочнице эта ветка всегда срабатывает вхолостую. Сейчас боевые подписки недоступны — см. [предупреждение в начале раздела](./index.md#subscriptions). Остальная механика песочницы совпадает с боевой: идемпотентность по периоду, `cap_amount_rub` / `cap_window_rub`, lifecycle-переходы `pause` / `resume` / `cancel` и вебхуки работают так же. ## Списание в песочнице само становится `paid` через 30 секунд { #sandbox-charge-autopay } `POST /:id/charges` в песочнице возвращает платёж в статусе `pending` — **это нормально и это не тупик**. Песочное списание — обычный песочный платёж, поэтому на него действуют те же [magic-токены и авто-симуляция](../start/sandbox.md#magic-tokens), что и на `POST /payments`: | Что передал в `external_id` списания | Через сколько | Итоговый статус | |---|---:|---| | (ничего) | 30 сек | `paid` | | `xhub_test_paid` | 5 сек | `paid` | | `xhub_test_fail` | 5 сек | `failed` c `failure_reason: "insufficient_funds"` | | `xhub_test_expire` | 60 сек | `expired` | | `xhub_test_mismatch` | 5 сек | `amount_mismatch` | | `xhub_test_manual` | — | остаётся `pending`, пока не нажмёшь кнопку на песочной странице | !!! tip "Не опрашивай `/charges` в цикле, ожидая мгновенного `paid`" Типичная ошибка: сразу после `POST /charges` начать поллить `GET /subscriptions/:id/charges`, увидеть `pending` и решить, что списание не работает. Просто подожди — по умолчанию через **30 секунд** прилетит `payment.status_changed` со `status: paid` и `payment_method: "recurring"`. !!! warning "Провал списания тестируется ТОЛЬКО магическим токеном" В песочнице нет плательщика, у которого могут кончиться деньги, и нет банка, который может отозвать мандат, — поэтому «естественного» провала списания не существует. Единственный способ прогнать свою ветку dunning'а: ```bash curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_.../charges \ -H "X-Api-Key: xh_test_..." -H "X-Api-Secret: ..." \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "amount_rub": "500.00", "external_id": "sub-charge-2026-07 xhub_test_fail" }' ``` Через 5 секунд придёт `payment.status_changed` со `status: "failed"`, `payment_method: "recurring"`, `subscription_id` и `failure_reason: "insufficient_funds"` — на этом payload'е и стоит писать ветку dunning'а. Полный список причин и рекомендованных действий — [причина отказа](../payments/index.md#failure-reason). Токен ищется как отдельное слово (`\bxhub_test_fail\b`), поэтому его можно дописать к своему `external_id` через пробел или дефис — сопоставление на твоей стороне не сломается. У списаний `description` не передаётся, так что токен кладётся только в `external_id`. **`failed`-списание освобождает период.** Деньги не двигались, поэтому повторный `POST /charges` в том же периоде создаст новое списание, а не вернёт старое. Это верно и в бою. ---