Пауза / возобновление / отмена
Три 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 в том же периоде создаст новое списание, а не вернёт старое. Это верно и в бою.