Платежи
Жизненный цикл
Обычный путь платежа: pending → processing → paid → settled. Быстрая обработка проскакивает processing и отдаёт paid сразу.
Терминальны только failed, cancelled и refunded — из них переходов нет. Всё остальное, включая expired, может измениться, и неочевидные переходы разобраны в 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 |
Банк-эквайер вернул деньги клиенту после того как мы засчитали платёж. Терминальный. См. ниже |
Возврат после успешной оплаты — refunded
Банк-эквайер может отозвать платёж и вернуть деньги клиенту через минуты, часы, дни или месяцы после того как мы прислали тебе payment.status_changed со status: paid или settled.
Когда это происходит:
- Эквайер возвращает RUB клиенту со счёта X-Hub
- X-Hub переводит платёж в
status: refunded(терминальный) - Соответствующая сумма USDT списывается с твоего
available_balance - Если выплата за этот платёж ещё PENDING в твоём кабинете — она автоматически пересчитывается (уменьшается)
- Если выплата уже была PAID (USDT уже у тебя в кошельке) — мы свяжемся для clawback'а (обычно: вычитаем из следующей выплаты)
- Прилетит 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
{
"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) | Явный выбор терминала — см. выбор терминала ниже. Если у тебя не включена эта опция — 400 METHOD_SELECTION_DISABLED, платёж не создаётся. Не передан — маршрут выбирает X-Hub |
|
ttl_minutes |
integer | Срок жизни ссылки на оплату, в минутах, от 1 до 129600. Применяется, если способ оплаты твоего аккаунта поддерживает свой срок; если нет — ссылка живёт стандартное время этого способа, а параметр ничего не меняет. Не передан — стандартное время способа. Выше максимума твоего аккаунта — 422 VALIDATION_ERROR, платёж не создаётся. Фактический срок всегда в expires_at ответа — сверяйся с ним, а не с переданным значением |
|
shop_id |
string (uuid) | Магазин платежа, если в аккаунте несколько магазинов — голый uuid, тот же, что приходит в shop_id вебхука. Не передан — платёж идёт в магазин по умолчанию. Отказы — ниже |
|
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 |
client_info |
object | Данные плательщика для антифрода X-Hub — JSON-объект до 4096 байт (считается сериализованный JSON в UTF-8). Не объект, больше 4096 байт или символ NUL — 400 VALIDATION_ERROR, платёж не создаётся. Используется только нашим антифродом, в банк не передаётся и в ответах API, вебхуках и кабинете не возвращается. Рекомендуемые ключи — ниже |
|
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. Действует, только если есть адрес возврата |
success_url — это удобство, а не подтверждение оплаты
Переход по success_url означает лишь, что плательщик нажал кнопку возврата. Он может закрыть приложение банка и не вернуться вовсе, а до статуса paid платёж может дойти позже перехода. Источник истины — webhook payment.status_changed и GET /payments/{id}; заказ отдавать по ним, а не по факту редиректа.
Назначение платежа
Это текст, который плательщик видит на экране оплаты в приложении банка — туда стоит вписывать номер заказа или название подписки, а не внутренний идентификатор.
Источник берётся по цепочке: description → если его нет, external_id → если нет и его, подставляется стандартный текст.
Перед тем как текст уходит в банк, он обрабатывается:
- повторяющиеся пробелы схлопываются в один;
- часть служебных слов вырезается;
- если после этого текст короче 15 символов — вместо него подставляется стандартный текст;
- если длиннее 140 символов — обрезается до 140 (без потери эмодзи на границе среза), а не отклоняется.
Например, "VPN подписка 1 месяц" плательщик увидит как "VPN подписка 1 месяц".
Данные плательщика: некоторые терминалы требуют их на каждом платеже
Часть терминалов (справочник — GET /api/v1/payment-methods, поле required_fields в каждом методе) требует данные плательщика на каждом платеже: client_phone, client_email, description и/или объект payer. Если выбран такой терминал (маршрутом X-Hub или твоим method_id), а какого-то из полей нет — платёж не создаётся и API отвечает:
{
"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.
Рекомендация: передавай `client_phone`, `client_email` и `description` в **каждом** платеже, когда они у тебя есть, — тогда переход на другой терминал или способ оплаты не начнёт отклонять твои платежи.
Объект payer
Данные плательщика, которые ты собрал, когда проверял своего клиента. Он обязателен на терминале, у которого в required_fields есть payer (см. обменный терминал): без объекта такой платёж не создаётся.
"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.
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 |
"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, вебхуках, кабинете и выгрузках не возвращаются.
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
{
"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", бывает только у списания по подписке — его инициирует платёжная система, и в теле запроса оно не передаётся (подписки). Ровно эти же значения приходят в webhook.payment_url— дляpayment_method: "sbp"это ссылка НСПК на оплату по СБП. Её можно отдать клиенту редиректом, а можно закодировать в QR у себя и показать на своей странице или прислать картинкой в бот (host-to-host) — тогда клиент остаётся у тебя. Дляpayment_method: "card"это поле работает иначе — см. оплата картой ниже, не полагайся на этот абзац для карты.- Окно оплаты 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не меняется — платёж и правда всё ещё оплачен, а возврат может не состояться. Подробнее — возвраты.subscription_id— подписка, по которой сделано списание ("sub_..."). Поле присутствует всегда; у обычного платежа оноnull.period_ref— период, который это списание занимает в идемпотентности ("2026-08"/"2026-W32"/"2026-08-07"— формат зависит отintervalподписки). Поле присутствует всегда; у обычного платежа оноnull. Считать период самому не нужно — см. идемпотентность списания.method_id— какой терминал реально принял платёж (см. выбор терминала) — заполняется автоматически, независимо от того, передавал ли тыmethod_idв запросе. Поле присутствует всегда;null, если у тебя не подключён выбор терминала или платёж не был направлен на конкретный терминал.failure_reason— почему платёж не прошёл. Поле присутствует всегда; у всех статусов, кромеfailed, оноnull. Набор значений и рекомендованное действие по каждому — причина отказа.shop_id— магазин платежа (голый uuid, как в вебхуке). Поле присутствует всегда; у платежей, созданных до появления магазинов, — магазин по умолчанию.sandbox—trueтолько в тестовом режиме.expires_at— фактический срок жизни ссылки: он учитываетttl_minutes, если способ оплаты твоего аккаунта его применил, иначе это стандартное время способа. Всегда ориентируйся на это поле.
Что делать: редирект клиента на payment_url.
Отказы при создании платежа
Кроме ошибок в самом запросе (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 |
Полный список кодов и стратегия ретраев — Коды ошибок.
Магазин платежа — shop_id
Ключ API один на аккаунт; если магазинов несколько, магазин платежа передаётся в shop_id:
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 |
Платёж ни в одном из этих случаев не создаётся.
Оплата картой
Запрос устроен так же, как и для СБП — меняется только payment_method:
{
"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 внутри устроен иначе:
{
"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"
}
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/...), который сам сделает следующий редирект. Какой именно — деталь реализации, которая может измениться; полагаться на конкретный домен или путь нельзя.
Если приём карт не подключён
payment_method: "card" — не флаг, который включает карту сам по себе. Доступность карты — свойство твоего аккаунта, и она проверяется на сервере до того, как платёж будет создан:
- ответ —
400 CARD_NOT_SUPPORTED; - платёж не создаётся — в системе не остаётся ни строки, ни
payment_id, ретраить с тем жеIdempotency-Keyнечего донасоздавать; - это не сбой и не временная ошибка — ретрай ничего не изменит.
Подключение карт — настройка на уровне аккаунта, её включает X-Hub по обращению (как и KYC, см. client_phone выше). В теле запроса включить приём карт нельзя — напиши в 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) — те же самые, что описаны выше и в обработке ошибок. - Возвраты.
POST /refundsработает с оплаченным картой платежом так же, как с оплаченным по СБП — поpayment_id, без привязки к способу оплаты.
Выбор терминала (опционально)
У части мерчантов на один способ оплаты может быть заведено несколько
терминалов (например, два терминала под СБП). По умолчанию X-Hub сам
распределяет платежи между ними, и в API это никак не отражается. Явный выбор
терминала — отдельная опция, которую X-Hub включает по запросу; пока она
не включена, method_id в POST /payments не принимается.
Если терминал меняет рубли на USDT прямо при оплате — курс и сумма сделки, границы суммы и коды ошибок этого потока разобраны на отдельной странице «Обменный терминал».
Список своих терминалов — GET /payment-methods
Опция не включена — 403 METHOD_SELECTION_DISABLED. Включена — массив:
[
{ "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. Отсутствие любого из требуемых полей —422 PAYER_DATA_REQUIREDдо создания платежа (см. предупреждение выше).
Выбрать терминал при создании платежа
Передай method_id из списка выше в POST /payments — платёж уйдёт на этот терминал. Не передал — X-Hub выберет терминал сам (как и раньше).
| Код ошибки | Когда | Платёж создаётся? |
|---|---|---|
400 METHOD_SELECTION_DISABLED |
Передан method_id, но опция выбора терминала тебе не подключена |
Нет |
400 METHOD_NOT_AVAILABLE |
Передан method_id, которого нет в твоём списке (см. GET /payment-methods) |
Нет |
Получить платёж — GET /payments/:id
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— имя плательщика, как его сообщил банк; поля может не быть (подробнее)paid_at,settled_at— временные меткиsubscription_id—"sub_..."у списания по подписке,nullу обычного платежаperiod_ref— период списания ("2026-08"),nullу обычного платежаrefund_pending—true, пока по платежу идёт незавершённый возвратfailure_reason— причина отказа уfailed,nullу остальных статусов (подробнее)
Ошибка 404 PAYMENT_NOT_FOUND — если платёж не принадлежит твоему мерчанту.
СБП-ссылка платежа — GET /payments/:id/sbp-link
Возвращает голую ссылку НСПК, стоящую за платёжным окном — чтобы закодировать её в QR в своём приложении или боте, не редиректя клиента на нашу страницу.
curl https://api.x-hub.online/api/v1/payments/pay_550e8400-.../sbp-link \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
{ "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
Хэш транзакции, которой клиент получил перевод USDT: подтверждает получение и используется, если по платежу возникает спор. Платёж, расчёт и выплата его не ждут. Запрос работает по платежам терминала с обменом при оплате; по остальным платежам и по платежам тестового режима отвечает 400 FEATURE_NOT_ENABLED.
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"}'
{ "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.
Статусы, причины отказа и порядок действий — в описании шага.
Список платежей — 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 с перечнем
допустимых, а не «фильтр не сработал».
Пример:
Ответ
{
"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:
Что делать: связаться с клиентом / X-Hub команда поможет разрулить. Деньги не теряются.
Возврат в PAID
После разбора админом X-Hub платёж может быть переведён из amount_mismatch обратно в paid или в failed — прилетит ещё один webhook payment.status_changed с новым статусом. Твой обработчик должен быть идемпотентным и готов к такому переходу.
expired
Клиент не оплатил в срок (expires_at). Повторить этот платёж нельзя — для новой попытки создавай новый.
expired — НЕ терминальный статус. Не закрывай заказ намертво
Часть банков подтверждает оплату уже после того, как мы истекли по таймауту. Такую позднюю оплату мы спасаем: перечитываем состояние заказа у платёжной системы и, если оплата подтвердилась, переводим платёж expired → paid — прилетает ещё один payment.status_changed.
Как это работает:
- Окно спасения — 6 часов от момента создания платежа. Позже мы уже не переспрашиваем.
- Спасение возможно не для всех способов оплаты — рассчитывать на него как на гарантию нельзя. Гарантировано другое:
expiredможет смениться, и твой обработчик обязан это пережить. - Действуем только на подтверждённую оплату. Отказ, таймаут и промежуточный статус платёж не трогают — честно истёкшие заказы так и остаются
expired. - Если поздняя оплата пришла на другую сумму, платёж уходит в
amount_mismatch(ручной разбор), а не вpaid.
Что это значит для интегратора. Если по expired ты необратимо закрываешь заказ (удаляешь корзину, отпускаешь товар, банишь повторную выдачу), то при поздней оплате клиент заплатит, а товара не получит — деньги у нас, обязательство у тебя.
Правильно: по expired помечай заказ как «не оплачен» обратимо и разреши клиенту новую попытку. Если позже придёт paid по тому же payment_id — выдай товар (или, если клиент уже оплатил повторно, оформи возврат по лишнему платежу). Разрешённые переходы из expired — только paid и amount_mismatch; никаких других.
failed
Платёж не прошёл. Финальный статус. Почему — в поле 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. |
Почему failure_reason: "expired" бывает у статуса failed
«Срок истёк» приезжает к тебе двумя разными путями, и это одно и то же событие:
- наш TTL (15 минут) закрывает неоплаченный счёт →
status: "expired", поляfailure_reasonнет; - платёжная система сообщила, что счёт у неё истёк →
status: "failed"+failure_reason: "expired".
Действие в обоих случаях одинаковое — выставить новый счёт. Если у тебя ветка expired уже написана, направь в неё и этот случай.
Обрабатывай по белому списку
Набор кодов будет пополняться по мере того, как мы уточняем причины отказов. Ветку default пиши как «повторить один раз, дальше в поддержку» (то есть как unknown), а не как «отменить подписку»: незнакомый код почти всегда означает исправимую ситуацию.
Проверить свою обработку можно в песочнице: magic-токен xhub_test_fail возвращает failure_reason: "insufficient_funds" — тот случай, ради которого ветку повторного списания и стоит написать.