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

Пауза / возобновление / отмена

Три lifecycle-перехода. Все требуют только заголовки авторизации (тело не нужно). Возвращают обновлённый объект подписки.

# Пауза: 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, Verify signature и полный пример запроса). Отличить списание можно по payment_method: "recurring"; сопоставить с подпиской — по полю subscription_id в самом payload'е ("sub_..."), а с периодом — по period_ref ("2026-07"), без обращения к API. Свой external_id для этого больше не нужен.

{
  "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'ов).

{
  "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.

Тем же событием приезжает ссылка привязки, если на момент ответа POST /subscriptions она ещё не была готова: status остаётся pending_binding (и previous_status равен ему), а в payload'е приходит заполненный binding_url и binding_url_pending: false. Новых типов событий мы для этого не заводили — набор событий закрыт, и обработчик, который уже слушает subscription.status_changed, получает ссылку без единой правки маршрутизации. Подробнее — binding_url_pending.

Это удобно, чтобы узнавать об активации мандата (клиент подтвердил привязку), о просрочке и об отзыве подписки на стороне банка/плательщика — без опроса. Как fallback (или для проверки текущего состояния) всегда доступен GET /subscriptions/:id.

Порядок событий при привязке не гарантирован

Подтверждение мандата порождает два независимых события: активацию подписки и списание первого периода. Они приходят разными 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 (ключи xh_test_...): ответы содержат "sandbox": true, реальные СБП-мандаты не создаются, внешняя платёжная система не вызывается.

binding_url — ссылка вида https://app.x-hub.online/sandbox/bind/sbx_sub_<hash>. Открывать её не нужно: реального плательщика в песочнице нет, мандат подтверждается сам. Если открыть — страница честно об этом скажет («мандат уже подтверждён»), никаких действий она не предлагает и статус не меняет. Текущее состояние подписки читай через 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)
Списание при привязке не происходит происходит: первый период списывает платёжная система, может прийти с задержкой
subscription.status_changed (pending_binding → active) приходит приходит
POST /:id/charges работает сразу после создания работает после активации мандата

Главное расхождение песочницы с боем — списание при привязке

В песочнице нет плательщика, поэтому дебета при подтверждении мандата не происходит: сразу после POST /subscriptions список GET /:id/charges пуст, и твой первый POST /charges создаёт первое и единственное списание периода.

В бою иначе: списание за текущий период делает платёжная система при подтверждении мандата, и оно может прийти с задержкой. Интеграция, которую ты отладил в песочнице по схеме «создал подписку → сразу списал», в бою рискует двойным дебетом клиента (либо, если период совпал, вернёт 200 с чужим id платежа).

Поэтому сценарий «первое списание» песочницей не проверяется. Обязательно предусмотри в коде ветку из раздела Как это работает: проверить GET /v1/subscriptions/:id/charges перед первым собственным списанием и, если там пусто, не списывать за этот период самому, а дождаться payment.status_changed — даже если в песочнице эта ветка всегда срабатывает вхолостую.

Сейчас боевые подписки недоступны — см. предупреждение в начале раздела.

Остальная механика песочницы совпадает с боевой: идемпотентность по периоду, cap_amount_rub / cap_window_rub, lifecycle-переходы pause / resume / cancel и вебхуки работают так же.

Списание в песочнице само становится paid через 30 секунд

POST /:id/charges в песочнице возвращает платёж в статусе pending — это нормально и это не тупик. Песочное списание — обычный песочный платёж, поэтому на него действуют те же magic-токены и авто-симуляция, что и на 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, пока не нажмёшь кнопку на песочной странице

Не опрашивай /charges в цикле, ожидая мгновенного paid

Типичная ошибка: сразу после POST /charges начать поллить GET /subscriptions/:id/charges, увидеть pending и решить, что списание не работает. Просто подожди — по умолчанию через 30 секунд прилетит payment.status_changed со status: paid и payment_method: "recurring".

Провал списания тестируется ТОЛЬКО магическим токеном

В песочнице нет плательщика, у которого могут кончиться деньги, и нет банка, который может отозвать мандат, — поэтому «естественного» провала списания не существует. Единственный способ прогнать свою ветку dunning'а:

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'а. Полный список причин и рекомендованных действий — причина отказа.

Токен ищется как отдельное слово (\bxhub_test_fail\b), поэтому его можно дописать к своему external_id через пробел или дефис — сопоставление на твоей стороне не сломается. У списаний description не передаётся, так что токен кладётся только в external_id.

failed-списание освобождает период. Деньги не двигались, поэтому повторный POST /charges в том же периоде создаст новое списание, а не вернёт старое. Это верно и в бою.