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

Создать подписку — 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'и у тебя ещё не настроены.

Рекомендованный порядок:

  1. POST /subscriptions → 201.
  2. binding_url не пустой → отправляй клиента, как обычно (это подавляющее большинство случаев).
  3. binding_url пустой и binding_url_pending: true → покажи клиенту «готовим ссылку» и жди события со ссылкой; типовое ожидание — секунды.
  4. Пришло событие со ссылкой → отправляй клиента на неё. Дальше поток обычный.

Если ссылка так и не появится

Мы ждём ссылку до часа. Если платёжная система её так и не выпустит (или сама закроет привязку), подписка переводится в терминальный 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

GET /api/v1/subscriptions?page=1&per_page=20&status=active

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.