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

Платежи

Жизненный цикл

Обычный путь платежа: 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.

Когда это происходит:

  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

{
  "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

curl https://api.x-hub.online/api/v1/payment-methods \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."

Опция не включена — 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 — если платёж не принадлежит твоему мерчанту.

Возвращает голую ссылку НСПК, стоящую за платёжным окном — чтобы закодировать её в 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 с перечнем допустимых, а не «фильтр не сработал».

Пример:

GET /api/v1/payments?status=paid&from=2026-04-01T00:00:00Z&per_page=50

Ответ

{
  "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:

{
  "status": "amount_mismatch",
  "amount_rub": "500.00",
  "actual_amount_rub": "490.00"
}

Что делать: связаться с клиентом / 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" — тот случай, ради которого ветку повторного списания и стоит написать.