--- title: "Ручки подписок" url: "https://docs.x-hub.online/subscriptions/api/" description: "Создание, чтение, изменение подписки и списания по ней." relatedDocuments: - "https://docs.x-hub.online/subscriptions/" - "https://docs.x-hub.online/subscriptions/consent-window/" - "https://docs.x-hub.online/subscriptions/manage/" - "https://docs.x-hub.online/payments/" --- # Создать подписку — POST /subscriptions Создаёт подписку и инициирует привязку мандата. Возвращает `binding_url`, на который надо отправить клиента. ```bash 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` — см. [периодичность](./consent-window.md#presets) | | `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` — см. [магазин платежа](../payments/index.md#shop-refusals). Повтор с тем же `Idempotency-Key`, но другим `shop_id` — `409 IDEMPOTENCY_KEY_REUSED` | | `method_id` | string (uuid) | | Явный выбор терминала из списка `GET /payment-methods` — см. [выбор терминала](../payments/index.md#terminal-selection). Только если терминалы включены для твоего аккаунта, иначе `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`, а не молчаливое игнорирование. !!! note "Отказ привязки в режиме `binding_charge=none` приходит как `expired`, а не `failed`" Это сознательно: `expired` — не терминальный статус, он допускает поздний успех (плательщик подтвердил QR, когда попытка уже истекла). Семантика переходов статусов платежа — [Edge cases: expired](../payments/index.md#edge-case-expired). ## Ответ 201 ```json { "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](./consent-window.md). У подписки с капом, равным сумме, `cap_amount_rub` в ответе всегда дублирует `amount_rub`; `cap_window_rub` в ответе появляется только у подписок, созданных до этой схемы и имеющих оконный лимит. **Что дальше:** отправь клиента на `binding_url`. !!! tip "Рекомендуем передавать `success_url`" Без него после успешной привязки плательщик остаётся на окне X-Hub. С ним он возвращается в твой магазин: кнопка «В магазин» с автовозвратом через 5 секунд. Это закрывает последний шаг воронки — клиент видит завершение у тебя, а не у нас. `current_period_start` и `current_period_end` заполняются в момент активации мандата. !!! note "`cap_window_rub` в ответе" Если ты не передавал `cap_window_rub`, поле в ответе **отсутствует** целиком (а не приходит `null`). Не полагайся на его наличие. !!! tip "Идемпотентность" Повтор `POST /subscriptions` с тем же `Idempotency-Key` вернёт **ту же** подписку (HTTP `200`, без создания второго мандата). # Ссылка привязки может готовиться — `binding_url_pending` { #binding-url-pending } Ссылку привязки выпускает не X-Hub, а платёжная система, и делает она это **асинхронно**. Обычно ссылка готова к моменту ответа, но иногда — нет. Раньше мы в такой ситуации отвечали ошибкой; теперь **подписка создаётся всегда**, а ссылка догоняет отдельным событием. Признак — булево поле **`binding_url_pending`**. Оно присутствует **всегда** (у обычной подписки `false`) — и в ответе `POST /subscriptions`, и в `GET /subscriptions/:id`, и в webhook'е `subscription.status_changed`. ```json { "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. Пришло событие со ссылкой → отправляй клиента на неё. Дальше поток обычный. !!! warning "Если ссылка так и не появится" Мы ждём ссылку **до часа**. Если платёжная система её так и не выпустит (или сама закроет привязку), подписка переводится в терминальный **`cancelled`** — придёт `subscription.status_changed` с `previous_status: "pending_binding"`. Списаний по такой подписке не было и не будет; чтобы повторить попытку, создай **новую** подписку (с новым `Idempotency-Key`). !!! note "Как отличить «готовится» от «что-то не так»" `binding_url: null` возможен по двум причинам, и `binding_url_pending` их разводит: | `binding_url` | `binding_url_pending` | Что это значит | Что делать | |---|---|---|---| | ссылка | `false` | норма | отправить клиента | | `null` | `true` | ссылка ещё выпускается | ждать событие | | `null` | `false` | нештатная ситуация: ссылки не будет | повторить запрос или написать в X-Hub | !!! tip "Совместимость" Поле только добавлено — существующие интеграции, читающие `binding_url`, продолжают работать. Если твой код падает на пустом `binding_url` (например, сразу подставляет его в редирект), добавь проверку `binding_url_pending` — теперь такой ответ штатно возможен. # Получить подписку — GET /subscriptions/:id ```bash curl https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-... \ -H "X-Api-Key: ..." -H "X-Api-Secret: ..." ``` Ответ — тот же формат, что при создании, с актуальным `status`. ## Поля ответа | Поле | Тип | Что означает | |---|---|---| | `id` | string | ID подписки в формате `sub_` | | `status` | string | Текущий статус (см. [жизненный цикл](./index.md#zhiznennyy-tsikl-podpiski)) | | `past_due` | bool | Мандат в просрочке у банка. Присутствует всегда; `status` при этом остаётся `active` — см. [про `past_due`](./index.md#zhiznennyy-tsikl-podpiski) | | `rebind_required` | bool | Мандат утрачен: нужна новая привязка, списания больше не пройдут. Присутствует только при `true`; `status` при этом `paused` — см. [про `rebind_required`](./index.md#zhiznennyy-tsikl-podpiski) | | `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`](#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`. В боевом поле отсутствует | !!! warning "Чего в объекте подписки НЕТ" В текущей версии API подписка **не отдаёт**: - **дату следующего списания** — в режиме A следующее списание инициируешь ты сам, поэтому расписание ведёшь у себя; - **дату последнего списания** — бери её из `GET /subscriptions/:id/charges` (список отсортирован по убыванию `created_at`, первый элемент — самое свежее списание); - **`client_ref` и полный `client_phone`** — они принимаются, но наружу не возвращаются (приватность плательщика). Период считать вручную **не нужно**: ключ периода каждого списания приходит готовым в поле `period_ref` объекта платежа и webhook'а `payment.status_changed` — см. [идемпотентность списания](#idempotency-charge). Ошибка `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` | ## Ответ ```json { "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` — **не** меняются. ```bash 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`. Ответ — обновлённый объект подписки. ## Ограничения { #patch-limits } - **Правка разрешена не во всех статусах.** Подписки в `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` (первый элемент = самое свежее списание). ```bash 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`) | ## Ответ ```json { "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`), см. [Платежи](../payments/index.md#payments). Отличие только в `payment_method: "recurring"`. ## Как отличить списание при привязке от своего { #raspoznat-binding-charge } По полю **`external_id`**: | `external_id` | Кто инициировал | |---|---| | твоя строка (если ты её передавал в `POST /charges`) | **ты** — списание режима A | | `null` | **платёжная система** — списание при привязке мандата | Списаниям, которые инициирует платёжная система, `external_id` присвоить некому, поэтому он всегда `null`. Обратное, строго говоря, не гарантировано: если ты вызвал `POST /charges` **без** `external_id`, твоё списание тоже придёт с `null`. !!! tip "Практический вывод" Всегда передавай `external_id` в `POST /:id/charges`. Тогда правило становится однозначным: `external_id == null` ⇒ это не твоё списание, а списание при привязке. # Создать списание (режим A) — POST /subscriptions/:id/charges Инициирует одно списание по **активному** мандату. Доступно только когда подписка в статусе `active`. ```bash 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 символов. Настоятельно рекомендуем передавать — см. [как отличить списание при привязке](#raspoznat-binding-charge) | Других полей тело не принимает. Период списания задать нельзя — он всегда определяется по времени **нашего сервера** в момент запроса. Лишние ключи в теле молча игнорируются: они не вызовут ошибку, но и не повлияют ни на что. ## Ответ 201 Ответ — стандартный объект платежа (как у `POST /payments`), с `payment_method: "recurring"`: ```json { "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`. ## Идемпотентность списания { #idempotency-charge } !!! warning "Идемпотентность списания — по периоду, а не только по заголовку" В одном **периоде** может быть **одно** списание. Повторный `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`, а не даты. !!! danger "Границы периода — календарные, а не «раз в 30 дней»" Период идемпотентности не привязан к дате привязки мандата. Мандат, подтверждённый 31 июля, занимает период `2026-07`; твоё списание 1 августа попадёт уже в `2026-08` и **пройдёт** — клиент заплатит дважды за два дня. Если в первом периоде списание уже сделала платёжная система при привязке (см. [общий поток](./index.md#kak-eto-rabotaet)), своё первое списание планируй не «через месяц после привязки», а на **следующий календарный период**, и перед вызовом сверяйся с `GET /:id/charges` — по полю `period_ref` списаний, а не по их датам. Обратная сторона того же правила: если твоё списание за месяц уже прошло, повтор в том же месяце вернёт `200` и старый платёж. Не считай такой ответ подтверждением нового списания — сверяй `id` платежа в ответе. !!! warning "Это описание периодов — для подписок старой схемы" Для подписок, привязанных через [окно X-Hub](./consent-window.md), периоды устроены иначе: они отсчитываются от даты **последней успешной оплаты** («якоря»), а не по календарю; `interval_count` задаёт длину периода; списание **раньше** начала периода отклоняется `409 CHARGE_BEFORE_WINDOW` с `next_allowed_at`; неуспешные повторы ограничены бюджетом повторов (`409 RECURRING_CHARGE_EXHAUSTED` при исчерпании). У таких подписок `period_ref` имеет форму `aYYYYMMDD-` (например `a20260915-month1`) — дата начала периода видна прямо в ключе. Ключ периода вычислять самому не нужно: сравнивай `period_ref` из `GET /:id/charges` и вебхуков. ## Ошибки списания { #charge-errors } | 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` — момент, когда списание пройдёт. Строй свой крон на него, повторять раньше бессмысленно. См. [окно следующего списания](./consent-window.md#charge-window) | | 409 | `RECURRING_CHARGE_EXHAUSTED` | Бюджет повторов периода исчерпан (число попыток или окно с 1-й попытки) | В `details.next_allowed_at` — начало следующего периода. Приходит и вебхук `subscription.charge_exhausted` (один на период). Смета повторов плательщика в окне привязки и эта таблица — одно и то же: [повторы](./consent-window.md#retries) | | 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` | Списания по подпискам сейчас недоступны. Сейчас так отвечает любое списание по активной подписке в боевом режиме — см. [предупреждение в начале раздела](./index.md#subscriptions) | Платёж **не создан**, период свободен. Повтор ответ не изменит; X-Hub сообщит, когда списания снова заработают | | 503 | `PROVIDER_UNAVAILABLE` | Списание не дошло: сеть, таймаут, `5xx` у платёжной системы. **Дебет мог пройти** — мы просто не увидели ответ | Платёж остаётся `pending`. **Не создавай второе списание.** Повтори тот же запрос — идемпотентность по периоду вернёт то же списание | | 502 | `PROVIDER_ERROR` | Списание отклонено. Сюда попадает **реальный отказ банка** (нет денег на счёте, мандат отозван на стороне банка), а также осознанный отказ платёжной системы | Списание помечено `failed`, период освобождён. **Причину смотри в `failure_reason` самого списания** — `GET /subscriptions/:id/charges`. От неё зависит, имеет ли смысл повтор | !!! warning "`502 PROVIDER_ERROR` — это в том числе «банк не дал денег»" Сам HTTP-код на этой ручке общий: и «не хватило средств», и «мандат отозван» приходят как `502 PROVIDER_ERROR`. Но **причина больше не теряется** — она сохраняется у списания в поле `failure_reason`: ```bash 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" ``` Разбор кодов и рекомендованное действие по каждому — [причина отказа](../payments/index.md#failure-reason). Коротко: `insufficient_funds` — повторить через 1–3 дня и **подписку не отменять**; `mandate_revoked` — повторы бессмысленны, нужна новая привязка. Не трактуй `502` как «сбой на нашей стороне, надо ретраить вслепую»: для списания это ответ по конкретному клиенту, и что делать — говорит `failure_reason`. Практическая развилка: - `503` → **неопределённость**, повторяй тот же запрос; - `502` → **отказ**, включай свой dunning — но ветку выбирай по `failure_reason`, а не по факту отказа. Если отказ повторяется на всех клиентах разом — это уже похоже на нашу проблему, напиши команде X-Hub. !!! note "Как связать списание с подпиской" Платёж рекуррентного списания несёт **два** связующих поля — `subscription_id` (`"sub_..."`) и `period_ref` (период, который списание занимает) — и в webhook `payment.status_changed` (при любом изменении статуса), и в объекте платежа (`GET /payments/:id`, `GET /subscriptions/:id/charges`). Поведение у обоих полей одинаковое, это пара: в webhook у обычных (не подписочных) платежей их **нет**, в REST-ответе они присутствуют всегда и равны `null`. Дополнительно списания подписки доступны списком: `GET /subscriptions/:id/charges`.