Создать подписку — POST /subscriptions
Создаёт подписку и инициирует привязку мандата. Возвращает binding_url, на который надо отправить клиента.
curl -X POST https://api.x-hub.online/api/v1/subscriptions \
-H "X-Api-Key: xh_live_..." \
-H "X-Api-Secret: YOUR_API_SECRET" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_rub": "500.00",
"interval": "month",
"interval_count": 1,
"description": "Подписка VPN, тариф Про",
"external_id": "sub-order-12345",
"client_ref": "user_abc",
"client_phone": "+79991234567"
}'
Body
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
amount_rub |
string | ✓ | Плановая сумма списания. Decimal, до 2 знаков ("500.00"), строго больше нуля — иначе 400 INVALID_AMOUNT. Целая часть — до 10 знаков |
interval |
string | ✓ | Период списания: "day", "week" или "month" |
description |
string | ✓ | За что подписка — плательщик видит это заголовком в окне привязки, под именем магазина. Непустая строка после обрезки пробелов, до 32 символов — иначе 400 VALIDATION_ERROR |
interval_count |
int | Множитель периода. По умолчанию 1. Границы зависят от interval: day — 1…365, week — 1…52, month — 1…12. Документированные пресеты: day×1 (ежедневно), day×3, week×1, week×2, month×1 — см. периодичность |
|
external_id |
string | Твой ID подписки (для сопоставления). До 256 символов | |
client_ref |
string | Твой идентификатор клиента-плательщика. До 256 символов. Не возвращается в ответах (§ приватность) | |
client_phone |
string | Телефон клиента, до 32 символов. Хранится только маской, полный номер наружу не отдаётся | |
shop_id |
string (uuid) | Магазин подписки, если в аккаунте несколько магазинов (голый uuid, как в вебхуке). Все списания подписки идут в этот магазин. Не передан — магазин по умолчанию. Отказы те же, что у платежа: 400 SHOP_NOT_FOUND, 403 SHOP_DISABLED, 422 SHOP_MARKUP_NOT_SET — см. магазин платежа. Повтор с тем же Idempotency-Key, но другим shop_id — 409 IDEMPOTENCY_KEY_REUSED |
|
method_id |
string (uuid) | Явный выбор терминала из списка GET /payment-methods — см. выбор терминала. Только если терминалы включены для твоего аккаунта, иначе 400 METHOD_SELECTION_DISABLED |
|
success_url |
string (url) | Куда вернуть плательщика после успешной привязки. Только схема http или https, до 2048 символов. Задан — в окне после оплаты появится кнопка «В магазин» с автовозвратом через 5 секунд. Не задан — плательщик останется на окне привязки | |
binding_charge |
string | Режим привязки. "first" (по умолчанию) — привязка = первое списание подписки на amount_rub. "none" — привязка без списания: плательщик подтверждает подписку QR'ом на символический ₽1, который банк не списывает; деньги начнут двигаться с первого списания по токену. После создания не меняется |
Потолок списания (cap_amount_rub) задавать не нужно: он всегда равен amount_rub — списание выше плановой суммы невозможно по построению. Тело принимает только перечисленные поля: незнакомый ключ (в том числе cap_amount_rub или cap_window_rub по старой привычке) — 400 VALIDATION_ERROR, а не молчаливое игнорирование.
Отказ привязки в режиме binding_charge=none приходит как expired, а не failed
Это сознательно: expired — не терминальный статус, он допускает поздний успех (плательщик подтвердил QR, когда попытка уже истекла). Семантика переходов статусов платежа — Edge cases: expired.
Ответ 201
{
"id": "sub_550e8400-e29b-41d4-a716-446655440000",
"status": "pending_binding",
"amount_rub": "500.00",
"interval": "month",
"interval_count": 1,
"cap_amount_rub": "500.00",
"external_id": "sub-order-12345",
"description": "Подписка VPN, тариф Про",
"binding_url": "https://pay.x-hub.online/p/s/550e8400-e29b-41d4-a716-446655440000",
"binding_url_pending": false,
"current_period_start": null,
"current_period_end": null,
"past_due": false,
"binding_charge": "first"
}
binding_url ведёт на окно X-Hub (или — на аккаунтах со старой схемой — в платёжное окно): куда попадёт плательщик и что увидит — привязка через окно X-Hub. У подписки с капом, равным сумме, cap_amount_rub в ответе всегда дублирует amount_rub; cap_window_rub в ответе появляется только у подписок, созданных до этой схемы и имеющих оконный лимит.
Что дальше: отправь клиента на binding_url.
Рекомендуем передавать success_url
Без него после успешной привязки плательщик остаётся на окне X-Hub. С ним он
возвращается в твой магазин: кнопка «В магазин» с автовозвратом через 5 секунд.
Это закрывает последний шаг воронки — клиент видит завершение у тебя, а не у нас. current_period_start и current_period_end заполняются в момент активации мандата.
cap_window_rub в ответе
Если ты не передавал cap_window_rub, поле в ответе отсутствует целиком (а не приходит null). Не полагайся на его наличие.
Идемпотентность
Повтор POST /subscriptions с тем же Idempotency-Key вернёт ту же подписку (HTTP 200, без создания второго мандата).
Ссылка привязки может готовиться — binding_url_pending
Ссылку привязки выпускает не X-Hub, а платёжная система, и делает она это асинхронно. Обычно ссылка готова к моменту ответа, но иногда — нет. Раньше мы в такой ситуации отвечали ошибкой; теперь подписка создаётся всегда, а ссылка догоняет отдельным событием.
Признак — булево поле binding_url_pending. Оно присутствует всегда (у обычной подписки false) — и в ответе POST /subscriptions, и в GET /subscriptions/:id, и в webhook'е subscription.status_changed.
{
"id": "sub_550e8400-...",
"status": "pending_binding",
"binding_url": null,
"binding_url_pending": true
}
Что делать. Ничего не переспрашивай и, главное, не создавай подписку заново — мандат уже существует, повторное создание даст второй мандат на того же плательщика. Дождись webhook'а subscription.status_changed: он придёт с тем же status: "pending_binding" (то есть previous_status будет равен status — та же идиома, что у past_due), но уже с заполненным binding_url и binding_url_pending: false. Опрос GET /subscriptions/:id работает как всегда — как fallback, если webhook'и у тебя ещё не настроены.
Рекомендованный порядок:
POST /subscriptions→201.binding_urlне пустой → отправляй клиента, как обычно (это подавляющее большинство случаев).binding_urlпустой иbinding_url_pending: true→ покажи клиенту «готовим ссылку» и жди события со ссылкой; типовое ожидание — секунды.- Пришло событие со ссылкой → отправляй клиента на неё. Дальше поток обычный.
Если ссылка так и не появится
Мы ждём ссылку до часа. Если платёжная система её так и не выпустит (или сама закроет привязку), подписка переводится в терминальный cancelled — придёт subscription.status_changed с previous_status: "pending_binding". Списаний по такой подписке не было и не будет; чтобы повторить попытку, создай новую подписку (с новым Idempotency-Key).
Как отличить «готовится» от «что-то не так»
binding_url: null возможен по двум причинам, и binding_url_pending их разводит:
binding_url |
binding_url_pending |
Что это значит | Что делать |
|---|---|---|---|
| ссылка | false |
норма | отправить клиента |
null |
true |
ссылка ещё выпускается | ждать событие |
null |
false |
нештатная ситуация: ссылки не будет | повторить запрос или написать в X-Hub |
Совместимость
Поле только добавлено — существующие интеграции, читающие binding_url, продолжают работать. Если твой код падает на пустом binding_url (например, сразу подставляет его в редирект), добавь проверку binding_url_pending — теперь такой ответ штатно возможен.
Получить подписку — GET /subscriptions/:id
curl https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-... \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
Ответ — тот же формат, что при создании, с актуальным status.
Поля ответа
| Поле | Тип | Что означает |
|---|---|---|
id |
string | ID подписки в формате sub_<uuid> |
status |
string | Текущий статус (см. жизненный цикл) |
past_due |
bool | Мандат в просрочке у банка. Присутствует всегда; status при этом остаётся active — см. про past_due |
rebind_required |
bool | Мандат утрачен: нужна новая привязка, списания больше не пройдут. Присутствует только при true; status при этом paused — см. про rebind_required |
amount_rub |
string | Плановая сумма списания за период |
interval |
string | Календарный интервал: day / week / month |
interval_count |
int | Множитель, который ты передал при создании. На расчёт периода не влияет (см. выше) |
cap_amount_rub |
string | Потолок на одно списание |
cap_window_rub |
string | Оконный потолок. Отсутствует, если не задан |
external_id |
string / null | Твой ID подписки |
binding_url |
string / null | Ссылка на подтверждение мандата. Может быть null (см. выше) |
binding_url_pending |
bool | Ссылка ещё выпускается платёжной системой. Присутствует всегда; при true жди subscription.status_changed со ссылкой и не создавай подписку заново — см. binding_url_pending |
current_period_start |
string / null | ISO-8601. Начало текущего периода мандата. До активации null |
current_period_end |
string / null | ISO-8601. Конец текущего периода мандата — до этого момента мандат оплачен. До активации null; у мандатов, созданных до 08.08.2026, может оставаться null, пока платёжная система не пришлёт очередное обновление |
sandbox |
bool | Только в тестовом режиме, значение true. В боевом поле отсутствует |
Чего в объекте подписки НЕТ
В текущей версии API подписка не отдаёт:
- дату следующего списания — в режиме A следующее списание инициируешь ты сам, поэтому расписание ведёшь у себя;
- дату последнего списания — бери её из
GET /subscriptions/:id/charges(список отсортирован по убываниюcreated_at, первый элемент — самое свежее списание); client_refи полныйclient_phone— они принимаются, но наружу не возвращаются (приватность плательщика).
Период считать вручную не нужно: ключ периода каждого списания приходит готовым в поле period_ref объекта платежа и webhook'а payment.status_changed — см. идемпотентность списания.
Ошибка 404 SUBSCRIPTION_NOT_FOUND — если подписка не принадлежит твоему мерчанту или относится к другому режиму (test/live). Чужая подписка неотличима от несуществующей (никогда не 403).
Список подписок — GET /subscriptions
Query параметры
| Параметр | Тип | По умолчанию |
|---|---|---|
page |
int | 1 (макс 250000) |
per_page |
int | 20 (макс 100) |
status |
string | — (все). Один из pending_binding / active / paused / cancelled / expired |
Ответ
{
"data": [
{ "id": "sub_...", "status": "active", "amount_rub": "500.00", "interval": "month" }
],
"pagination": {
"page": 1,
"per_page": 20,
"total": 12,
"total_pages": 1
}
}
Изменить подписку — PATCH /subscriptions/:id
Меняет только суммы/потолки. Прорации нет: новая amount_rub действует со следующего списания, уже созданные списания не пересчитываются. interval, interval_count, external_id — не меняются.
curl -X PATCH https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-... \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Content-Type: application/json" \
-d '{ "amount_rub": "450.00", "cap_amount_rub": "450.00" }'
Body (изменяемые поля)
| Поле | Тип | Описание |
|---|---|---|
amount_rub |
string | Новая плановая сумма списания. Строго больше нуля — иначе 400 INVALID_AMOUNT |
cap_amount_rub |
string | Новый потолок на одно списание |
cap_window_rub |
string / null | Новый оконный потолок. Передай null, чтобы снять оконный лимит |
Нужно передать хотя бы одно из трёх полей, иначе 400 VALIDATION_ERROR. Ответ — обновлённый объект подписки.
Ограничения
- Правка разрешена не во всех статусах. Подписки в
paused,cancelledиexpiredне изменяются —409 INVALID_STATE. Отменённый или истёкший мандат остаётся неизменным для выгрузок и сверок; ждать от него «подготовки к новой сумме» не нужно — новая подписка оформляется заново. - Итоговая
amount_rubне выше итоговогоcap_amount_rub— с учётом уже лежащих в подписке значений:400 INVALID_AMOUNT, ничего не записано. Иначе каждое будущее списание отбивалось бы409 CAP_EXCEEDED.
Гонка с отменой закрыта: если cancel прошёл между твоим чтением и записью, патч не применится — ответ тот же 409 INVALID_STATE.
Список списаний — GET /subscriptions/:id/charges
Возвращает историю списаний (это платежи с payment_method: "recurring") в формате платежей. Сюда попадают и твои списания через POST /:id/charges, и списание, которое платёжная система сделала сама при подтверждении мандата. Сортировка — по убыванию created_at (первый элемент = самое свежее списание).
curl "https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../charges?page=1&per_page=20" \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
Query параметры
| Параметр | Тип | По умолчанию |
|---|---|---|
page |
int | 1 (макс 250000) |
per_page |
int | 20 (макс 100) |
Ответ
{
"data": [
{
"id": "pay_...",
"status": "paid",
"amount_rub": "500.00",
"payment_method": "recurring",
"external_id": "sub-charge-2026-07",
"subscription_id": "sub_550e8400-...",
"period_ref": "2026-07",
"created_at": "2026-07-18T10:00:00Z"
}
],
"pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 }
}
Формат каждого элемента — точно такой же, как у обычного платежа (GET /payments/:id), см. Платежи. Отличие только в payment_method: "recurring".
Как отличить списание при привязке от своего
По полю external_id:
external_id |
Кто инициировал |
|---|---|
твоя строка (если ты её передавал в POST /charges) |
ты — списание режима A |
null |
платёжная система — списание при привязке мандата |
Списаниям, которые инициирует платёжная система, external_id присвоить некому, поэтому он всегда null. Обратное, строго говоря, не гарантировано: если ты вызвал POST /charges без external_id, твоё списание тоже придёт с null.
Практический вывод
Всегда передавай external_id в POST /:id/charges. Тогда правило становится однозначным: external_id == null ⇒ это не твоё списание, а списание при привязке.
Создать списание (режим A) — POST /subscriptions/:id/charges
Инициирует одно списание по активному мандату. Доступно только когда подписка в статусе active.
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../charges \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_rub": "500.00",
"external_id": "sub-charge-2026-07"
}'
Body
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
amount_rub |
string | ✓ | Сумма списания. Decimal, до 2 знаков. Должна быть ≤ cap_amount_rub подписки |
external_id |
string | Твой ID списания. До 256 символов. Настоятельно рекомендуем передавать — см. как отличить списание при привязке |
Других полей тело не принимает. Период списания задать нельзя — он всегда определяется по времени нашего сервера в момент запроса. Лишние ключи в теле молча игнорируются: они не вызовут ошибку, но и не повлияют ни на что.
Ответ 201
Ответ — стандартный объект платежа (как у POST /payments), с payment_method: "recurring":
{
"id": "pay_660e8400-e29b-41d4-a716-446655440111",
"status": "pending",
"amount_rub": "500.00",
"actual_amount_rub": null,
"amount_usdt": null,
"external_id": "sub-charge-2026-07",
"payment_method": "recurring",
"subscription_id": "sub_550e8400-...",
"period_ref": "2026-07",
"created_at": "2026-07-18T10:00:00Z",
"paid_at": null,
"settled_at": null,
"metadata": null
}
Финальный результат списания придёт webhook'ом payment.status_changed (paid / failed и т.д.) — как у обычного платежа. Начальный статус в ответе — pending.
Идемпотентность списания
Идемпотентность списания — по периоду, а не только по заголовку
В одном периоде может быть одно списание. Повторный POST /charges в том же периоде — даже с другим Idempotency-Key — вернёт то же списание (HTTP 200), а не создаст дубль.
Период определяется по времени нашего сервера в момент запроса и считается по UTC от interval подписки:
interval |
Ключ периода | Пример |
|---|---|---|
day |
YYYY-MM-DD |
2026-07-18 |
week |
ISO-неделя YYYY-Www |
2026-W29 |
month |
YYYY-MM |
2026-07 |
interval_count в расчёте периода не участвует: при interval: "month", interval_count: 3 период всё равно календарный месяц, и списывать можно ежемесячно. Ограничение «раз в 3 месяца» реализуй у себя.
Управлять периодом из запроса нельзя — параметра для этого в API нет. Хочешь списать за другой период — дождись его наступления.
Вычислять ключ периода самому не нужно и не надо: он приходит готовым в поле period_ref — и в ответе на POST /charges, и в объекте платежа, и в webhook'е payment.status_changed. Твой расчёт по created_at и наш серверный расчёт разойдутся ровно на границе месяца/ISO-недели и в чужой таймзоне — то есть там, где ошибка стоит двойного списания. Сравнивай period_ref, а не даты.
Границы периода — календарные, а не «раз в 30 дней»
Период идемпотентности не привязан к дате привязки мандата. Мандат, подтверждённый 31 июля, занимает период 2026-07; твоё списание 1 августа попадёт уже в 2026-08 и пройдёт — клиент заплатит дважды за два дня.
Если в первом периоде списание уже сделала платёжная система при привязке (см. общий поток), своё первое списание планируй не «через месяц после привязки», а на следующий календарный период, и перед вызовом сверяйся с GET /:id/charges — по полю period_ref списаний, а не по их датам.
Обратная сторона того же правила: если твоё списание за месяц уже прошло, повтор в том же месяце вернёт 200 и старый платёж. Не считай такой ответ подтверждением нового списания — сверяй id платежа в ответе.
Это описание периодов — для подписок старой схемы
Для подписок, привязанных через окно X-Hub, периоды устроены иначе: они отсчитываются от даты последней успешной оплаты («якоря»), а не по календарю; interval_count задаёт длину периода; списание раньше начала периода отклоняется 409 CHARGE_BEFORE_WINDOW с next_allowed_at; неуспешные повторы ограничены бюджетом повторов (409 RECURRING_CHARGE_EXHAUSTED при исчерпании). У таких подписок period_ref имеет форму aYYYYMMDD-<interval><count> (например a20260915-month1) — дата начала периода видна прямо в ключе. Ключ периода вычислять самому не нужно: сравнивай period_ref из GET /:id/charges и вебхуков.
Ошибки списания
| HTTP | Code | Причина | Что делать |
|---|---|---|---|
| 400 | MISSING_IDEMPOTENCY_KEY |
Не передан заголовок Idempotency-Key |
Добавь UUID |
| 400 | VALIDATION_ERROR |
Невалидное тело (amount_rub не decimal, забыт Content-Type: application/json) |
Исправь запрос |
| 400 | INVALID_AMOUNT |
amount_rub = 0 |
Списание на ноль рублей невозможно — передай положительную сумму |
| 404 | SUBSCRIPTION_NOT_FOUND |
Подписка не найдена / чужая / другого режима | Проверь ID и режим ключа |
| 409 | CHARGE_MANDATE_INACTIVE |
Подписка не active (pending_binding / paused / cancelled), либо мандат не привязан |
Дождись активации / resume; при rebind_required — новая подписка |
| 409 | CHARGE_BEFORE_WINDOW |
Ещё рано: до начала следующего периода подписки | В details.next_allowed_at — момент, когда списание пройдёт. Строй свой крон на него, повторять раньше бессмысленно. См. окно следующего списания |
| 409 | RECURRING_CHARGE_EXHAUSTED |
Бюджет повторов периода исчерпан (число попыток или окно с 1-й попытки) | В details.next_allowed_at — начало следующего периода. Приходит и вебхук subscription.charge_exhausted (один на период). Смета повторов плательщика в окне привязки и эта таблица — одно и то же: повторы |
| 409 | CHARGE_PERIOD_OCCUPIED |
В периоде уже есть чужое списание (твой external_id не совпал с его) |
Не подтверждай по нему свой заказ. Проверь GET /:id/charges и разбирай ручной пересборкой |
| 409 | CAP_EXCEEDED |
Сумма превышает cap_amount_rub (на списание) или cap_window_rub (за период) |
Уменьши сумму или подними потолок через PATCH |
| 501 | RECURRING_NOT_SUPPORTED |
Списания по мандату недоступны для твоего аккаунта | Платёж не создан, период свободен. Повтор ответ не изменит — напиши в X-Hub |
| 503 | PROVIDER_NOT_CONFIGURED |
Списания по подпискам сейчас недоступны. Сейчас так отвечает любое списание по активной подписке в боевом режиме — см. предупреждение в начале раздела | Платёж не создан, период свободен. Повтор ответ не изменит; X-Hub сообщит, когда списания снова заработают |
| 503 | PROVIDER_UNAVAILABLE |
Списание не дошло: сеть, таймаут, 5xx у платёжной системы. Дебет мог пройти — мы просто не увидели ответ |
Платёж остаётся pending. Не создавай второе списание. Повтори тот же запрос — идемпотентность по периоду вернёт то же списание |
| 502 | PROVIDER_ERROR |
Списание отклонено. Сюда попадает реальный отказ банка (нет денег на счёте, мандат отозван на стороне банка), а также осознанный отказ платёжной системы | Списание помечено failed, период освобождён. Причину смотри в failure_reason самого списания — GET /subscriptions/:id/charges. От неё зависит, имеет ли смысл повтор |
502 PROVIDER_ERROR — это в том числе «банк не дал денег»
Сам HTTP-код на этой ручке общий: и «не хватило средств», и «мандат отозван» приходят как 502 PROVIDER_ERROR. Но причина больше не теряется — она сохраняется у списания в поле failure_reason:
curl "https://api.x-hub.online/api/v1/subscriptions/sub_.../charges?per_page=1" \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
# → data[0].status == "failed", data[0].failure_reason == "insufficient_funds"
Разбор кодов и рекомендованное действие по каждому — причина отказа. Коротко: insufficient_funds — повторить через 1–3 дня и подписку не отменять; mandate_revoked — повторы бессмысленны, нужна новая привязка.
Не трактуй 502 как «сбой на нашей стороне, надо ретраить вслепую»: для списания это ответ по конкретному клиенту, и что делать — говорит failure_reason. Практическая развилка:
503→ неопределённость, повторяй тот же запрос;502→ отказ, включай свой dunning — но ветку выбирай поfailure_reason, а не по факту отказа.
Если отказ повторяется на всех клиентах разом — это уже похоже на нашу проблему, напиши команде X-Hub.
Как связать списание с подпиской
Платёж рекуррентного списания несёт два связующих поля — subscription_id ("sub_...") и period_ref (период, который списание занимает) — и в webhook payment.status_changed (при любом изменении статуса), и в объекте платежа (GET /payments/:id, GET /subscriptions/:id/charges).
Поведение у обоих полей одинаковое, это пара: в webhook у обычных (не подписочных) платежей их нет, в REST-ответе они присутствуют всегда и равны null. Дополнительно списания подписки доступны списком: GET /subscriptions/:id/charges.