---
title: "Платежи"
url: "https://docs.x-hub.online/payments/"
description: "Создание платежа по СБП, чтение статуса и список платежей."
relatedDocuments:
- "https://docs.x-hub.online/payments/errors/"
- "https://docs.x-hub.online/payments/refunds/"
- "https://docs.x-hub.online/exchange-terminal/"
- "https://docs.x-hub.online/subscriptions/manage/"
- "https://docs.x-hub.online/reference/faq/"
- "https://docs.x-hub.online/subscriptions/api/"
- "https://docs.x-hub.online/reference/errors/"
---
# Платежи { #payments }
## Жизненный цикл
Обычный путь платежа: `pending` → `processing` → `paid` → `settled`. Быстрая обработка проскакивает `processing` и отдаёт `paid` сразу.
Терминальны только `failed`, `cancelled` и `refunded` — из них переходов нет. Всё остальное, включая `expired`, может измениться, и неочевидные переходы разобраны в [Edge cases](#edge-cases): `amount_mismatch` после нашего разбора уходит в `paid` или `failed`, `expired` при поздней оплате — в `paid` или `amount_mismatch`, а `settled` может стать `refunded` спустя месяцы.
| Статус | Значение |
|---|---|
| `pending` | Создан, ждёт оплаты. Срок зависит от способа оплаты и от `ttl_minutes`, если он применён, — **точный срок всегда в поле `expires_at`** ответа, ориентируйся на него, а не на константу |
| `processing` | Клиент начал оплату (сканировал QR) |
| `paid` | Платёж подтверждён банком. Сумма в рублях учтена в `pending_rub` баланса, USDT начисляются на settlement |
| `settled` | Прошёл settlement. USDT на `available_balance` |
| `amount_mismatch` | Клиент заплатил не ту сумму. Требует разбора |
| `expired` | Время на оплату истекло, клиент не оплатил. **Не терминальный** — поздняя оплата может поднять платёж в `paid`, см. ниже |
| `cancelled` | Платёж отменён до того как деньги дошли — мерчанту ничего не начислялось (terminal) |
| `failed` | Отказ банка / техническая ошибка |
| `refunded` | Банк-эквайер вернул деньги клиенту **после** того как мы засчитали платёж. Терминальный. См. ниже |
!!! warning "Возврат после успешной оплаты — `refunded`"
Банк-эквайер может отозвать платёж и вернуть деньги клиенту через минуты, часы, дни или месяцы после того как мы прислали тебе `payment.status_changed` со `status: paid` или `settled`.
Когда это происходит:
1. Эквайер возвращает RUB клиенту со счёта X-Hub
2. X-Hub переводит платёж в `status: refunded` (терминальный)
3. Соответствующая сумма USDT **списывается** с твоего `available_balance`
4. Если выплата за этот платёж ещё **PENDING** в твоём кабинете — она автоматически пересчитывается (уменьшается)
5. Если выплата уже была **PAID** (USDT уже у тебя в кошельке) — мы свяжемся для clawback'а (обычно: вычитаем из следующей выплаты)
6. **Прилетит webhook** `payment.status_changed` со `status: refunded` и `previous_status: paid` или `settled`
Твоя система должна:
- Идемпотентно обрабатывать `refunded` (это **другое** событие чем `cancelled` — там денег и не было)
- Откатить выдачу товара/услуги клиенту если возможно
- Не считать `refunded` доходом
## Создать платёж — POST /payments
### Headers
| Header | Обяз. | |
|---|---|---|
| `X-Api-Key` | ✓ | |
| `X-Api-Secret` | ✓ | |
| `Idempotency-Key` | ✓ | UUID |
| `Content-Type` | ✓ | `application/json` |
### Body
```json
{
"amount_rub": "500.00",
"external_id": "order-12345",
"payment_method": "sbp",
"description": "VPN подписка 1 месяц",
"success_url": "https://shop.example.com/orders/12345/done",
"metadata": {"user_id": "usr_abc", "plan": "premium"}
}
```
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
| `amount_rub` | string | ✓ | Decimal, 2 знака. Минимум `"30.00"` для любого разового платежа, одинаковый для всех аккаунтов (списания по уже выданному мандату подписки порогу не подчиняются), максимум `"1000000.00"`. Ниже минимума — `400 AMOUNT_BELOW_MINIMUM`, точная сумма минимума приходит в `message` |
| `external_id` | string | | Твой ID заказа (для сопоставления). Максимум 256 символов |
| `payment_method` | string | ✓ | `"sbp"` — оплата по СБП. `"card"` — оплата картой; доступна, только если приём карт подключён твоему аккаунту, иначе `400 CARD_NOT_SUPPORTED` и платёж **не создаётся** |
| `method_id` | string (uuid) | | Явный выбор терминала — см. [выбор терминала](#terminal-selection) ниже. Если у тебя не включена эта опция — `400 METHOD_SELECTION_DISABLED`, платёж **не создаётся**. Не передан — маршрут выбирает X-Hub |
| `ttl_minutes` | integer | | Срок жизни ссылки на оплату, в минутах, от 1 до 129600. Применяется, если способ оплаты твоего аккаунта поддерживает свой срок; если нет — ссылка живёт стандартное время этого способа, а параметр ничего не меняет. Не передан — стандартное время способа. Выше максимума твоего аккаунта — `422 VALIDATION_ERROR`, платёж **не создаётся**. Фактический срок всегда в `expires_at` ответа — сверяйся с ним, а не с переданным значением |
| `shop_id` | string (uuid) | | Магазин платежа, если в аккаунте несколько магазинов — голый uuid, тот же, что приходит в `shop_id` вебхука. Не передан — платёж идёт в магазин по умолчанию. Отказы — [ниже](#shop-refusals) |
| `description` | string | | До 256 символов, видит клиент |
| `metadata` | object | | Произвольный JSON, до 4KB. Возвращается в webhook и GET. Служебные ключи X-Hub (`bindingNoCharge`, `provider_data`, `terminalCode`, `methodId`, `tariffCoreReview`, `payerNameMasked`, `payerContactMasked`, `payerCardMasked`, `nameMatch`, `charge_recovery`) отбрасываются при создании платежа |
| `client_phone` | string | * | Телефон клиента в формате E.164: `+` и 10–15 цифр (например `+79001234567`). **Обязателен** только если у вашего аккаунта включён KYC или терминал требует данные плательщика (см. ниже). Кривой формат — `422 VALIDATION_ERROR`. Если KYC включён и поле отсутствует — `400 VALIDATION_ERROR` (точный текст сообщения может отличаться — ориентируйся на код и поле `details`). |
| `client_email` | string | * | Email клиента, до 254 символов. Необязателен глобально, но обязателен для терминалов, требующих данные плательщика (см. ниже). |
| `payer` | object | * | Данные плательщика, которые ты собрал при проверке своего клиента: `full_name`, `document_number` и, по желанию, `kyc_reference`, `kyc_completed_at`. **Обязателен** для терминалов, у которых в `required_fields` есть `payer` (см. ниже); остальным не нужен. Поля, правила значений и отказы — [объект `payer`](#payer-object) |
| `client_info` | object | | Данные плательщика для антифрода X-Hub — JSON-объект до 4096 байт (считается сериализованный JSON в UTF-8). Не объект, больше 4096 байт или символ NUL — `400 VALIDATION_ERROR`, платёж не создаётся. Используется **только** нашим антифродом, в банк не передаётся и в ответах API, вебхуках и кабинете не возвращается. Рекомендуемые ключи — [ниже](#client-info) |
| `customer_id` | string | | Идентификатор платящего клиента в твоей системе, 1–256 символов (значение обрезается от пробелов по краям; пустое или из одних пробелов — `400 VALIDATION_ERROR`). Необязателен, но передавай, если он у тебя есть: его используют способы оплаты, которым нужна идентификация плательщика — один и тот же клиент должен выглядеть для них одним и тем же человеком, а не новым в каждом платеже |
| `webhook_url` | string | | Переопределяет глобальный webhook-URL из кабинета **для этого платежа**. Только публичный HTTPS (не приватные IP, не localhost, не HTTP), иначе `400 VALIDATION_ERROR`; правило одно для production и sandbox. Если не передан — используется глобальный URL из кабинета |
| `success_url` | string | | Куда вернуть плательщика **после успешной оплаты** — по этому адресу ведёт кнопка возврата в приложении банка. Ссылка уходит в банк как есть, без изменений с нашей стороны. Только `http`/`https`, до 2048 символов. Если не передан — кнопка ведёт на страницу по умолчанию |
| `auto_return` | boolean | | Вернуть плательщика в магазин автоматически: после оплаты окно показывает кнопку «В магазин» с отсчётом 5 секунд и само переходит по адресу возврата. `false` — только кнопка «Вернуться в магазин», без отсчёта. Если не передан — настройка аккаунта, по умолчанию `true`. Действует, только если есть адрес возврата |
!!! warning "success_url — это удобство, а не подтверждение оплаты"
Переход по `success_url` означает лишь, что плательщик нажал кнопку возврата. Он может закрыть приложение банка и не вернуться вовсе, а до статуса `paid` платёж может дойти позже перехода. **Источник истины — webhook `payment.status_changed` и `GET /payments/{id}`**; заказ отдавать по ним, а не по факту редиректа.
!!! tip "Назначение платежа"
Это текст, который плательщик видит на экране оплаты в приложении банка — туда стоит вписывать номер заказа или название подписки, а не внутренний идентификатор.
Источник берётся по цепочке: `description` → если его нет, `external_id` → если нет и его, подставляется стандартный текст.
Перед тем как текст уходит в банк, он обрабатывается:
- повторяющиеся пробелы схлопываются в один;
- часть служебных слов вырезается;
- если после этого текст короче 15 символов — вместо него подставляется стандартный текст;
- если длиннее 140 символов — обрезается до 140 (без потери эмодзи на границе среза), а не отклоняется.
Например, `"VPN подписка 1 месяц"` плательщик увидит как `"VPN подписка 1 месяц"`.
!!! warning "Данные плательщика: некоторые терминалы требуют их на каждом платеже"
Часть терминалов (справочник — `GET /api/v1/payment-methods`, поле `required_fields` в каждом методе) требует данные плательщика на **каждом** платеже: `client_phone`, `client_email`, `description` и/или объект `payer`. Если выбран такой терминал (маршрутом X-Hub или твоим `method_id`), а какого-то из полей нет — платёж **не создаётся** и API отвечает:
```json
{
"error": {
"code": "PAYER_DATA_REQUIRED",
"message": "This payment method requires payer data that is missing from the request.",
"details": { "missing_fields": ["client_email"] }
}
}
```
Отказ приходит до обращения к банку; повтори запрос с тем же `Idempotency-Key`, добавив недостающие поля, — платёж создастся как обычно: `422` не «запирает» ключ.
В `missing_fields` попадают только те из имён, которые терминал требует и которых нет в запросе: `"payer"` — если объекта `payer` нет вовсе (например, `["client_phone", "payer"]`). Если `payer` передан, но не прошёл проверку, ответ другой — `VALIDATION_ERROR`, см. [объект `payer`](#payer-object).
Рекомендация: передавай `client_phone`, `client_email` и `description` в **каждом** платеже, когда они у тебя есть, — тогда переход на другой терминал или способ оплаты не начнёт отклонять твои платежи.
#### Объект `payer` { #payer-object }
Данные плательщика, которые ты собрал, когда проверял своего клиента. Он обязателен на терминале, у которого в `required_fields` есть `payer` (см. [обменный терминал](../exchange-terminal/index.md#step-create)): без объекта такой платёж не создаётся.
```json
"payer": {
"full_name": "Тестов Тест Тестович",
"document_number": "0000 000000",
"kyc_reference": "check-0001",
"kyc_completed_at": "2026-10-01T12:00:00Z"
}
```
| Поле | Тип | Обяз. | Правила |
|---|---|---|---|
| `full_name` | string | ✓ | ФИО плательщика. От 2 до 150 символов после того, как пробелы по краям убраны, а подряд идущие пробелы схлопнуты в один. Допустимы буквы любого алфавита, пробел, дефис, апостроф (`'` или `’`) и точка; хотя бы одна буква обязательна. Цифры, запятые, другие знаки и управляющие символы (в том числе перенос строки и табуляция) не допускаются |
| `document_number` | string | ✓ | Номер документа плательщика. Для проверки он приводится к верхнему регистру, из него убираются пробелы, дефисы и точки: `"0000 000000"` и `"0000-000000"` — один и тот же номер. После этого в нём должны остаться только буквы и цифры, от 5 до 32 знаков. Любой другой знак (например, `/` или запятая) отклоняет значение |
| `kyc_reference` | string | | Идентификатор проверки клиента в твоей системе. От 1 до 128 символов после того, как пробелы по краям убраны; управляющие символы не допускаются |
| `kyc_completed_at` | string | | Когда проверка клиента завершена: дата и время в формате ISO 8601 **с часовым поясом**, например `2026-10-01T12:00:00Z` или `2026-10-01T15:00:00+03:00`. Без часового пояса — отказ. Момент позже текущего — отказ (расхождение часов до 5 минут допускается) |
Внутри объекта обязательны `full_name` и `document_number`; `kyc_reference` и `kyc_completed_at` можно не передавать. Других ключей в объекте быть не должно: лишний ключ отклоняет запрос. Если платёж идёт на терминал, которому эти данные нужны, объект проверяется, и ошибка в нём отклоняет платёж — присылай только корректный объект.
Что отвечает API. Платёж во всех случаях **не создаётся**; сообщения об ошибках называют поле и правило, но не повторяют присланные значения.
| Что не так | Ответ |
|---|---|
| Терминал требует `payer`, а ключа `payer` в запросе нет | `422 PAYER_DATA_REQUIRED`, `details.missing_fields` содержит `"payer"` (и другие недостающие поля, если они есть) |
| Форма объекта: `payer` не объект (строка, число, `null`, массив), нет `full_name` или `document_number`, значение поля не строка, в объекте лишний ключ | `400 VALIDATION_ERROR`, `message` — `Invalid request body`, в `details` — массив `{path, message}`, где `path` — `payer`, `payer.full_name` и т.п. |
| Значение не прошло правила из таблицы выше (длина, допустимые знаки, формат даты, дата в будущем) | `422 VALIDATION_ERROR`, в `message` — поле и правило (`payer.document_number …`), в `details` — массив из одного элемента `{path, message}`; называется первое найденное нарушение |
Если `payer` передан, но не прошёл проверку, ответ — `VALIDATION_ERROR`, даже когда не хватает и других полей (например, `client_phone`). Отказ не расходует `Idempotency-Key`: исправь запрос и повтори его с тем же ключом. Текст `message` и `details[].message` — на английском и может меняться; разбирай ответ по `error.code` и `details[].path`.
!!! info "client_info: данные плательщика для антифрода"
Если ты отдаёшь плательщику ссылку банка напрямую, без окна оплаты X-Hub, наш антифрод не видит, кто платит, — эти данные можешь передать только ты. Передавай в `client_info` то, что твой сервер знает о плательщике в момент оформления заказа:
| Ключ | Что это |
|---|---|
| `ip` | IP-адрес плательщика (IPv4 или IPv6) — не адрес твоего сервера. **Обязателен, если плательщик не проходит через окно оплаты X-Hub** |
| `user_agent` | Заголовок `User-Agent` браузера плательщика |
| `page_url` | Адрес страницы, с которой плательщик перешёл к оплате |
| `accept_language` | Необязательно: заголовок `Accept-Language` браузера плательщика |
| `timezone` | Необязательно: часовой пояс плательщика, например `Europe/Moscow` |
```json
"client_info": {
"ip": "203.0.113.24",
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ...",
"page_url": "https://shop.example.com/checkout",
"accept_language": "ru-RU,ru;q=0.9",
"timezone": "Europe/Moscow"
}
```
Ключи не проверяются: можно передать и другие, лишнее не мешает, кривое значение не отклоняет платёж — такой ключ просто не используется. Данные нужны только нашему антифроду: в банк они не уходят, в ответах API, вебхуках, кабинете и выгрузках не возвращаются.
!!! tip "Per-payment webhook_url"
По умолчанию все события платежа летят на глобальный `webhook_url` из кабинета. Поле `webhook_url` в теле `POST /payments` позволяет направить события **конкретного** платежа на другой адрес — удобно для multi-tenant интеграций.
- **Production:** только публичный HTTPS. Приватные IP, `localhost` и `http://` отклоняются (`400 VALIDATION_ERROR`, `"webhook_url must be a public HTTPS URL ..."`).
- **Sandbox:** те же правила. Вебхук отправляет сервер X-Hub из интернета, до `localhost` твоей машины он не доберётся, поэтому для локальной отладки нужен публичный HTTPS-адрес — свой dev-домен или HTTPS-туннель.
### Ответ 201
```json
{
"id": "pay_550e8400-e29b-41d4-a716-446655440000",
"shop_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "pending",
"amount_rub": "500.00",
"actual_amount_rub": null,
"amount_requested": "500.00",
"amount_received": null,
"amount_usdt": null,
"external_id": "order-12345",
"description": "VPN подписка 1 месяц",
"payment_method": "sbp",
"payment_url": "https://qr.nspk.ru/BD10100NR0811JNT8FAQG039UTUIDSOM",
"expires_at": "2026-04-14T13:15:00Z",
"created_at": "2026-04-14T13:00:00Z",
"paid_at": null,
"settled_at": null,
"subscription_id": null,
"period_ref": null,
"method_id": null,
"refund_pending": false,
"failure_reason": null,
"sandbox": false,
"metadata": {"user_id": "usr_abc", "plan": "premium"}
}
```
- `payment_method` — способ оплаты **тем же словарём, что и в запросе**: `"sbp"` или `"card"`. Третье значение, `"recurring"`, бывает только у списания по подписке — его инициирует платёжная система, и в теле запроса оно не передаётся ([подписки](../subscriptions/manage.md)). Ровно эти же значения приходят в webhook.
- `payment_url` — для `payment_method: "sbp"` это ссылка НСПК на оплату по СБП. Её можно отдать клиенту редиректом, а можно закодировать в QR у себя и показать на своей странице или прислать картинкой в бот (host-to-host) — тогда клиент остаётся у тебя. **Для `payment_method: "card"` это поле работает иначе** — см. [оплата картой](#card-payment) ниже, не полагайся на этот абзац для карты.
- Окно оплаты X-Hub по `payment_url` после успешной оплаты или отказа (`paid`, `settled`, `failed`, `cancelled`) доступно одну минуту, дальше ссылка отвечает `404`, как несуществующая. Статус платежа бери из API или вебхука, не из этой ссылки.
- `amount_requested` / `amount_received` — алиасы `amount_rub` / `actual_amount_rub` (запрошенная и фактически полученная сумма).
- `payer_name` — имя плательщика, как его сообщил банк (строка). Поля может не быть: оно приходит не у каждого платежа, и когда имени нет, ключа в ответе нет вовсе (не `null`), пример выше — как раз такой платёж. Когда имя есть, оно появляется вместе со статусом `paid` или позже; его всегда можно перечитать запросом `GET /api/v1/payments/:id`. Значение отдаётся как получено от банка — формат (полное имя, сокращение, регистр) мы не меняем.
- `refund_pending` — по платежу **запрошен возврат, но деньги ещё не вернулись**. Поле присутствует **всегда** (`false` у обычного платежа). Пока оно `true`, основной `status` не меняется — платёж и правда всё ещё оплачен, а возврат может не состояться. Подробнее — [возвраты](../reference/faq.md#kak-sdelat-vozvrat).
- `subscription_id` — подписка, по которой сделано списание (`"sub_..."`). Поле присутствует **всегда**; у обычного платежа оно `null`.
- `period_ref` — период, который это списание занимает в идемпотентности (`"2026-08"` / `"2026-W32"` / `"2026-08-07"` — формат зависит от `interval` подписки). Поле присутствует **всегда**; у обычного платежа оно `null`. Считать период самому не нужно — см. [идемпотентность списания](../subscriptions/api.md#idempotency-charge).
- `method_id` — какой терминал реально принял платёж (см. [выбор терминала](#terminal-selection)) — заполняется автоматически, независимо от того, передавал ли ты `method_id` в запросе. Поле присутствует **всегда**; `null`, если у тебя не подключён выбор терминала или платёж не был направлен на конкретный терминал.
- `failure_reason` — **почему платёж не прошёл**. Поле присутствует **всегда**; у всех статусов, кроме `failed`, оно `null`. Набор значений и рекомендованное действие по каждому — [причина отказа](#failure-reason).
- `shop_id` — магазин платежа (голый uuid, как в вебхуке). Поле присутствует **всегда**; у платежей, созданных до появления магазинов, — магазин по умолчанию.
- `sandbox` — `true` только в тестовом режиме.
- `expires_at` — фактический срок жизни ссылки: он учитывает `ttl_minutes`, если способ оплаты твоего аккаунта его применил, иначе это стандартное время способа. Всегда ориентируйся на это поле.
**Что делать:** редирект клиента на `payment_url`.
### Отказы при создании платежа { #create-refusals }
Кроме ошибок в самом запросе (`VALIDATION_ERROR`, `AMOUNT_BELOW_MINIMUM`, `CARD_NOT_SUPPORTED` и т.п.), `POST /payments` может отказать, если приём по запрошенному способу оплаты сейчас не настроен у твоего аккаунта. Платёж в этом случае **не создаётся**: его нет в `GET /payments`, вебхуков по нему не будет.
| Код ошибки | Когда | Ретрай? |
|---|---|---|
| `400 PAYMENT_ROUTE_UNAVAILABLE` | Для способа оплаты нет рабочего маршрута — например, не назначен терминал | Нет — не поможет, пока не изменится настройка аккаунта. Напиши в X-Hub |
| `503 PAYMENT_ROUTE_UNAVAILABLE` | Маршрут не удалось проверить из-за временного сбоя на нашей стороне | Да — через 30–60 сек с тем же `Idempotency-Key` |
| `422 PAYMENT_UNAVAILABLE` | Приём по этому способу оплаты для аккаунта сейчас недоступен | Нет — повтор получит тот же ответ. Напиши в X-Hub |
Полный список кодов и стратегия ретраев — [Коды ошибок](../reference/errors.md).
### Магазин платежа — `shop_id` { #shop-refusals }
Ключ API один на аккаунт; если магазинов несколько, магазин платежа передаётся в `shop_id`:
```bash
curl -X POST https://api.x-hub.online/api/v1/payments \
-H "X-Api-Key: xh_live_..." -H "X-Api-Secret: ..." \
-H "Idempotency-Key: 5b1f0c1e-9a3b-4c2d-8e4f-0123456789ab" \
-H "Content-Type: application/json" \
-d '{"amount_rub": "500.00", "payment_method": "sbp", "shop_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"}'
```
Магазин платежа — тот, что передан в `shop_id`; без `shop_id` платёж попадает в магазин по умолчанию, всё работает как раньше. Один и тот же `Idempotency-Key` в разных магазинах — разные платежи.
| Код ошибки | Когда |
|---|---|
| `400 SHOP_NOT_FOUND` | Магазина с таким `shop_id` нет в твоём аккаунте |
| `403 SHOP_DISABLED` | Магазин выключен |
| `422 SHOP_MARKUP_NOT_SET` | Магазину ещё не назначена ставка — напиши в X-Hub |
Платёж ни в одном из этих случаев **не создаётся**.
## Оплата картой { #card-payment }
Запрос устроен так же, как и для СБП — меняется только `payment_method`:
```json
{
"amount_rub": "500.00",
"external_id": "order-12346",
"payment_method": "card",
"description": "VPN подписка 1 месяц",
"success_url": "https://shop.example.com/orders/12346/done"
}
```
Ответ `201` — та же форма, что и у СБП, но `payment_url` внутри устроен иначе:
```json
{
"id": "pay_7c1e2f3a-...",
"status": "pending",
"amount_rub": "500.00",
"payment_method": "card",
"payment_url": "https://api.x-hub.online/r/7c1e2f3a-9b04-4f1d-8a52-1c6d0b3e77af",
"expires_at": "2026-04-14T13:15:00Z"
}
```
!!! danger "payment_url для card — редирект, а не QR"
Для `sbp` `payment_url` — это НСПК-ссылка: её содержимое можно закодировать в QR и нарисовать у себя (host-to-host, см. выше). Для `card` `payment_url` ведёт на ХОСТИНГОВУЮ страницу ввода карты — форму, которую рисует сама платёжная система. У этой страницы нет QR-полезной нагрузки: кодировать `payment_url` в QR-картинку бессмысленно, получится ссылка на форму, а не платёжный QR, и клиент не сможет оплатить.
Для `card` работает только один путь: **редирект клиента на `payment_url`**. Host-to-host (свой чекаут поверх payload) для карты недоступен — платёжная страница обязана открыться как есть.
Считай `payment_url` непрозрачной (opaque) ссылкой: не парси её, не вытаскивай из неё параметры, не пытайся отрисовать что-то своё на её основе — просто редиректь клиента по ней. Домен в ссылке может быть как доменом платёжной системы, так и нашим собственным (в примере выше — `api.x-hub.online/r/...`), который сам сделает следующий редирект. Какой именно — деталь реализации, которая может измениться; полагаться на конкретный домен или путь нельзя.
### Если приём карт не подключён { #card-not-enabled }
`payment_method: "card"` — не флаг, который включает карту сам по себе. Доступность карты — свойство твоего аккаунта, и она проверяется на сервере **до** того, как платёж будет создан:
- ответ — `400 CARD_NOT_SUPPORTED`;
- платёж **не создаётся** — в системе не остаётся ни строки, ни `payment_id`, ретраить с тем же `Idempotency-Key` нечего донасоздавать;
- это не сбой и не временная ошибка — ретрай ничего не изменит.
Подключение карт — настройка на уровне аккаунта, её включает X-Hub по обращению (как и KYC, см. [`client_phone`](#body) выше). В теле запроса включить приём карт нельзя — напиши в X-Hub, если он тебе нужен.
### Что не отличается для card
- **Webhook.** Событие `payment.status_changed` для картой оплаченного платежа устроено так же, как для СБП: тот же payload, тот же `X-Webhook-Signature`, `payment_method` в нём просто будет `"card"`. Отдельного события для карты нет.
- **Жизненный цикл статусов.** `pending → processing → paid → settled` и все edge cases (`amount_mismatch`, `expired`, `failed`, `refunded`) — те же самые, что описаны выше и в [обработке ошибок](./errors.md).
- **Возвраты.** [`POST /refunds`](./refunds.md#refunds) работает с оплаченным картой платежом так же, как с оплаченным по СБП — по `payment_id`, без привязки к способу оплаты.
## Выбор терминала (опционально) { #terminal-selection }
У части мерчантов на один способ оплаты может быть заведено несколько
терминалов (например, два терминала под СБП). По умолчанию X-Hub сам
распределяет платежи между ними, и в API это никак не отражается. Явный выбор
терминала — **отдельная опция**, которую X-Hub включает по запросу; пока она
не включена, `method_id` в `POST /payments` не принимается.
Если терминал меняет рубли на USDT прямо при оплате — курс и сумма сделки,
границы суммы и коды ошибок этого потока разобраны на отдельной странице
[«Обменный терминал»](../exchange-terminal/index.md).
### Список своих терминалов — GET /payment-methods
```bash
curl https://api.x-hub.online/api/v1/payment-methods \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
```
Опция не включена — `403 METHOD_SELECTION_DISABLED`. Включена — массив:
```json
[
{ "method_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "payment_method": "sbp", "label": "Терминал 1", "required_fields": ["client_phone", "client_email", "description"] },
{ "method_id": "7c1e2f3a-9b04-4f1d-8a52-1c6d0b3e77af", "payment_method": "sbp", "label": "Терминал 2", "required_fields": [] }
]
```
- `method_id` — интеграционный ключ. Передавай его в `POST /payments`, чтобы направить платёж на конкретный терминал.
- `payment_method` — тот же словарь, что и везде: `"sbp"` / `"card"` / `"recurring"`.
- `label` — **чисто косметика** для твоего интерфейса (порядковый номер терминала внутри способа оплаты). Не сопоставляй по нему ничего — он может измениться при переназначении терминалов; единственный стабильный идентификатор — `method_id`.
- `required_fields` — поля данных плательщика (`client_phone`, `client_email`, `description`, `payer`), которые этот терминал требует в `POST /payments` на каждом платеже; пустой массив — требований нет. `payer` — объект с данными плательщика, его поля описаны в [объекте `payer`](#payer-object). Отсутствие любого из требуемых полей — `422 PAYER_DATA_REQUIRED` до создания платежа (см. [предупреждение выше](#body)).
### Выбрать терминал при создании платежа
Передай `method_id` из списка выше в `POST /payments` — платёж уйдёт на этот терминал. Не передал — X-Hub выберет терминал сам (как и раньше).
| Код ошибки | Когда | Платёж создаётся? |
|---|---|---|
| `400 METHOD_SELECTION_DISABLED` | Передан `method_id`, но опция выбора терминала тебе не подключена | Нет |
| `400 METHOD_NOT_AVAILABLE` | Передан `method_id`, которого нет в твоём списке (см. `GET /payment-methods`) | Нет |
## Получить платёж — GET /payments/:id
```bash
curl https://api.x-hub.online/api/v1/payments/pay_550e8400-... \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
```
Ответ — тот же формат что и при создании, но с обновлёнными полями:
- `status` — актуальный
- `actual_amount_rub` — сколько реально заплатил клиент; `null`, пока денег нет (в том числе у `failed`/`cancelled`/`expired`)
- `amount_usdt` — после settlement, сколько начислено
- `payer_name` — имя плательщика, как его сообщил банк; поля может не быть ([подробнее](#payer-name))
- `paid_at`, `settled_at` — временные метки
- `subscription_id` — `"sub_..."` у списания по подписке, `null` у обычного платежа
- `period_ref` — период списания (`"2026-08"`), `null` у обычного платежа
- `refund_pending` — `true`, пока по платежу идёт незавершённый возврат
- `failure_reason` — причина отказа у `failed`, `null` у остальных статусов ([подробнее](#failure-reason))
Ошибка `404 PAYMENT_NOT_FOUND` — если платёж не принадлежит твоему мерчанту.
## СБП-ссылка платежа — GET /payments/:id/sbp-link { #sbp-link }
Возвращает голую ссылку НСПК, стоящую за платёжным окном — чтобы закодировать её в QR в **своём** приложении или боте, не редиректя клиента на нашу страницу.
```bash
curl https://api.x-hub.online/api/v1/payments/pay_550e8400-.../sbp-link \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
```
```json
{ "sbp_link": "https://qr.nspk.ru/AS100000000000000000000000000?type=02&bank=100000000111&sum=100000&cur=RUB" }
```
- `sbp_link` — ссылка НСПК (`qr.nspk.ru` / `web.qr.nspk.ru`) или `null`.
- `null` — платёж открыт, но у этого способа оплаты нет голой НСПК-ссылки (брендовая страница оплаты): рисуй QR нечего, води клиента через `payment_url`.
- Работает только по **открытым** статусам (`pending`, `processing`). Закрытый (`paid`, `expired`, …) — тот же `404 PAYMENT_NOT_FOUND`, что и чужой платёж: по закрытому ссылка уже не нужна.
- Для части способов оплаты ссылка доступна только с включённым у мерчанта разрешением «прямые ссылки» — без него оплата идёт через платёжное окно X-Hub (`payment_url`), и этот эндпоинт отвечает `403 DIRECT_LINK_NOT_ALLOWED`. Разрешение включает поддержка по запросу.
## Хэш перевода клиенту — POST и GET /payments/:id/delivery-proof { #delivery-proof }
Хэш транзакции, которой клиент получил перевод USDT: подтверждает получение и используется, если по платежу возникает спор. Платёж, расчёт и выплата его не ждут. Запрос работает по платежам терминала с обменом при оплате; по остальным платежам и по платежам тестового режима отвечает `400 FEATURE_NOT_ENABLED`.
```bash
curl -X POST https://api.x-hub.online/api/v1/payments/pay_550e8400-.../delivery-proof \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Content-Type: application/json" \
-d '{"network": "TRC20", "tx_hash": "b1f0c4d2a7e83a6f5d9c0e1b2a3f4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d"}'
```
```json
{ "network": "TRC20", "tx_hash": "b1f0c4d2a7e83a6f5d9c0e1b2a3f4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d", "status": "accepted" }
```
- `network` — `TRC20`, `ERC20` или `BEP20`; `tx_hash` — хэш транзакции в формате этой сети, до 200 символов.
- `POST` принимается по платежу в статусе `paid` или `settled`, иначе `409 INVALID_STATUS`. Тот же хэш в той же сети повторно — безопасно, ответ тот же.
- `GET` отдаёт текущий хэш и его `status`; пока хэша нет — `{ "network": null, "tx_hash": null, "status": "waiting" }`.
- `status`: `waiting`, `accepted`, `in_review`, `awaiting_confirmations`, `confirmed`, `confirmed_amount_differs`, `rejected`. У `rejected` есть поле `reason`: `not_found_or_unconfirmed`, `already_used` или `invalid`.
- Хэш можно заменить, пока он `accepted`, `in_review` или `awaiting_confirmations`, и передать заново после `rejected`. Подтверждённый (`confirmed`, `confirmed_amount_differs`) через API не заменяется: `409 DELIVERY_PROOF_LOCKED`.
Статусы, причины отказа и порядок действий — в [описании шага](../exchange-terminal/index.md#step-delivery-proof).
## Список платежей — GET /payments
### Query параметры
| Параметр | Тип | По умолчанию |
|---|---|---|
| `page` | int | `1` (макс `250000`) |
| `per_page` | int | `20` (макс `100`) |
| `status` | string | — (все) |
| `from` | ISO8601 | — |
| `to` | ISO8601 | — |
| `shop_id` | uuid | — (все магазины) |
`shop_id` — только платежи этого магазина (для магазина по умолчанию — вместе с платежами, созданными до появления магазинов). Чужой или несуществующий магазин — `400 SHOP_NOT_FOUND`.
Значение вне границ (`page`/`per_page`) — `400 VALIDATION_ERROR`.
`status` — один из `pending`, `processing`, `paid`, `amount_mismatch`, `settled`,
`expired`, `cancelled`, `failed`, `refunded` (регистр не важен: `PAID` работает
так же, как `paid`). Незнакомое значение — `400 VALIDATION_ERROR` с перечнем
допустимых, а не «фильтр не сработал».
Пример:
```
GET /api/v1/payments?status=paid&from=2026-04-01T00:00:00Z&per_page=50
```
### Ответ
```json
{
"data": [
{ "id": "pay_...", "status": "paid", "amount_rub": "500.00" }
],
"pagination": {
"page": 1,
"per_page": 50,
"total": 245,
"total_pages": 5
}
}
```
## Edge cases
### amount_mismatch
Клиент заплатил больше или меньше указанного. В webhook и GET:
```json
{
"status": "amount_mismatch",
"amount_rub": "500.00",
"actual_amount_rub": "490.00"
}
```
Что делать: связаться с клиентом / X-Hub команда поможет разрулить. Деньги **не теряются**.
!!! note "Возврат в PAID"
После разбора админом X-Hub платёж может быть переведён из `amount_mismatch` обратно в `paid` или в `failed` — **прилетит ещё один webhook** `payment.status_changed` с новым статусом. Твой обработчик должен быть идемпотентным и готов к такому переходу.
### expired { #edge-case-expired }
Клиент не оплатил в срок (`expires_at`). Повторить этот платёж нельзя — для новой попытки создавай новый.
!!! danger "`expired` — НЕ терминальный статус. Не закрывай заказ намертво"
Часть банков подтверждает оплату уже после того, как мы истекли по таймауту. Такую позднюю оплату мы **спасаем**: перечитываем состояние заказа у платёжной системы и, если оплата подтвердилась, переводим платёж `expired → paid` — прилетает ещё один `payment.status_changed`.
Как это работает:
- Окно спасения — **6 часов от момента создания** платежа. Позже мы уже не переспрашиваем.
- Спасение возможно не для всех способов оплаты — рассчитывать на него как на гарантию нельзя. Гарантировано другое: `expired` может смениться, и твой обработчик обязан это пережить.
- Действуем **только** на подтверждённую оплату. Отказ, таймаут и промежуточный статус платёж не трогают — честно истёкшие заказы так и остаются `expired`.
- Если поздняя оплата пришла на другую сумму, платёж уходит в `amount_mismatch` (ручной разбор), а не в `paid`.
**Что это значит для интегратора.** Если по `expired` ты необратимо закрываешь заказ (удаляешь корзину, отпускаешь товар, банишь повторную выдачу), то при поздней оплате клиент заплатит, а товара не получит — деньги у нас, обязательство у тебя.
Правильно: по `expired` помечай заказ как «не оплачен» **обратимо** и разреши клиенту новую попытку. Если позже придёт `paid` по тому же `payment_id` — выдай товар (или, если клиент уже оплатил повторно, оформи [возврат](./refunds.md#refunds) по лишнему платежу). Разрешённые переходы из `expired` — только `paid` и `amount_mismatch`; никаких других.
### failed
Платёж не прошёл. Финальный статус. **Почему** — в поле `failure_reason`, и от него зависит, что делать дальше.
#### Причина отказа — `failure_reason` { #failure-reason }
Поле есть в объекте платежа (REST) **всегда**: у всех статусов, кроме `failed`, оно `null`. В webhook-payload оно приходит **только** у `status: "failed"` — так же, как `subscription_id` и `period_ref` приходят только у списаний по подписке.
| `failure_reason` | Что произошло | Что делать |
|---|---|---|
| `insufficient_funds` | На счёте плательщика не хватило денег | **Повторить списание через 1–3 дня.** Подписку не отменять — деньги на счёте появляются. Это самый частый отказ у рекуррента. |
| `declined_by_bank` | Банк плательщика отклонил операцию: лимит, антифрод, блокировка карты | Повторить **один** раз позже. Если снова — попросить плательщика сменить способ оплаты. |
| `mandate_revoked` | Согласие на автосписание больше не действует | Повторы бессмысленны — попросить плательщика **привязать оплату заново**. |
| `cancelled_by_customer` | Плательщик сам отменил платёж | Автоповторов **не делать** — это осознанный отказ. |
| `cancelled_by_bank` | Платёжную сессию отменил **банк** — до того, как истёк срок оплаты | Плательщик ни при чём, ждать ему нечего: выставить новый счёт. Не путать с `expired` — срок ещё не истёк, и «повторите позже» здесь неверный совет. |
| `expired` | Счёт не оплатили до истечения срока | Выставить новый счёт / прислать новую ссылку. Платёжное средство тут ни при чём. |
| `unknown` | Причину распознать не удалось | Повторить один раз. Если повторяется — написать в поддержку с `payment_id`. |
!!! note "Почему `failure_reason: "expired"` бывает у статуса `failed`"
«Срок истёк» приезжает к тебе двумя разными путями, и это одно и то же событие:
- **наш** TTL (15 минут) закрывает неоплаченный счёт → `status: "expired"`, поля `failure_reason` нет;
- **платёжная система** сообщила, что счёт у неё истёк → `status: "failed"` + `failure_reason: "expired"`.
Действие в обоих случаях одинаковое — выставить новый счёт. Если у тебя ветка `expired` уже написана, направь в неё и этот случай.
!!! tip "Обрабатывай по белому списку"
Набор кодов будет пополняться по мере того, как мы уточняем причины отказов. Ветку `default` пиши как «повторить один раз, дальше в поддержку» (то есть как `unknown`), а не как «отменить подписку»: незнакомый код почти всегда означает исправимую ситуацию.
Проверить свою обработку можно в песочнице: magic-токен `xhub_test_fail` возвращает `failure_reason: "insufficient_funds"` — тот случай, ради которого ветку повторного списания и стоит написать.
---