Документация¶
X-Hub — платёжный шлюз для B2B-мерчантов. Принимаем рубли от российских клиентов через СБП, рассчитываемся с вами по фиксированному курсу на следующий рабочий день.
Base URL: https://api.x-hub.online/api/v1
Что получает мерчант¶
- Приём платежей СБП без юр.лица в РФ
- USDT → RUB обмены для обменников (клиент шлёт USDT, получает RUB на карту)
- Автоматические ежедневные выплаты USDT (TRC20) на твой кошелёк
- Webhook-уведомления о каждом изменении статуса
- Идемпотентность, HMAC-подпись, ретраи из коробки
- Markup-курс фиксируется на сутки (T+1 settlement)
Ключевые концепции¶
| Термин | Что это |
|---|---|
| Payment | Заявка на приём рублей. Создаётся тобой, оплачивается клиентом через СБП |
| Exchange | Обмен USDT → RUB. Клиент шлёт USDT, получает RUB на карту по СБП |
| Webhook | HTTP POST от X-Hub на твой URL при изменении статуса платежа/обмена |
| Settlement | Ежедневная конвертация pending RUB → available USDT по фиксированному курсу |
| Withdrawal | Автоматическая выплата USDT с баланса на твой кошелёк — происходит сама, без запросов с твоей стороны |
| Markup | Твой процент сверх биржевого курса (договаривается при подключении) |
Быстрый старт¶
Создадим первый платёж и получим webhook за 5 минут.
Что нужно¶
api_key— публичный идентификатор. Строка видаxh_live_...(продакшн) илиxh_test_...(sandbox)api_secret— секрет, хранится на твоём бэкендеwebhook_secret— для верификации webhook'ов- Публичный URL для webhook'ов (HTTPS, работающий 24/7)
Как получить ключи¶
-
Свяжись с командой X-Hub через контакты, которые передал тебе менеджер. Обсудим тариф, настроим нужные опции (приём RUB / обмен USDT→RUB / KYC) и создадим твой аккаунт.
-
На твой email прилетит magic-link от X-Hub. Кликаешь → попадаешь в свой кабинет на
https://app.x-hub.online. -
Генерируешь свои ключи сам. В кабинете раздел «API ключи» → большая кнопка «Сгенерировать API-ключи». Нажимаешь и получаешь один раз все три секрета:
api_key(префиксxh_test_для sandbox илиxh_live_для production)api_secretwebhook_secret
-
Сразу сохраняешь в менеджер паролей (1Password, Bitwarden и т.п.).
api_keyпотом можно посмотреть в любой момент в кабинете, ноapi_secretиwebhook_secretбольше показаны не будут.
Как хранятся секреты
Ключи и секреты генерируются на сервере X-Hub и показываются один раз — в момент генерации. api_secret хранится у нас только в виде хэша — восстановить его нельзя, только перевыпустить. webhook_secret хранится на нашей стороне: он нужен серверу X-Hub, чтобы подписывать каждый webhook.
Ключи у тебя раздельные для теста и боя, а webhook_secret — общий
api_key + api_secret живут в двух независимых слотах: тестовом и боевом. Когда ты генерируешь ключи второго слота, webhook_secret не выдаётся повторно — он один на оба режима, не меняется, и в открытом виде показывается ровно один раз, при самой первой генерации. Если ты его потерял — перевыпусти его отдельно (см. ниже); повторно «подсмотреть» существующий нельзя. Так сделано намеренно: этим секретом подписываются уведомления об оплате, и любой, у кого есть доступ к твоему кабинету, иначе мог бы получить его, просто нажав «сгенерировать ключи» в тестовом режиме.
Ротация¶
api_secret— ротируешь сам в том же разделе «API ключи» кнопкой «Перевыпустить». Старый секрет сразу перестаёт работать. Ротируется секрет того слота, в котором ты сейчас работаешь; при подозрении на утечку боевого ключа можно (и нужно) сначала перевести аккаунт в тестовый режим — приём боевых платежей остановится, — а потом сменить именно боевой секрет.webhook_secret— перевыпускается отдельной кнопкой в блоке Webhook. Старый секрет сразу перестаёт работать, поэтому меняй его синхронно со своим бэкендом (лучше в окно низкого трафика).api_key— стабильный публичный идентификатор. Ротация не предусмотрена (для смены нужен новый merchant-аккаунт).- Emergency reset — если потерял доступ к кабинету, попроси X-Hub через поддержку перевыпустить
api_secret— тебе передадут его разово через защищённый канал. Уточни, какой слот меняем: тестовый или боевой.
Храни api_secret и webhook_secret только на бэкенде
Никогда не отдавай клиенту/браузеру. При компрометации — перевыпусти через кабинет.
Настройка Webhook URL¶
После генерации ключей укажи URL твоего сервера, куда X-Hub будет присылать события платежей:
- В том же разделе «API ключи» кабинета найди блок Webhook URL
- Нажми «Изменить» → вставь URL своего endpoint'а (только HTTPS публичный)
- Сохрани
Важно:
- Пока
webhook_urlне указан (или не сгенерированwebhook_secret), webhook'и копятся в PENDING и не отправляются. Как только конфигурация становится полной (URL + секрет), накопленные события отпускаются сразу — в ближайшем цикле доставки, а не через отложенную паузу. Новые события после этого доставляются в течение ~5 секунд. - Если webhook так и не настроен, накопленные события живут 30 суток, после чего помечаются
FAILEDи не доставляются уже никогда. - URL можно менять в любой момент — изменение не требует реcтарта интеграции.
- Подробнее про формат webhook payload — в разделе Webhook ниже, про проверку подписи — в Verify signature.
Переход sandbox → production¶
У тебя один аккаунт и два слота ключей: тестовые xh_test_... и боевые xh_live_...:
- В sandbox тестируешь интеграцию под ключами
xh_test_...(без реальных денег). - Когда готов к боевому запуску — сам переключаешь режим в кабинете кнопкой «Активировать боевой режим», генерируешь
xh_live_...ключи и подставляешь их в код вместо test-ключей. - Запрос с ключом не-активного режима вернёт
401с кодомMODE_MISMATCHи понятным сообщением, какой режим сейчас активен.
Подробнее — в разделе Sandbox ниже.
Сетевые требования¶
Входящие webhook'и (X-Hub → твой сервер)¶
X-Hub отправляет webhook'и с фиксированного IP 72.56.237.132. Если твой webhook-endpoint закрыт файрволом — добавь этот IP в whitelist.
Исходящие запросы (твой сервер → X-Hub)¶
Если X-Hub выдал тебе IP-whitelist (не всем мерчантам — по запросу), то запросы с других IP получат 403 IP_NOT_WHITELISTED. По умолчанию whitelist не активен.
Production endpoint: https://api.x-hub.online | Sandbox: тот же URL, разный префикс ключа.
1. Создать платёж¶
curl -X POST https://api.x-hub.online/api/v1/payments \
-H "X-Api-Key: xh_test_abc123..." \
-H "X-Api-Secret: YOUR_API_SECRET" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_rub": "500.00",
"external_id": "order-12345",
"payment_method": "sbp",
"description": "VPN подписка 1 месяц"
}'
Ответ:
{
"id": "pay_550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"amount_rub": "500.00",
"payment_url": "https://paywidget.org/abc-def-ghi",
"expires_at": "2026-04-14T13:15:00Z"
}
Что дальше: редирект клиента на payment_url или встраивание iframe.
2. Клиент оплачивает¶
Клиент видит QR-код СБП, оплачивает через приложение банка. X-Hub получает подтверждение, меняет статус на paid.
3. Получить webhook¶
X-Hub делает POST на твой webhook_url с payload:
{
"event": "payment.status_changed",
"payment_id": "pay_550e8400-...",
"external_id": "order-12345",
"status": "paid",
"previous_status": "processing",
"amount_rub": "500.00",
"actual_amount_rub": null,
"amount_usdt": null,
"payment_method": "sbp",
"timestamp": "2026-04-14T13:05:12Z",
"metadata": {"user_id": "usr_abc"}
}
В payload payment.status_changed есть поле refund_pending (true, пока по платежу идёт незавершённый возврат; присутствует всегда).
У неуспешного списания в payload приходит failure_reason — почему не прошло. От причины зависит, что делать: не хватило денег → повторить через день-два и подписку не отменять; согласие на автосписание отозвано → просить привязать заново. Поле приходит только у status: "failed"; у остальных статусов его в payload нет. Коды и действия →
У рекуррентного списания в payload дополнительно приходят два поля: "subscription_id": "sub_..." — подписка (и её пользователь), и "period_ref": "2026-08" — период, который это списание занимает. По ним событие сопоставляется с подпиской и периодом без обращения к API. Оба поля приходят при любом изменении статуса списания: оплата, отказ, истечение, расчёт — и в песочнице тоже. У обычных (не подписочных) платежей этих полей в payload нет.
В заголовках X-Webhook-Signature и X-Webhook-Timestamp — HMAC-SHA256 от timestamp + "." + body, ключ = твой webhook_secret. Как проверить подпись →
4. Проверить статус вручную¶
curl https://api.x-hub.online/api/v1/payments/pay_550e8400-... \
-H "X-Api-Key: xh_test_abc123..." \
-H "X-Api-Secret: YOUR_API_SECRET"
Sandbox (тестовый режим)¶
Sandbox позволяет протестировать интеграцию до реальных платежей — без реальных денег и вызовов внешних платёжных систем. Все endpoints работают как в prod, но платежи симулируются: по умолчанию автоматически (magic-токены, см. ниже), а вручную — кнопками на песочной странице оплаты, которая открывается по payment_url. Админ X-Hub для этого не нужен.
Как получить¶
Попросите админа X-Hub создать аккаунт — новый аккаунт стартует в тестовом режиме. После этого на ваш email придёт magic-link → зайдите в кабинет → нажмите «Сгенерировать API-ключи». Sandbox-ключи имеют префикс xh_test_ (production — xh_live_). Процесс тот же что в «Как получить ключи» выше, отличается только префикс.
Отличия от production¶
| Поле | Sandbox | Production |
|---|---|---|
| API key prefix | xh_test_... |
xh_live_... |
| Payment-gateway | fake (без HTTP) | Реальный платёжный шлюз |
| Payment URL | https://app.x-hub.online/sandbox/pay/{payment_id} — рабочая песочная страница оплаты (не заглушка), см. ниже. Точный адрес всегда приходит в payment_url |
Реальная ссылка СБП |
| Смена статуса | Авто-симуляция magic-токенами (см. ниже) или кнопками на песочной странице оплаты — в любой момент, без участия админа X-Hub | Автоматически (callback) |
| Реальные USDT | Нет, только в ledger | Да |
| Settlement (T+1) | Пропускается | Выполняется |
| Anomaly detection | Пропускается | Активно |
| Webhook мерчанту | Присылается | Присылается |
sandbox в API response |
true |
false (всегда присутствует) |
sandbox в webhook payload |
true |
поле отсутствует |
Обмены и KYC в sandbox¶
- Обмены (
POST /api/v1/exchanges) полностью работают и в sandbox — на изолированном тестовом леджере. Если опция обменов не включена на аккаунте —400 FEATURE_NOT_ENABLED(как и в production). - KYC (
/api/v1/kyc/*) — зависит от конфигурации аккаунта: у части аккаунтов sandbox-KYC отключён (400 SANDBOX_NOT_SUPPORTED), у остальных работает. - Подписки (
/api/v1/subscriptions) работают в sandbox, но с важным расхождением с боем — списания при привязке мандата в песочнице не происходит, а сами списания становятсяpaidавтоматически через 30 секунд. Обязательно прочитай Подписки → Sandbox перед тем как переносить интеграцию в бой. ЕслиPOST /subscriptionsв песочнице отвечает501 RECURRING_NOT_SUPPORTED— это, скорее всего, не про твой аккаунт, см. Подписки.
Flow¶
- Создаёте платёж через API как в prod:
POST /api/v1/payments - В ответе видите
"sandbox": trueиpayment_urlна песочную страницу оплаты - Дальше — два пути, они друг друга не исключают:
- ничего не делать: sandbox сам переведёт платёж в терминальный статус (по умолчанию — happy-path
paidчерез 30 секунд, см. magic-токены); - открыть
payment_urlи нажать нужную кнопку — статус сменится немедленно. Кто сработал первым, тот и выиграл: второй путь после этого просто ничего не делает (idempotent).
- ничего не делать: sandbox сам переведёт платёж в терминальный статус (по умолчанию — happy-path
- Ваш webhook endpoint получает payload и обрабатывает как обычно.
- Проверяете ваш обработчик, баланс, интеграцию.
Magic-токены — сценарии ошибок¶
Чтобы протестировать не только happy-path, передавайте ключевое слово
в external_id или description. Sandbox опознаёт его и
автоматически триггерит нужный статус:
| Ключевое слово | Delay | Итог |
|---|---|---|
| (ничего) | 30 сек | paid ← default |
xhub_test_paid |
5 сек | paid (быстро) |
xhub_test_fail |
5 сек | failed c failure_reason: "insufficient_funds" |
xhub_test_expire |
60 сек | expired |
xhub_test_mismatch |
5 сек | amount_mismatch (фактическая сумма = amount_rub - 1) |
xhub_test_manual |
— | авто-симуляция выключена: статус двигаешь сам кнопками на песочной странице оплаты |
xhub_test_fail намеренно отдаёт самую частую и самую дорогую причину — «не хватило денег». Это тот случай, где подписку отменять не надо, а надо повторить списание через день-два; написать эту ветку удобнее здесь, чем на живых подписчиках. Полный список кодов — причина отказа.
Поиск токена — case-insensitive и word-boundary (regex \b<token>\b): токен должен быть отдельным «словом», отделённым пробелом, дефисом или пунктуацией. Например description: "Заказ #42 — xhub_test_fail" сработает, а description: "prexhub_test_failpost" — нет (токен склеен с другими символами).
Если токенов в external_id/description несколько, побеждает первый по порядку из таблицы (paid → fail → expire → mismatch); xhub_test_manual имеет приоритет над всеми и просто отключает автомат.
Песочная страница оплаты¶
payment_url песочного платежа ведёт на рабочий симулятор оплаты на app.x-hub.online. Открой его в браузере — увидишь сумму, статус, обратный отсчёт до expires_at и три кнопки:
| Кнопка | Итоговый статус |
|---|---|
| Имитировать успешную оплату | paid |
| Имитировать ошибку | failed |
| Имитировать истечение срока | expired |
Каждое нажатие проходит ту же машину состояний, тот же леджер и тот же outbox вебхуков, что и боевой callback, — то есть уведомление придёт настоящее, с подписью и с sandbox: true. Страница сама опрашивает статус раз в 3 секунды, поэтому смена статуса авто-симуляцией видна на ней без перезагрузки.
Статус amount_mismatch кнопкой не ставится — его даёт только magic-токен xhub_test_mismatch (или админ X-Hub по запросу).
Те же действия из кода: /api/sandbox/*¶
Страница — тонкая обёртка над двумя публичными endpoint'ами. Их можно дёргать напрямую из автотестов: авторизация не нужна, заголовки X-Api-Key / X-Api-Secret / Idempotency-Key не передаются.
# Прочитать состояние песочного платежа
curl https://api.x-hub.online/api/sandbox/payment-info/550e8400-e29b-41d4-a716-446655440000
# Перевести его в нужный статус
curl -X POST https://api.x-hub.online/api/sandbox/simulate/550e8400-e29b-41d4-a716-446655440000 \
-H "Content-Type: application/json" \
-d '{"action": "paid"}'
В URL — «голый» UUID, без префикса pay_
В остальном API идентификатор платежа выглядит как pay_550e8400-…. Эти два endpoint'а принимают только UUID-часть — префикс надо отрезать.
GET /api/sandbox/payment-info/{id} → 200:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "PENDING",
"amount_rub": "500.00",
"is_sandbox": true,
"created_at": "2026-04-20T10:00:00.000Z",
"expires_at": "2026-04-20T10:15:00.000Z",
"paid_at": null
}
POST /api/sandbox/simulate/{id} — тело {"action": "paid" | "failed" | "expired"} → 200:
Ошибки:
| HTTP | Code | Причина |
|---|---|---|
| 400 | VALIDATION_ERROR |
action не одно из paid / failed / expired |
| 404 | NOT_FOUND |
Платежа нет или он не песочный. Боевые платежи через эти ручки недоступны в принципе |
| 409 | INVALID_TRANSITION |
Переход недопустим из текущего статуса (например, платёж уже PAID) |
| 409 | STATE_CHANGED |
Статус поменялся параллельно (авто-симуляция успела раньше) — перечитай payment-info |
Не встраивай /api/sandbox/* в боевой код
Эти endpoint'ы существуют только для песочницы и намеренно не требуют авторизации: payment_id — UUIDv4, а боевые платежи отсекаются по is_sandbox. В production аналога нет — платёж туда двигает сама платёжная система.
Практическое следствие: любой, кому известен id твоего песочного платежа, может перевести его в paid. Реальных денег за этим нет, но не используй песочные платежи как доказательство оплаты.
Webhook payload¶
Тот же формат что в prod, плюс дополнительное поле sandbox: true:
{
"event": "payment.status_changed",
"payment_id": "pay_abc123",
"external_id": "ORD-001",
"status": "paid",
"previous_status": "pending",
"amount_rub": "1000.00",
"actual_amount_rub": null,
"amount_usdt": null,
"payment_method": "sbp",
"metadata": null,
"sandbox": true,
"timestamp": "2026-04-20T10:00:00Z"
}
Подпись через заголовок X-Webhook-Signature: sha256=<hex> — та же HMAC-SHA256, что в production.
Поле amount_usdt в sandbox
В sandbox amount_usdt всегда null — реальная конвертация RUB→USDT не выполняется, settlement пропускается (см. таблицу «Отличия от production» выше). Тестируй только обработку статусов и не опирайся на значение amount_usdt в sandbox.
Переключение Тест↔Боевой¶
Sandbox и production живут в одном аккаунте — это два слота ключей и переключатель режима, которым ты управляешь сам:
- Подтверждаешь, что интеграция работает в sandbox (ключи
xh_test_...) - В кабинете генерируешь боевые ключи
xh_live_...(второй слот; тестовые ключи никуда не пропадают) - Нажимаешь «Активировать боевой режим» — аккаунт переходит в боевой режим
- Меняешь ключи в своём коде:
xh_test_→xh_live_ - Вернуться в тест можно в любой момент кнопкой «Вернуться в тест» — и обратно, сколько угодно раз
В каждый момент активен один режим. Запрос с ключом не-активного режима → 401 с кодом MODE_MISMATCH и человекочитаемым сообщением, какой режим сейчас активен. Тестовые и боевые данные (платежи, баланс, идемпотентность) полностью изолированы друг от друга.
Аутентификация¶
Все запросы к API (кроме health-check) требуют два заголовка:
| Header | Значение |
|---|---|
X-Api-Key |
Публичный идентификатор мерчанта. Строка, начинающаяся с xh_live_ (продакшн) или xh_test_ (sandbox) |
X-Api-Secret |
Секрет мерчанта (64 hex символа) |
При невалидных ключах (в т.ч. без префикса xh_live_/xh_test_) — 401 Unauthorized.
Где хранить ключи¶
api_key— можно логировать, не секретapi_secret— только на твоём бэкенде в env/secret manager, не в gitwebhook_secret— только на твоём бэкенде, для проверки HMAC подписи webhook'ов
Правила хранения
- Никогда не клади в git
- Никогда не отдавай клиенту (браузер/мобилка)
- Не пересылай в чатах
- Логируй только
api_key, неapi_secret
Idempotency-Key¶
Обязательно для всех POST запросов, которые создают ресурсы.
Формат: UUID (любая версия, RFC 4122). Не-UUID строка → 400 VALIDATION_ERROR ("Idempotency-Key must be a valid UUID").
Как работает¶
- При первом вызове — создаётся новый ресурс
- Повторный вызов с тем же ключом вернёт исходный ресурс (без дубликата) — в любой момент: уникальность ключа вечная в рамках пары (аккаунт, режим test/live). Статус-код при создании —
201, при idempotent-повторе —200 - 24 часа живёт лишь внутренний кэш быстрых повторов — на семантику он не влияет: повтор с тем же ключом хоть через месяц вернёт исходный ресурс, а не создаст новый
- Тело запроса при повторе не сверяется — возвращается ответ первого вызова. Генерируй новый
Idempotency-Keyдля каждой логически новой операции - Идемпотентность изолирована между режимами: тот же ключ в тестовом (
xh_test_) и боевом (xh_live_) режимах — это два независимых платежа
Зачем¶
Предотвращает двойное списание при сетевых сбоях. Если твой POST упал по таймауту — ретрай с тем же ключом безопасен.
Плохо и хорошо¶
// ❌ НЕПРАВИЛЬНО: генерируем UUID на каждый retry
async function createPayment(amount) {
return fetch('...', {
headers: { 'Idempotency-Key': crypto.randomUUID() }, // каждый retry = новый UUID → дубли
});
}
// ❌ НЕПРАВИЛЬНО: не-UUID строка
// 'Idempotency-Key': `order-${orderId}` → 400 VALIDATION_ERROR
// ✅ ПРАВИЛЬНО: UUID генерируется один раз на заказ,
// сохраняется рядом с заказом в своей БД и переиспользуется при ретраях
async function createPayment(orderId, amount) {
let idempotencyKey = await db.getIdempotencyKey(orderId);
if (!idempotencyKey) {
idempotencyKey = crypto.randomUUID();
await db.saveIdempotencyKey(orderId, idempotencyKey);
}
return fetch('...', {
headers: { 'Idempotency-Key': idempotencyKey }, // тот же UUID при ретраях того же заказа
});
}
Rate Limiting¶
Лимитов четыре, и они разбиты на две независимые корзины: «денежные» ручки (создание приёма денег) и всё остальное в /api/v1. Внутри каждой корзины отдельно считаются лимит на ключ и лимит на IP.
Денежными считаются ровно эти вызовы:
POST /paymentsPOST /exchangesPOST /subscriptionsPOST /subscriptions/{id}/charges
Вложенные пути денежными не считаются: например, POST /payments/{id}/refunds идёт в общую корзину.
| Корзина | Лимит | На что считается | Окно | Порог |
|---|---|---|---|---|
| Денежные ручки | Per-key | значение заголовка X-Api-Key |
60 сек | 120 запросов |
| Денежные ручки | Per-IP | IP-адрес, с которого пришёл запрос | 60 сек | 120 запросов |
Всё остальное в /api/v1 |
Per-key | значение заголовка X-Api-Key |
60 сек | 60 запросов |
Всё остальное в /api/v1 |
Per-IP | IP-адрес, с которого пришёл запрос | 60 сек | 300 запросов |
Корзины не сообщаются друг с другом. Шторм опроса статусов выедает только свои 60/300 и не может заблокировать создание платежа — ради этого разделение и сделано.
Для одного IP это ужесточение
Раньше денежные запросы делили общий IP-бюджет в 300 req/min. Теперь у них собственные 120 req/min на адрес. Если несколько твоих сервисов или несколько мерчант-аккаунтов создают платежи через один NAT/egress-адрес, вы упрётесь в лимит раньше, чем до этого изменения. Планируй нагрузку по созданию платежей отдельно от нагрузки по чтению.
При превышении любого — 429 Too Many Requests с кодом RATE_LIMIT_EXCEEDED. Различить, какой именно лимит сработал, можно по message и по значению X-RateLimit-Limit:
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests for API. Limit: 300 per minute per IP."}}
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many payment requests. Limit: 120 per minute. Retry with the same Idempotency-Key."}}
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many payment requests from this IP. Limit: 120 per minute. Retry with the same Idempotency-Key."}}
Повтор отбитого денежного запроса безопасен: с тем же Idempotency-Key второй платёж не создастся.
Все лимиты работают ДО авторизации
Счётчики увеличиваются на каждом запросе, даже если ключ неверный, отозван или относится к неактивному режиму. Запрос, который в итоге получит 401, всё равно съедает квоту.
Практические следствия:
- Per-key лимит считается по строке ключа, а не по аккаунту. Тестовый (
xh_test_) и боевой (xh_live_) ключи — независимые бюджеты. После ротации ключа новый ключ стартует с чистым счётчиком. - Per-IP лимит — общий на весь исходящий IP. Если несколько твоих сервисов (или несколько мерчант-аккаунтов) ходят к нам через один NAT/egress-адрес, они делят одни и те же 300 req/min на чтение и одни и те же 120 req/min на создание платежей. Ретрай-штормы одного сервиса упрутся в лимит у соседнего.
Заголовки ответа (отдаём оба семейства — legacy и draft-6 standard) приходят на каждом ответе, не только на 429:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1776166800
RateLimit-Policy: 60;w=60
RateLimit-Limit: 60
RateLimit-Remaining: 42
RateLimit-Reset: 38
X-RateLimit-Reset— Unix timestamp (секунды с эпохи), когда окно сбрасываетсяRateLimit-Reset(draft-6) — секунды до сброса окнаRetry-After(секунды) — добавляется только к ответу429
Заголовки описывают тот лимит, до которого запрос дошёл последним. Порядок проверки: сначала IP-лимит своей корзины, затем key-лимит своей корзины. Для чтения в штатной ситуации это Limit: 60, для создания платежа — Limit: 120. Если запрос отбит IP-лимитом, до key-счётчика он не доходит — тогда в заголовках будет Limit: 300 (чтение) или Limit: 120 (создание платежа).
Если получил 429 — подожди Retry-After секунд (или до X-RateLimit-Reset) и ретрай.
Платежи¶
Жизненный цикл¶
stateDiagram-v2
[*] --> pending
pending --> processing
pending --> paid: быстрая обработка
pending --> expired
pending --> cancelled
pending --> failed
pending --> amount_mismatch
processing --> paid
processing --> failed
processing --> cancelled
processing --> amount_mismatch
processing --> expired
paid --> settled
paid --> refunded: банк вернул клиенту до settlement
settled --> refunded: банк вернул клиенту после settlement
amount_mismatch --> paid: разбор X-Hub
amount_mismatch --> failed: разбор X-Hub
expired --> paid: поздняя оплата (окно 6 ч)
expired --> amount_mismatch: поздняя оплата не на ту сумму
Терминальны только failed, cancelled и refunded — из них переходов нет. Всё остальное, включая expired, может измениться.
| Статус | Значение |
|---|---|
pending |
Создан, ждёт оплаты. Истекает по умолчанию через 15 минут — точный срок всегда в поле expires_at ответа, ориентируйся на него, а не на константу |
processing |
Клиент начал оплату (сканировал QR) |
paid |
Платёж подтверждён банком. USDT в pending_rub |
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 месяц",
"metadata": {"user_id": "usr_abc", "plan": "premium"}
}
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
amount_rub |
string | ✓ | Decimal, 2 знака. Минимум "1.00", максимум "1000000.00" |
external_id |
string | Твой ID заказа (для сопоставления). Максимум 256 символов | |
payment_method |
string | ✓ | Сейчас только "sbp" |
description |
string | До 256 символов, видит клиент | |
metadata |
object | Произвольный JSON, до 4KB. Возвращается в webhook и GET | |
client_phone |
string | * | Телефон клиента в формате +7XXXXXXXXXX (11 цифр с +7). Обязателен только если у вашего аккаунта включён KYC (узнаёте при онбординге). Если не включён — поле игнорируется. Если KYC включён и поле отсутствует — 400 VALIDATION_ERROR (точный текст сообщения может отличаться — ориентируйся на код и поле details). |
webhook_url |
string | Переопределяет глобальный webhook-URL из кабинета для этого платежа. В production — только публичный HTTPS (не приватные IP, не localhost, не HTTP), иначе 400 VALIDATION_ERROR. В sandbox дополнительно разрешены http:// и localhost. Если не передан — используется глобальный URL из кабинета |
Per-payment webhook_url (XHU-28)
По умолчанию все события платежа летят на глобальный 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: дополнительно разрешены
http://иlocalhost— для локальной разработки.
Ответ 201¶
{
"id": "pay_550e8400-e29b-41d4-a716-446655440000",
"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://paywidget.org/abc-def",
"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,
"refund_pending": false,
"failure_reason": null,
"sandbox": false,
"metadata": {"user_id": "usr_abc", "plan": "premium"}
}
amount_requested/amount_received— алиасыamount_rub/actual_amount_rub(запрошенная и фактически полученная сумма).refund_pending— по платежу запрошен возврат, но деньги ещё не вернулись. Поле присутствует всегда (falseу обычного платежа). Пока оноtrue, основнойstatusне меняется — платёж и правда всё ещё оплачен, а возврат может не состояться. Подробнее — возвраты.subscription_id— подписка, по которой сделано списание ("sub_..."). Поле присутствует всегда; у обычного платежа оноnull.period_ref— период, который это списание занимает в идемпотентности ("2026-08"/"2026-W32"/"2026-08-07"— формат зависит отintervalподписки). Поле присутствует всегда; у обычного платежа оноnull. Считать период самому не нужно — см. идемпотентность списания.failure_reason— почему платёж не прошёл. Поле присутствует всегда; у всех статусов, кромеfailed, оноnull. Набор значений и рекомендованное действие по каждому — причина отказа.sandbox—trueтолько в тестовом режиме.
Что делать: редирект клиента на payment_url.
Получить платёж — 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— сколько реально заплатил клиентamount_usdt— после settlement, сколько начислено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¶
Query параметры¶
| Параметр | Тип | По умолчанию |
|---|---|---|
page |
int | 1 |
per_page |
int | 20 (макс 100) |
status |
string | — (все) |
from |
ISO8601 | — |
to |
ISO8601 | — |
Пример:
Ответ¶
{
"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, по умолчанию 15 минут от создания). Повторить этот платёж нельзя — для новой попытки создавай новый.
expired — НЕ терминальный статус. Не закрывай заказ намертво
Часть банков подтверждает оплату уже после того, как мы истекли по таймауту. Такую позднюю оплату мы спасаем: перечитываем состояние заказа у платёжной системы и, если оплата подтвердилась, переводим платёж expired → paid — прилетает ещё один payment.status_changed.
Как это работает:
- Окно спасения — 6 часов от момента создания платежа. Позже мы уже не переспрашиваем.
- Спасение возможно не на всех рельсах — рассчитывать на него как на гарантию нельзя. Гарантировано другое:
expiredможет смениться, и твой обработчик обязан это пережить. - Действуем только на подтверждённую оплату. Отказ, таймаут и промежуточный статус платёж не трогают — честно истёкшие заказы так и остаются
expired. - Если поздняя оплата пришла на другую сумму, платёж уходит в
amount_mismatch(ручной разбор), а не вpaid.
Что это значит для интегратора. Если по expired ты необратимо закрываешь заказ (удаляешь корзину, отпускаешь товар, банишь повторную выдачу), то при поздней оплате клиент заплатит, а товара не получит — деньги у нас, обязательство у тебя.
Правильно: по expired помечай заказ как «не оплачен» обратимо и разреши клиенту новую попытку. Если позже придёт paid по тому же payment_id — выдай товар (или, если клиент уже оплатил повторно, оформи возврат через поддержку X-Hub). Разрешённые переходы из 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 |
Плательщик сам отменил платёж | Автоповторов не делать — это осознанный отказ. |
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" — тот случай, ради которого ветку повторного списания и стоит написать.
Обработка отмен, истечений и ошибок¶
Не каждый платёж заканчивается успехом — часть клиентов закроет окно, часть не успеет за 15 минут, часть получит отказ банка, а небольшая доля уже оплаченных платежей может «вернуться» с возвратом. Этот раздел — про то, как корректно обработать каждую ветку у себя.
Какие статусы означают неуспех¶
| Status | Что значит | Деньги списаны? | Что делать |
|---|---|---|---|
cancelled |
Платёж отменён до зачисления (клиент закрыл окно, отменил в банке) | Нет | Просто пометить как failed в своей системе. Дать клиенту создать новый платёж. |
expired |
TTL истёк, клиент не оплатил | Пока нет | Дать клиенту повторить — нужен новый платёж. Этот не терминальный: поздняя оплата может поднять его в paid в течение 6 часов от создания. Закрывай заказ обратимо — см. expired. |
failed |
Платёж не прошёл | Нет | Смотреть failure_reason — от него зависит действие: повторить через день, просить привязать заново или ничего не делать. См. причина отказа. |
refunded |
Платёж был успешен, потом провайдер вернул деньги клиенту | Да, но мы списали обратно с твоего баланса | Критично — пересчитать выдачу товара/услуги клиенту (отозвать доступ или выставить долг). |
amount_mismatch |
Фактическая сумма не совпала с ожиданием | Деньги на удержании | Дождаться разбора админом X-Hub — прилетит следующий webhook с новым статусом (paid или failed). |
refunded ≠ cancelled
cancelled приходит до успеха (деньги не двигались), refunded — после успеха (мы уже зачислили, потом отозвали). Если выдал товар на paid и пришёл refunded — у клиента остаётся товар, но денег ты уже не получишь. Это самый рискованный кейс.
Code example — multi-status handler¶
Полный обработчик, который покрывает все ветки:
Node.js / TypeScript¶
function handleXhubWebhook(event) {
if (event.event !== 'payment.status_changed') return;
switch (event.status) {
case 'paid':
case 'settled':
// успешная оплата — выдать товар/услугу
provisionService(event.payment_id, event.external_id);
break;
case 'cancelled':
case 'expired':
// клиент не оплатил — не выдавать, разрешить повтор.
// ВАЖНО: пометка должна быть ОБРАТИМОЙ. `expired` не терминален —
// поздняя оплата поднимет этот же payment_id в `paid` (окно 6 часов),
// и тогда сюда прилетит второй webhook с успехом.
markOrderAsAbandoned(event.external_id);
break;
case 'failed':
// Платёж не прошёл. ЧТО делать — зависит от причины, а не от статуса:
// на одинаковый `failed` бывает и «повтори через день, подписчик
// останется», и «повторы бессмысленны».
switch (event.failure_reason) {
case 'insufficient_funds':
// денег не хватило — подписку НЕ отменяем, пробуем позже
scheduleRetry(event.subscription_id, { inDays: 2 });
break;
case 'mandate_revoked':
// согласие отозвано — повторы не помогут, нужна новая привязка
askCustomerToRebind(event.subscription_id);
break;
case 'expired':
// «срок истёк» у платёжной системы — то же, что наш status:"expired"
markOrderAsAbandoned(event.external_id);
break;
case 'cancelled_by_customer':
// осознанный отказ — автоповторов не делаем
markOrderAsAbandoned(event.external_id);
break;
default:
// `declined_by_bank`, `unknown` и любой БУДУЩИЙ код: одна попытка,
// потом человек. Отменять подписку по незнакомому коду нельзя —
// почти всегда ситуация исправима.
notifyClientOfFailure(event.external_id, event.payment_id);
scheduleRetry(event.subscription_id, { inDays: 1, maxAttempts: 1 });
}
break;
case 'refunded':
// CRITICAL: возврат после успеха.
// Если товар уже выдан — отозвать доступ или выставить долг клиенту.
handleRefundAfterFulfillment(event.external_id, event.payment_id);
alertOps('Refund-after-paid: ' + event.payment_id);
break;
case 'amount_mismatch':
// ждать ручного разбора X-Hub — следующий webhook прилетит
// с финальным статусом (paid или failed)
pauseOrderUntilResolved(event.external_id);
break;
default:
// неизвестный статус — лог + 200 (чтобы не словить retry-storm)
logUnknownStatus(event);
}
}
Python¶
def handle_xhub_webhook(event):
if event['event'] != 'payment.status_changed':
return
status = event['status']
ext_id = event['external_id']
if status in ('paid', 'settled'):
provision_service(event['payment_id'], ext_id)
elif status in ('cancelled', 'expired'):
mark_order_as_abandoned(ext_id)
elif status == 'failed':
notify_client_of_failure(ext_id, event['payment_id'])
elif status == 'refunded':
# CRITICAL: возврат после успеха
handle_refund_after_fulfillment(ext_id, event['payment_id'])
alert_ops(f"Refund-after-paid: {event['payment_id']}")
elif status == 'amount_mismatch':
pause_order_until_resolved(ext_id)
else:
log_unknown_status(event)
PHP¶
function handleXhubWebhook(array $event): void {
if ($event['event'] !== 'payment.status_changed') return;
$status = $event['status'];
$extId = $event['external_id'];
if ($status === 'paid' || $status === 'settled') {
provisionService($event['payment_id'], $extId);
} elseif ($status === 'cancelled' || $status === 'expired') {
markOrderAsAbandoned($extId);
} elseif ($status === 'failed') {
notifyClientOfFailure($extId, $event['payment_id']);
} elseif ($status === 'refunded') {
// CRITICAL: возврат после успеха
handleRefundAfterFulfillment($extId, $event['payment_id']);
alertOps('Refund-after-paid: ' . $event['payment_id']);
} elseif ($status === 'amount_mismatch') {
pauseOrderUntilResolved($extId);
} else {
logUnknownStatus($event);
}
}
Best practices¶
- Идемпотентность через
X-Webhook-Id. Тот жеX-Webhook-Id— это retry от X-Hub (мы повторим, если твой endpoint не ответил 200 за 10 сек). ЗапишиX-Webhook-Idв свою БД и не выполняй action дважды для одного и того же id. Подробнее — раздел «Идемпотентность» в Webhooks ниже. - Idempotent action как таковой. Одно и то же событие (например
paidдля одногоpayment_id) может прийти несколько раз —provisionService()должна быть безопасна для повторного вызова (либо сама проверяет «уже выдали», либо UPSERT-операция). - Подпись обязательна. Не доверяй webhook'у безоговорочно — всегда проверяй
X-Webhook-Signature. См. Верификация подписи. -
Финальные статусы — их ровно три. Терминальны только
failed,cancelledиrefunded. Остальное может измениться:expired→paid(поздняя оплата, окно 6 часов от создания) или →amount_mismatch(поздняя оплата не на ту сумму).expiredтерминальным не является — см. expired;paid/settled→refunded(минуты-часы-дни-месяцы спустя);amount_mismatch→paidилиfailedпосле разбора.
Практическое правило: необратимые действия (списать товар со склада, окончательно закрыть заказ) вешай только на
failed/cancelled. Всё остальное должно уметь «переиграться». - Узнавай о сбоях шлюза. Подпишись наgateway.status_changed(см. ниже) — если у X-Hub проблемы, мы сами пришлём webhookstatus: down/degradedсreason: api|completion|both. Используй это для своей системы алертов / мониторинга — не нужно опрашивать/healthв цикле. - Не возвращай 200 ДО обработки. Если упал на DB-вставке — верни 5xx, X-Hub ретайнет. Если вернул 200 — для нас это значит «доставлено», ретая не будет.
Webhooks¶
X-Hub уведомляет твой бэкенд об изменениях платежей и выплат через HTTP POST на твой webhook_url.
Требования к endpoint¶
- HTTPS обязательно (HTTP не принимаем в проде)
- Отвечает HTTP 200 в пределах 10 секунд
- Ретраи — при
5xx,429и сетевых ошибках/таймаутах. Ответ4xx(кроме429) помечает обычное событие какFAILEDсразу, без ретраев; для денежных событий4xxне окончателен — см. Retry-стратегия.3xx-редиректы не следуются и терминальны всегда - Endpoint должен быть прямым URL: редирект (даже
301на тот же хост сwww) считается провалом доставки - Идемпотентная обработка (webhook может прийти дважды)
Формат payload¶
Payload — плоский объект (не вложенный). Все поля на верхнем уровне:
{
"event": "payment.status_changed",
"payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
"external_id": "order-12345",
"status": "paid",
"previous_status": "processing",
"amount_rub": "500.00",
"actual_amount_rub": "500.00",
"amount_usdt": null,
"payment_method": "sbp",
"timestamp": "2026-04-14T13:05:12Z",
"metadata": {"user_id": "usr_abc"}
}
Пример failed webhook (списание по подписке не прошло — на счёте не хватило денег):
{
"event": "payment.status_changed",
"payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
"external_id": "order-12345",
"status": "failed",
"previous_status": "pending",
"amount_rub": "299.00",
"actual_amount_rub": null,
"amount_usdt": null,
"payment_method": "recurring",
"subscription_id": "sub_9f1c...",
"period_ref": "2026-08",
"refund_pending": false,
"failure_reason": "insufficient_funds",
"timestamp": "2026-08-09T04:26:11Z",
"metadata": {"user_id": "usr_abc"}
}
Пример refunded webhook (банк отозвал платёж после settlement):
{
"event": "payment.status_changed",
"payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
"external_id": "order-12345",
"status": "refunded",
"previous_status": "settled",
"amount_rub": "500.00",
"actual_amount_rub": "500.00",
"amount_usdt": "6.20000000",
"payment_method": "sbp",
"timestamp": "2026-05-13T16:12:06Z",
"metadata": {"user_id": "usr_abc"}
}
События¶
| event | Когда |
|---|---|
payment.status_changed |
Любое изменение статуса платежа (в т.ч. списаний по подписке, payment_method: "recurring") |
withdrawal.status_changed |
Любое изменение статуса выплаты |
exchange.status_changed |
Любое изменение статуса обмена (RUB↔USDT) |
subscription.status_changed |
Изменение статуса подписки: подтверждение мандата, пауза, отмена, а также готовность ссылки привязки (см. раздел про подписки ниже) |
refund.completed |
Возврат по платежу исполнен — деньги ушли клиенту |
refund.rejected |
Заявка на возврат отклонена — деньги не двигались |
kyc.status_changed |
Изменение статуса KYC-верификации клиента |
gateway.status_changed |
Изменение статуса платёжного шлюза X-Hub: operational / degraded / down |
Смотри payment.status / withdrawal.status / exchange.status чтобы понять текущее состояние. X-Webhook-Timestamp — ISO-8601 в UTC.
refund.completed / refund.rejected — заявочные возвраты¶
Возврат по уже рассчитанному (settled) платежу инициируется через поддержку X-Hub — публичного API для этого нет (см. FAQ). О судьбе заявки ты узнаёшь этими двумя событиями. Payload одинаковый:
{
"event": "refund.completed",
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"refund_id": "9c8d4f16-8a7b-b2e6-f0c9-d1235b3fa1e2",
"amount_rub": "500.00",
"status": "COMPLETED"
}
| Поле | Что значит |
|---|---|
payment_id |
Платёж, по которому делался возврат |
refund_id |
Идентификатор заявки на возврат. На один платёж заявок может быть несколько (частичные возвраты) — уведомления по ним независимы |
amount_rub |
Сумма именно этой заявки, а не всего платежа. Частичный возврат — обычное дело |
status |
COMPLETED у refund.completed, REJECTED у refund.rejected |
Отличия от остального API — читай внимательно
payment_idздесь БЕЗ префиксаpay_— это «голый» UUID, в отличие отpayment.status_changedи REST-ответов. Сопоставляя с платежом, отрежь префикс на своей стороне. То же и сrefund_id.payment.status_changedпо заявочному возврату НЕ приходит вообще — ни при частичном, ни при полном. Даже когда сумма возвратов покрывает платёж целиком и он уходит вrefunded, единственное уведомление об этом —refund.completed. Не жди смены статуса платежа: считай возвраты по этим двум событиям.statusприходит в ВЕРХНЕМ регистре (COMPLETED/REJECTED), а не в нижнем, какstatusплатежа.- Причина отказа в
refund.rejectedне передаётся — спрашивай у команды X-Hub.
refund.completed — денежное событие (медленная лестница ретраев), refund.rejected — нет.
Это не то же самое, что payment.status_changed со status: refunded: тот приходит, когда деньги отозвал банк-эквайер сам, без заявки с твоей стороны (см. Возврат после успешной оплаты).
gateway.status_changed — статус платёжного шлюза¶
Когда платёжный шлюз X-Hub деградирует или восстанавливается, мы шлём отдельный webhook. Это позволяет тебе подключить наш статус к своему мониторингу/алертам без необходимости опрашивать /health endpoint в цикле.
{
"event": "gateway.status_changed",
"status": "down",
"error_rate_1h": 0,
"completion_rate_1h": 0,
"reason": "completion",
"since": "2026-05-15T04:00:00.000Z",
"timestamp": "2026-05-15T04:00:32.144Z"
}
Поля:
status— текущий статус шлюза:operational— всё работает штатноdegraded— повышенный процент ошибок или сниженная доля успешных оплатdown— шлюз временно недоступен / клиенты не могут завершить оплаты
error_rate_1h— процент API-ошибок за последний час (0-100)completion_rate_1h— процент успешных оплат за последний часPAID / (PAID+CANCELLED+EXPIRED+FAILED), 0-100. Может бытьnull— это значит за час было слишком мало финализированных платежей (< 5), чтобы посчитать значимую метрикуreason— что вызвало смену статуса:api— повышенный процент ошибок на нашей API-стороне (создание платежей)completion— клиенты сканируют QR, но платежи не доходят (низкая конверсия)both— оба сигнала одновременно
since— ISO-8601 timestamp когда текущий статус впервые установился
Webhook отправляется только при переходе через границу operational ↔ degraded/down. Промежуточные смены degraded ↔ down не дублируются. После отправки webhook'а на пару (мерчант, статус) применяется cooldown 15 минут — защита от flapping.
Что означают комбинации
reason=api+ высокийerror_rate_1h— наш бэкенд возвращает ошибки при попытке создать платёж.reason=completion+ низкийcompletion_rate_1h— создание работает, QR генерируется, но клиенты массово не доводят оплату до конца (банк/SBP таймаут, платёжный backend не подтверждает).reason=both— оба сигнала: вероятен полный коллапс шлюза.
Используй эту информацию для своего мониторинга — мы сами устраняем причину со своей стороны и пришлём operational когда восстановим.
Верификация подписи¶
В каждом webhook есть три заголовка:
X-Webhook-Id: 5b3fa1e2-9c8d-4f16-8a7b-b2e6f0c9d123
X-Webhook-Signature: sha256=a1b2c3d4...
X-Webhook-Timestamp: 2026-04-14T13:05:12.000Z
X-Webhook-Id— уникальный UUID доставки. Используй его для idempotent-обработки на своей стороне (если получил webhook с тем жеX-Webhook-Idповторно — это ретрай, не новое событие)X-Webhook-Signature/X-Webhook-Timestamp— используются для проверки подписи
Подпись вычисляется как:
Где timestamp — значение из заголовка X-Webhook-Timestamp, raw_body — сырое тело запроса (до парсинга JSON).
Важно
- Используй оба заголовка:
X-Webhook-TimestampиX-Webhook-Signature - Проверяй на сыром body до парсинга JSON, иначе подпись не сойдётся
- Данные для подписи:
timestamp + "." + raw_body(конкатенация через точку) - Если не совпало — отказывай 401, не обрабатывай
- Используй
crypto.timingSafeEqual(или аналог) — защита от timing attack - Защита от replay: отклоняй webhook'и с
X-Webhook-Timestampстарше 5 минут от текущего времени — даже если подпись валидна. Злоумышленник с перехваченным webhook не сможет переиграть его позже.
Node.js (Express)¶
import crypto from 'crypto';
import express from 'express';
const app = express();
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// Данные для подписи: timestamp + "." + raw_body
const signatureData = timestamp + '.' + req.body.toString('utf-8');
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.XHUB_WEBHOOK_SECRET)
.update(signatureData)
.digest('hex');
// timingSafeEqual бросает RangeError, если длины буферов различаются,
// — поэтому сначала проверяем наличие и длину, и только потом сравниваем.
if (
!signature ||
signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf-8'));
if (event.event === 'payment.status_changed' && event.status === 'paid') {
await grantAccessTo(event.external_id);
}
res.status(200).send('OK');
}
);
Обработчик обязан быть async
Внутри есть await grantAccessTo(...). Без async перед (req, res) файл не распарсится — SyntaxError на старте, а не в рантайме.
Python (Flask)¶
import os, hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
raw = request.get_data()
sig = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
# Данные для подписи: timestamp + "." + raw_body
signature_data = timestamp.encode() + b'.' + raw
expected = 'sha256=' + hmac.new(
os.environ['XHUB_WEBHOOK_SECRET'].encode(),
signature_data,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401, 'Invalid signature')
event = request.get_json()
if event['event'] == 'payment.status_changed' and event['status'] == 'paid':
grant_access_to(event['external_id'])
return 'OK', 200
PHP¶
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
// Данные для подписи: timestamp + "." + raw_body
$signatureData = $timestamp . '.' . $raw;
$expected = 'sha256=' . hash_hmac('sha256', $signatureData, getenv('XHUB_WEBHOOK_SECRET'));
if (!hash_equals($sig, $expected)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($raw, true);
if ($event['event'] === 'payment.status_changed' && $event['status'] === 'paid') {
grantAccessTo($event['external_id']);
}
http_response_code(200);
echo 'OK';
Retry-стратегия¶
Лестниц ретрая две, и они независимы. Какая сработает — зависит от того, что ответил твой endpoint и денежное ли это событие.
1. Быстрая лестница — сбои доставки (5xx, 429, сеть, таймаут)¶
| Попытка | Задержка после предыдущей |
|---|---|
| 1 | сразу |
| 2 | +1 сек |
| 3 | +2 сек |
| 4 | +4 сек |
| 5 | +8 сек |
| 6 | +16 сек |
| 7 | +32 сек |
| 8 | +64 сек (~1 мин) |
| 9 | +128 сек (~2 мин) |
| 10 | +256 сек (~4 мин) |
Формула: delay = 2^(attempt-1) секунд. Бюджет — 10 попыток; суммарные паузы дают ~8.5 минут. Потом доставка помечается FAILED и требует ручного разбора.
«~8.5 минут» — это сумма пауз, а не гарантированный срок
Считаются только попытки доставки. Паузы, которые ставит предохранитель, счётчик попыток не увеличивают — при лежащем endpoint'е те же 10 попыток растягиваются на часы и сутки. Не строй логику на «если за 10 минут вебхука не было — его уже не будет»: он может прийти сильно позже. Для проверки состояния используй GET /payments/:id.
2. Медленная лестница — денежное событие + 4xx¶
Код ответа описывает состояние твоей интеграции, а не нужность уведомления. Поэтому для событий о фактическом движении денег 4xx (кроме 429) больше не окончателен — такая доставка уходит на отдельный редкий график:
| Шаг | Пауза до следующей попытки |
|---|---|
| 1 | 15 минут |
| 2 | 1 час |
| 3 | 4 часа |
| 4 | 12 часов |
| 5 | 24 часа |
Итого окно ≈ 41 час, жёсткий потолок по возрасту доставки — 48 часов. После этого — FAILED и адресный алерт нашей дежурной смене (мерчант, платёж, сумма).
Денежными считаются:
payment.status_changedсоstatus:paid,settled,refunded;withdrawal.status_changedсоstatus: completed;exchange.status_changedсоstatus: completed;refund.completed.
Всё остальное (в т.ч. amount_mismatch, expired, cancelled, failed, refund.rejected, subscription.status_changed, kyc.status_changed, gateway.status_changed) — неденежное: 4xx → FAILED сразу, без ретраев.
Бюджеты двух лестниц раздельны: медленные шаги не расходуют 10 быстрых попыток, и наоборот.
На временных проблемах отвечай 5xx, не 4xx
5xx — «попробуй ещё раз», и ты получишь полную быструю лестницу. 4xx для неденежного события — «не присылай это больше», и ретраев не будет. Если у тебя временный сбой (упала БД, идёт деплой) — верни 5xx.
Предохранитель (circuit breaker)¶
Чтобы не долбить лежащий endpoint, доставка всему мерчанту временно ставится на паузу:
| Переход | Условие |
|---|---|
| работает → пауза | ≥ 5 доставок за скользящее окно 5 минут и ≥ 80 % из них провалились |
| пауза → пробная доставка | прошло время паузы: сначала 5 минут, дальше — удвоение при каждой неудачной пробе, потолок 30 минут |
| пробная доставка → работает | пробная доставка прошла успешно (счётчики сбрасываются) |
| пробная доставка → пауза | проба провалилась, пауза удваивается |
Что важно знать интегратору:
- Пауза не тратит попытки. Отложенная предохранителем доставка не увеличивает счётчик
attempts— именно поэтому реальное окно жизни уведомления измеряется не минутами, а часами и сутками. - Предохранитель общий на мерчанта, а не на событие. Пока он открыт, придерживаются все твои уведомления, включая свежие. Как только пробная доставка проходит — очередь разбирается целиком.
- Пока предохранитель открыт, пробуется одна доставка за цикл. Остальные ждут ~30 секунд и пробуют снова.
4xxпредохранитель не открывают. Он реагирует на недоступность endpoint'а (сеть, таймаут,5xx), а не на ответ «плохой запрос».- Потолок — 30 суток. Доставка, чей endpoint всё это время был недоступен, помечается
FAILEDокончательно. - Восстановился раньше и не хочешь ждать паузу — напиши команде X-Hub, предохранитель сбрасывается вручную.
Идемпотентность¶
Webhook может прийти дважды (retry после таймаута). Твой обработчик должен быть идемпотентным.
async function handleWebhook(event) {
const paymentId = event.payment_id;
const alreadyProcessed = await db.webhookLog.findUnique({
where: {
paymentId_status: { paymentId, status: event.status }
}
});
if (alreadyProcessed) return;
await db.webhookLog.create({
data: { paymentId, status: event.status }
});
await grantAccess(event.external_id);
}
Чек-лист¶
- [ ] Endpoint отвечает за < 10 секунд
- [ ] HTTPS с валидным сертификатом
- [ ] Проверка
X-Webhook-Signatureс использованиемX-Webhook-Timestamp + "." + raw_body - [ ] Идемпотентная обработка по
X-Webhook-Id(илиpayment_id + status) - [ ] Отвечаешь 200 после успешной обработки (не до)
- [ ] Логируешь все приходящие webhooks для отладки
- [ ] Обработчик не падает на неизвестных
eventтипах
Баланс и курсы¶
GET /balance¶
Текущий баланс мерчанта.
Ответ¶
{
"available_usdt": "1245.32100000",
"reserved_usdt": "0.00000000",
"pending_rub": "50000.00",
"total_settled_usdt": "15678.90000000",
"total_withdrawn_usdt": "14433.58000000",
"updated_at": "2026-04-14T12:00:00Z"
}
| Поле | Что значит |
|---|---|
available_usdt |
Доступно к выплате прямо сейчас |
reserved_usdt |
Зарезервировано под выплату в обработке |
pending_rub |
Уже принятые рубли, ждут settlement (T+1) |
total_settled_usdt |
Всего получено USDT за всё время |
total_withdrawn_usdt |
Всего выведено за всё время |
Settlement T+1¶
Что такое T+1: платежи, прошедшие до 12:00 UTC сегодня (T), конвертируются в USDT завтра (T+1) в 12:00 UTC по фиксированному курсу на момент settlement.
Зачем:
- Фиксируем курс на весь батч — нет спекуляций на колебаниях
- Успеваем проверить все callback'и от банка
- Есть буфер на возвраты/диспуты
Пример:
9 апр 10:00 — платёж 1000₽ принят (pending_rub += 1000)
10 апр 12:00 — settlement: 1000₽ / 83.3595 = 11.996 USDT → available_usdt
10 апр 12:01 — средства на балансе, попадут в ближайшую автоматическую выплату
GET /rates¶
Informational курс RUB/USDT. Не settlement — только для справки/калькулятора.
Ответ¶
{
"pair": "RUB/USDT",
"rate_merchant": "83.55",
"updated_at": "2026-04-14T12:00:00Z",
"note": "Informational rate. Final rate is fixed at T+1 settlement."
}
| Поле | Описание |
|---|---|
pair |
Всегда "RUB/USDT" |
rate_merchant |
Итоговый курс для мерчанта (RUB за 1 USDT), округлён до 2 знаков. Включает все комиссии/наценку X-Hub — используй его в калькуляторе. Рыночный курс и размер наценки отдельными полями не отдаются |
updated_at |
ISO-8601 UTC. Время последнего обновления курса |
note |
Информационное сообщение |
Используй rate_merchant в калькуляторе
Клиент платит 1000₽ → тебе начислится 1000 / rate_merchant USDT (минус флуктуации до settlement).
Пример¶
Клиент платит 1000₽, R = 79.37 (рыночный курс на T+1), markup = 5%:
merchant_rate = 79.37 / (1 - 0.05) = 83.5474 → в API придёт "83.55"
merchant_usdt = 1000 / 83.5474 = 11.96925338 USDT <-- тебе
xhub_fee = (1000 / 79.37) - 11.96925338 ≈ 0.63 USDT (все комиссии X-Hub)
Не считай начисление по rate_merchant до копейки
В ответе курс округлён до 2 знаков, а фактическое начисление считается по полной точности и фиксируется на T+1 settlement. GET /rates — для калькулятора и витрины, не для сверки.
Выплаты (автоматические)¶
Выплаты USDT происходят автоматически — ежедневным расчётом (settlement). После подтверждения платежей средства зачисляются на твой баланс и выплачиваются без каких-либо запросов с твоей стороны.
- Баланс виден в
GET /api/v1/balanceи в кабинете. - При выплате может прийти webhook
withdrawal.status_changed(payload ниже).
Ручные запросы выплат больше не поддерживаются
Эндпоинты POST /api/v1/withdrawals, GET /api/v1/withdrawals и GET /api/v1/withdrawals/:id выведены из эксплуатации и возвращают 410 с кодом WITHDRAWAL_DISCONTINUED. Создавать запросы на вывод не нужно — выплаты выполняются автоматически.
Webhook выплаты¶
При смене статуса выплаты — событие withdrawal.status_changed:
{
"event": "withdrawal.status_changed",
"withdrawal_id": "wd_abc123-...",
"status": "completed",
"previous_status": "processing",
"amount_usdt": "500.00000000",
"wallet_address": "TYour...",
"tx_hash": "c1a2b3...",
"timestamp": "2026-04-14T13:05:00Z"
}
Верификация подписи — как для платежей.
Подписки / Рекуррентные платежи¶
Подписки позволяют списывать с клиента деньги повторно по СБП-мандату — клиент один раз подтверждает согласие (мандат) в приложении банка, после чего ты инициируешь списания через API, не прося клиента платить каждый раз заново.
Опция включается на аккаунте
Рекуррентные платежи доступны только если у твоего аккаунта включена соответствующая опция. Если рекуррентные платежи глобально недоступны — вся ветка /subscriptions отвечает 404 NOT_FOUND, как будто эндпоинтов не существует. Если функция доступна, но списывать по мандату некому — POST-эндпоинты вернут 501 RECURRING_NOT_SUPPORTED, а GET (список / получение) работают и возвращают пустые списки. Хочешь подключить — напиши команде X-Hub.
501 RECURRING_NOT_SUPPORTED в песочнице ≠ «опция не включена твоему аккаунту»
У этого ответа две разные причины, и по коду ошибки они неразличимы:
- В боевом режиме — рекуррентная рельса твоему аккаунту действительно не назначена. Лечится обращением в X-Hub.
- В песочнице — песочная рекуррентная рельса не поднята на инстансе. Твой аккаунт тут ни при чём: песочница всегда ходит на встроенный симулятор, и если он выключен рубильником (это отдельный от самой фичи флаг), симулятор не умеет списаний по мандату →
501.
Различить можно по режиму ключа: если xh_test_... отдаёт 501, а сама ветка /subscriptions при этом жива (GET /subscriptions возвращает 200 с пустым списком, а не 404) — это причина №2, и обращение «включите мне опцию» уйдёт не по адресу. Напиши, что песочные подписки не поднимаются, — формулировка сэкономит круг переписки.
Ещё один ответ из той же серии: 503 PROVIDER_NOT_CONFIGURED — подписка (или мандат) ссылается на рельсу, которой на инстансе сейчас нет. Живой сценарий: подписки завели в песочнице, пока симулятор был включён, потом его выключили — сами подписки остались, а списывать по ним стало нечем.
Base URL тот же: https://api.x-hub.online/api/v1. Аутентификация — те же заголовки X-Api-Key / X-Api-Secret, что и во всём остальном API. Idempotency-Key (UUID) обязателен для POST /subscriptions и POST /subscriptions/:id/charges; lifecycle-эндпоинты (/pause, /resume, /cancel) его не требуют.
Ключевые термины¶
| Термин | Что это |
|---|---|
| Subscription (подписка) | Мандат на повторные списания. Создаётся тобой, подтверждается клиентом-плательщиком |
| Binding (привязка мандата) | Шаг подтверждения: клиент переходит по binding_url и разрешает списания в приложении банка. В этот же момент списывается первый период |
| Charge (списание) | Одно списание по активному мандату. Технически — это обычный платёж (Payment) с payment_method: "recurring" |
| Списание при привязке | Списание первого периода, которое инициирует платёжная система в момент подтверждения мандата — не ты. Приходит с external_id: null |
| Cap (потолок) | Лимит суммы: cap_amount_rub — на одно списание, cap_window_rub — суммарно за интервал |
| Period (период) | Календарный интервал списания (день / неделя / месяц). В одном периоде — одно списание (идемпотентность) |
Как это работает (общий поток)¶
- Ты создаёшь подписку:
POST /v1/subscriptions→ получаешьid,status: pending_bindingиbinding_url. Изредка ссылка ещё не готова в момент ответа — тогдаbinding_url: null,binding_url_pending: true, и ссылка приходит следом webhook'ом (см. Ссылка привязки готовится). - Отправляешь клиента на
binding_url— там он подтверждает мандат в приложении банка. - В момент подтверждения мандата с клиента сразу списывается первый период — на сумму
amount_rubподписки. Это списание инициирует платёжная система, не ты. Оно приходит тебе обычнымpayment.status_changedсpayment_method: "recurring"и видно вGET /v1/subscriptions/:id/charges. - Подписка переходит в
active— прилетаетsubscription.status_changed(или узнаёшь опросомGET /v1/subscriptions/:id). - По активной подписке ты инициируешь последующие списания:
POST /v1/subscriptions/:id/charges(режим A — списание по твоему триггеру). - Каждое списание — это платёж; его финальный статус приходит webhook'ом
payment.status_changed(как у обычных платежей).
Не списывай сам сразу после привязки — клиент заплатит дважды
Шаг 3 — не опечатка. Подтверждение мандата = немедленный дебет первого периода. Если ты, увидев active, тут же дёрнешь POST /:id/charges за тот же период, ты рискуешь списать с клиента второй раз.
Защита на нашей стороне есть — одно списание на период: твой POST /charges за уже занятый период вернёт 200 и тот же самый платёж (тот, что сделала платёжная система при привязке), а не создаст новый. Но полагаться только на неё нельзя: она работает по календарному периоду, поэтому привязка 31-го числа и твоё списание 1-го числа попадут в разные месяцы — и оба спишутся.
Правильный порядок:
- Создал подписку → отправил клиента на
binding_url. - Дождался
subscription.status_changed→active. - Проверил
GET /v1/subscriptions/:id/charges. Если там уже есть списание — первый период оплачен, своё списание не делай. - Своё первое
POST /chargesпланируй на следующий период.
Отличить списание при привязке от своего можно по external_id: у списаний, инициированных платёжной системой, он всегда null (см. Как отличить списание при привязке от своего).
Жизненный цикл подписки¶
stateDiagram-v2
[*] --> pending_binding
pending_binding --> active: плательщик подтвердил мандат
pending_binding --> cancelled: мандат отозван на стороне банка/плательщика<br/>или ссылка привязки так и не выпущена
active --> paused: POST /pause
paused --> active: POST /resume
active --> cancelled: POST /cancel (terminal)
paused --> cancelled: POST /cancel (terminal)
| Статус | Значение |
|---|---|
pending_binding |
Подписка создана, ждём подтверждения мандата клиентом. Твои списания через POST /:id/charges невозможны (409 CHARGE_MANDATE_INACTIVE). Но списание при привязке приходит именно в этом статусе — см. предупреждение ниже |
active |
Мандат подтверждён — можно списывать через POST /:id/charges |
paused |
Мерчант приостановил. Твои списания заблокированы (charge → 409). Возобновляется через /resume |
cancelled |
Терминальный. Мандат отменён мерчантом (/cancel), отозван на стороне банка/плательщика или привязка так и не состоялась — ссылка не была выпущена (см. binding_url_pending). Твои списания невозможны |
expired |
Терминальный. Зарезервированный статус — в текущей версии API подписка автоматически в expired не переводится |
Набор значений status закрыт этими пятью — новых мы не добавляем, поэтому жёсткий разбор статуса у тебя не сломается.
past_due — просрочка по мандату (отдельное поле, не статус)
Если банк не смог списать по мандату, он переходит в взыскание. Мандат при этом остаётся живым — повторное списание может пройти, — поэтому status честно остаётся active, а факт просрочки приходит отдельным булевым полем past_due:
Поле присутствует всегда (у нормальной подписки false) — и в объекте подписки, и в webhook'е subscription.status_changed.
Что с этим делать. past_due: true — сигнал, что клиент фактически не платит, хотя мандат формально активен. Типовая реакция: перевести аккаунт в grace-период, показать клиенту просьбу обновить платёжный способ, а через N дней ограничить сервис. Не полагайся на один только status — по нему такой мандат неотличим от исправного.
Смена past_due присылает subscription.status_changed с тем же status, что и раньше: в таком событии previous_status равен status — это и есть маркер «сменился под-статус, а не статус». Сравнивай past_due, а не только status.
rebind_required — мандат утрачен, нужна новая привязка
Если банк сообщает, что привязка по мандату не найдена (утрачена безвозвратно), повторные списания по этой подписке не пройдут — нужна новая привязка. В отличие от past_due (мандат жив, ретрай уместен), здесь status переходит в paused, а факт приходит отдельным булевым полем rebind_required:
Поле присутствует только со значением true (симметрично past_due, но противоположно по смыслу) — и в объекте подписки (GET /subscriptions/:id), и в webhook'е subscription.status_changed. Отличай от обычной паузы (paused без rebind_required) и от past_due (мандат ещё жив).
Что с этим делать. Не ретрай списание по этой подписке — оно не пройдёт. Предложи клиенту оформить подписку заново (новая привязка мандата); старую можно отменить.
pending_binding не означает «денег не двигали»
Списание первого периода происходит в момент подтверждения мандата и гонится с вебхуком активации подписки. Порядок не гарантирован: платёж-списание может прийти к тебе раньше, чем subscription.status_changed → active, то есть в тот момент, когда GET /subscriptions/:id ещё отдаёт pending_binding.
Поэтому не отбрасывай payment.status_changed с payment_method: "recurring" только на основании того, что подписка ещё не active. Деньги у плательщика уже списаны.
То же касается терминальных статусов: если отзыв мандата не сработал на стороне банка, списание может прийти по cancelled/paused подписке. Такие случаи мы ловим и разбираем вручную — но если увидел такое у себя, напиши нам.
Идентификация режима
Как и в остальном API, в тестовом режиме ответы подписок содержат "sandbox": true; в боевом — поле отсутствует.
Создать подписку — POST /subscriptions¶
Создаёт подписку и инициирует привязку мандата. Возвращает binding_url, на который надо отправить клиента.
curl -X POST https://api.x-hub.online/api/v1/subscriptions \
-H "X-Api-Key: xh_live_..." \
-H "X-Api-Secret: YOUR_API_SECRET" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_rub": "500.00",
"interval": "month",
"interval_count": 1,
"cap_amount_rub": "600.00",
"cap_window_rub": "600.00",
"external_id": "sub-order-12345",
"client_ref": "user_abc",
"client_phone": "+79991234567"
}'
Body¶
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
amount_rub |
string | ✓ | Плановая сумма списания. Decimal, до 2 знаков ("500.00") |
interval |
string | ✓ | Период списания: "day", "week" или "month" |
interval_count |
int | Множитель периода, 1–12. По умолчанию 1. Внимание: в текущей версии API он сохраняется и возвращается, но на расчёт периода не влияет — период всегда календарный (interval), см. идемпотентность списания |
|
cap_amount_rub |
string | ✓ | Потолок на одно списание. Списание с суммой выше → 409 CAP_EXCEEDED |
cap_window_rub |
string | Потолок суммарно за период. Если сумма списаний в периоде превысит — 409 CAP_EXCEEDED. Не передан — оконного лимита нет |
|
external_id |
string | Твой ID подписки (для сопоставления). До 256 символов | |
client_ref |
string | Твой идентификатор клиента-плательщика. До 256 символов. Не возвращается в ответах (§ приватность) | |
client_phone |
string | Телефон клиента, до 32 символов. Хранится только маской, полный номер наружу не отдаётся |
Ответ 201¶
{
"id": "sub_550e8400-e29b-41d4-a716-446655440000",
"status": "pending_binding",
"amount_rub": "500.00",
"interval": "month",
"interval_count": 1,
"cap_amount_rub": "600.00",
"cap_window_rub": "600.00",
"external_id": "sub-order-12345",
"binding_url": "https://sub.nspk.ru/abc-def-ghi",
"binding_url_pending": false,
"current_period_start": null,
"current_period_end": null,
"past_due": false
}
Что дальше: отправь клиента на binding_url. current_period_start и current_period_end заполняются в момент активации мандата.
cap_window_rub в ответе
Если ты не передавал cap_window_rub, поле в ответе отсутствует целиком (а не приходит null). Не полагайся на его наличие.
Идемпотентность
Повтор POST /subscriptions с тем же Idempotency-Key вернёт ту же подписку (HTTP 200, без создания второго мандата).
Ссылка привязки может готовиться — binding_url_pending¶
Ссылку привязки выпускает не X-Hub, а платёжная система, и делает она это асинхронно. Обычно ссылка готова к моменту ответа, но иногда — нет. Раньше мы в такой ситуации отвечали ошибкой; теперь подписка создаётся всегда, а ссылка догоняет отдельным событием.
Признак — булево поле binding_url_pending. Оно присутствует всегда (у обычной подписки false) — и в ответе POST /subscriptions, и в GET /subscriptions/:id, и в webhook'е subscription.status_changed.
{
"id": "sub_550e8400-...",
"status": "pending_binding",
"binding_url": null,
"binding_url_pending": true
}
Что делать. Ничего не переспрашивай и, главное, не создавай подписку заново — мандат уже существует, повторное создание даст второй мандат на того же плательщика. Дождись webhook'а subscription.status_changed: он придёт с тем же status: "pending_binding" (то есть previous_status будет равен status — та же идиома, что у past_due), но уже с заполненным binding_url и binding_url_pending: false. Опрос GET /subscriptions/:id работает как всегда — как fallback, если webhook'и у тебя ещё не настроены.
Рекомендованный порядок:
POST /subscriptions→201.binding_urlне пустой → отправляй клиента, как обычно (это подавляющее большинство случаев).binding_urlпустой иbinding_url_pending: true→ покажи клиенту «готовим ссылку» и жди события со ссылкой; типовое ожидание — секунды.- Пришло событие со ссылкой → отправляй клиента на неё. Дальше поток обычный.
Если ссылка так и не появится
Мы ждём ссылку до часа. Если платёжная система её так и не выпустит (или сама закроет привязку), подписка переводится в терминальный cancelled — придёт subscription.status_changed с previous_status: "pending_binding". Списаний по такой подписке не было и не будет; чтобы повторить попытку, создай новую подписку (с новым Idempotency-Key).
Как отличить «готовится» от «что-то не так»
binding_url: null возможен по двум причинам, и binding_url_pending их разводит:
binding_url |
binding_url_pending |
Что это значит | Что делать |
|---|---|---|---|
| ссылка | false |
норма | отправить клиента |
null |
true |
ссылка ещё выпускается | ждать событие |
null |
false |
нештатное: ссылка пришла на неизвестном хосте и подавлена (fail-closed) | повторить запрос или написать в X-Hub |
Совместимость
Поле только добавлено — существующие интеграции, читающие binding_url, продолжают работать. Если твой код падает на пустом binding_url (например, сразу подставляет его в редирект), добавь проверку binding_url_pending — теперь такой ответ штатно возможен.
Получить подписку — GET /subscriptions/:id¶
curl https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-... \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
Ответ — тот же формат, что при создании, с актуальным status.
Поля ответа¶
| Поле | Тип | Что означает |
|---|---|---|
id |
string | ID подписки в формате sub_<uuid> |
status |
string | Текущий статус (см. жизненный цикл) |
past_due |
bool | Мандат в просрочке у банка. Присутствует всегда; status при этом остаётся active — см. про past_due |
rebind_required |
bool | Мандат утрачен: нужна новая привязка, списания больше не пройдут. Присутствует только при true; status при этом paused — см. про rebind_required |
amount_rub |
string | Плановая сумма списания за период |
interval |
string | Календарный интервал: day / week / month |
interval_count |
int | Множитель, который ты передал при создании. На расчёт периода не влияет (см. выше) |
cap_amount_rub |
string | Потолок на одно списание |
cap_window_rub |
string | Оконный потолок. Отсутствует, если не задан |
external_id |
string / null | Твой ID подписки |
binding_url |
string / null | Ссылка на подтверждение мандата. Может быть null (см. выше) |
binding_url_pending |
bool | Ссылка ещё выпускается платёжной системой. Присутствует всегда; при true жди subscription.status_changed со ссылкой и не создавай подписку заново — см. binding_url_pending |
current_period_start |
string / null | ISO-8601. Начало текущего периода мандата. До активации null |
current_period_end |
string / null | ISO-8601. Конец текущего периода мандата — до этого момента мандат оплачен. До активации null; у мандатов, созданных до 08.08.2026, может оставаться null, пока платёжная система не пришлёт очередное обновление |
sandbox |
bool | Только в тестовом режиме, значение true. В боевом поле отсутствует |
Чего в объекте подписки НЕТ
В текущей версии API подписка не отдаёт:
- дату следующего списания — в режиме A следующее списание инициируешь ты сам, поэтому расписание ведёшь у себя;
- дату последнего списания — бери её из
GET /subscriptions/:id/charges(список отсортирован по убываниюcreated_at, первый элемент — самое свежее списание); client_refи полныйclient_phone— они принимаются, но наружу не возвращаются (приватность плательщика).
Период считать вручную не нужно: ключ периода каждого списания приходит готовым в поле period_ref объекта платежа и webhook'а payment.status_changed — см. идемпотентность списания.
Ошибка 404 SUBSCRIPTION_NOT_FOUND — если подписка не принадлежит твоему мерчанту или относится к другому режиму (test/live). Чужая подписка неотличима от несуществующей (никогда не 403).
Список подписок — GET /subscriptions¶
Query параметры¶
| Параметр | Тип | По умолчанию |
|---|---|---|
page |
int | 1 |
per_page |
int | 20 (макс 100) |
status |
string | — (все). Один из pending_binding / active / paused / cancelled / expired |
Ответ¶
{
"data": [
{ "id": "sub_...", "status": "active", "amount_rub": "500.00", "interval": "month" }
],
"pagination": {
"page": 1,
"per_page": 20,
"total": 12,
"total_pages": 1
}
}
Изменить подписку — PATCH /subscriptions/:id¶
Меняет только суммы/потолки. Прорации нет: новая amount_rub действует со следующего списания, уже созданные списания не пересчитываются. interval, interval_count, external_id — не меняются.
curl -X PATCH https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-... \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Content-Type: application/json" \
-d '{ "amount_rub": "550.00", "cap_amount_rub": "700.00" }'
Body (изменяемые поля)¶
| Поле | Тип | Описание |
|---|---|---|
amount_rub |
string | Новая плановая сумма списания |
cap_amount_rub |
string | Новый потолок на одно списание |
cap_window_rub |
string / null | Новый оконный потолок. Передай null, чтобы снять оконный лимит |
Нужно передать хотя бы одно из трёх полей, иначе 400 VALIDATION_ERROR. Ответ — обновлённый объект подписки.
Список списаний — GET /subscriptions/:id/charges¶
Возвращает историю списаний (это платежи с payment_method: "recurring") в формате платежей. Сюда попадают и твои списания через POST /:id/charges, и списание, которое платёжная система сделала сама при подтверждении мандата. Сортировка — по убыванию created_at (первый элемент = самое свежее списание).
curl "https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../charges?page=1&per_page=20" \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
Query параметры¶
| Параметр | Тип | По умолчанию |
|---|---|---|
page |
int | 1 |
per_page |
int | 20 (макс 100) |
Ответ¶
{
"data": [
{
"id": "pay_...",
"status": "paid",
"amount_rub": "500.00",
"payment_method": "recurring",
"external_id": "sub-charge-2026-07",
"subscription_id": "sub_550e8400-...",
"period_ref": "2026-07",
"created_at": "2026-07-18T10:00:00Z"
}
],
"pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 }
}
Формат каждого элемента — точно такой же, как у обычного платежа (GET /payments/:id), см. Платежи. Отличие только в payment_method: "recurring".
Как отличить списание при привязке от своего¶
По полю external_id:
external_id |
Кто инициировал |
|---|---|
твоя строка (если ты её передавал в POST /charges) |
ты — списание режима A |
null |
платёжная система — списание при привязке мандата |
Списаниям, которые инициирует платёжная система, external_id присвоить некому, поэтому он всегда null. Обратное, строго говоря, не гарантировано: если ты вызвал POST /charges без external_id, твоё списание тоже придёт с null.
Практический вывод
Всегда передавай external_id в POST /:id/charges. Тогда правило становится однозначным: external_id == null ⇒ это не твоё списание, а списание при привязке.
Создать списание (режим A) — POST /subscriptions/:id/charges¶
Инициирует одно списание по активному мандату. Доступно только когда подписка в статусе active.
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../charges \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_rub": "500.00",
"external_id": "sub-charge-2026-07"
}'
Body¶
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
amount_rub |
string | ✓ | Сумма списания. Decimal, до 2 знаков. Должна быть ≤ cap_amount_rub подписки |
external_id |
string | Твой ID списания. До 256 символов. Настоятельно рекомендуем передавать — см. как отличить списание при привязке |
Других полей тело не принимает. Период списания задать нельзя — он всегда определяется по времени нашего сервера в момент запроса. Лишние ключи в теле молча игнорируются: они не вызовут ошибку, но и не повлияют ни на что.
Ответ 201¶
Ответ — стандартный объект платежа (как у POST /payments), с payment_method: "recurring":
{
"id": "pay_660e8400-e29b-41d4-a716-446655440111",
"status": "pending",
"amount_rub": "500.00",
"actual_amount_rub": null,
"amount_usdt": null,
"external_id": "sub-charge-2026-07",
"payment_method": "recurring",
"subscription_id": "sub_550e8400-...",
"period_ref": "2026-07",
"created_at": "2026-07-18T10:00:00Z",
"paid_at": null,
"settled_at": null,
"metadata": null
}
Финальный результат списания придёт webhook'ом payment.status_changed (paid / failed и т.д.) — как у обычного платежа. Начальный статус в ответе — pending.
Идемпотентность списания¶
Идемпотентность списания — по периоду, а не только по заголовку
В одном периоде может быть одно списание. Повторный POST /charges в том же периоде — даже с другим Idempotency-Key — вернёт то же списание (HTTP 200), а не создаст дубль.
Период определяется по времени нашего сервера в момент запроса и считается по UTC от interval подписки:
interval |
Ключ периода | Пример |
|---|---|---|
day |
YYYY-MM-DD |
2026-07-18 |
week |
ISO-неделя YYYY-Www |
2026-W29 |
month |
YYYY-MM |
2026-07 |
interval_count в расчёте периода не участвует: при interval: "month", interval_count: 3 период всё равно календарный месяц, и списывать можно ежемесячно. Ограничение «раз в 3 месяца» реализуй у себя.
Управлять периодом из запроса нельзя — параметра для этого в API нет. Хочешь списать за другой период — дождись его наступления.
Вычислять ключ периода самому не нужно и не надо: он приходит готовым в поле period_ref — и в ответе на POST /charges, и в объекте платежа, и в webhook'е payment.status_changed. Твой расчёт по created_at и наш серверный расчёт разойдутся ровно на границе месяца/ISO-недели и в чужой таймзоне — то есть там, где ошибка стоит двойного списания. Сравнивай period_ref, а не даты.
Границы периода — календарные, а не «раз в 30 дней»
Период идемпотентности не привязан к дате привязки мандата. Мандат, подтверждённый 31 июля, занимает период 2026-07; твоё списание 1 августа попадёт уже в 2026-08 и пройдёт — клиент заплатит дважды за два дня.
Если в первом периоде списание уже сделала платёжная система при привязке (см. общий поток), своё первое списание планируй не «через месяц после привязки», а на следующий календарный период, и перед вызовом сверяйся с GET /:id/charges — по полю period_ref списаний, а не по их датам.
Обратная сторона того же правила: если твоё списание за месяц уже прошло, повтор в том же месяце вернёт 200 и старый платёж. Не считай такой ответ подтверждением нового списания — сверяй id платежа в ответе.
Ошибки списания¶
| HTTP | Code | Причина | Что делать |
|---|---|---|---|
| 400 | MISSING_IDEMPOTENCY_KEY |
Не передан заголовок Idempotency-Key |
Добавь UUID |
| 400 | VALIDATION_ERROR |
Невалидное тело (amount_rub не decimal, забыт Content-Type: application/json) |
Исправь запрос |
| 404 | SUBSCRIPTION_NOT_FOUND |
Подписка не найдена / чужая / другого режима | Проверь ID и режим ключа |
| 409 | CHARGE_MANDATE_INACTIVE |
Подписка не active (pending_binding / paused / cancelled), либо мандат не привязан |
Дождись активации / resume |
| 409 | CAP_EXCEEDED |
Сумма превышает cap_amount_rub (на списание) или cap_window_rub (за период) |
Уменьши сумму или подними потолок через PATCH |
| 501 | RECURRING_NOT_SUPPORTED |
Списывать по мандату на этой рельсе нечем. В песочнице обычно значит «песочные подписки не подняты», а не «опция не включена аккаунту» — см. предупреждение выше | Платёж не создан, период свободен |
| 503 | PROVIDER_NOT_CONFIGURED |
Рельса этой подписки сейчас не поднята на инстансе | Платёж не создан. Повтори позже, напиши в X-Hub если повторяется |
| 503 | PROVIDER_UNAVAILABLE |
Списание не дошло: сеть, таймаут, 5xx у платёжной системы. Дебет мог пройти — мы просто не увидели ответ |
Платёж остаётся pending. Не создавай второе списание. Повтори тот же запрос — идемпотентность по периоду вернёт то же списание |
| 502 | PROVIDER_ERROR |
Списание отклонено. Сюда попадает реальный отказ банка (нет денег на счёте, мандат отозван на стороне банка), а также осознанный отказ платёжной системы | Списание помечено failed, период освобождён. Причину смотри в failure_reason самого списания — GET /subscriptions/:id/charges. От неё зависит, имеет ли смысл повтор |
502 PROVIDER_ERROR — это в том числе «банк не дал денег»
Сам HTTP-код на этой ручке общий: и «не хватило средств», и «мандат отозван» приходят как 502 PROVIDER_ERROR. Но причина больше не теряется — она сохраняется у списания в поле failure_reason:
curl "https://api.x-hub.online/api/v1/subscriptions/sub_.../charges?per_page=1" \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
# → data[0].status == "failed", data[0].failure_reason == "insufficient_funds"
Разбор кодов и рекомендованное действие по каждому — причина отказа. Коротко: insufficient_funds — повторить через 1–3 дня и подписку не отменять; mandate_revoked — повторы бессмысленны, нужна новая привязка.
Не трактуй 502 как «сбой на нашей стороне, надо ретраить вслепую»: для списания это ответ по конкретному клиенту, и что делать — говорит failure_reason. Практическая развилка:
503→ неопределённость, повторяй тот же запрос;502→ отказ, включай свой dunning — но ветку выбирай поfailure_reason, а не по факту отказа.
Если отказ повторяется на всех клиентах разом — это уже похоже на нашу проблему, напиши команде X-Hub.
Как связать списание с подпиской
Платёж рекуррентного списания несёт два связующих поля — subscription_id ("sub_...") и period_ref (период, который списание занимает) — и в webhook payment.status_changed (при любом изменении статуса), и в объекте платежа (GET /payments/:id, GET /subscriptions/:id/charges).
Поведение у обоих полей одинаковое, это пара: в webhook у обычных (не подписочных) платежей их нет, в REST-ответе они присутствуют всегда и равны null. Дополнительно списания подписки доступны списком: GET /subscriptions/:id/charges.
Пауза / возобновление / отмена¶
Три lifecycle-перехода. Все требуют только заголовки авторизации (тело не нужно). Возвращают обновлённый объект подписки.
# Пауза: active → paused
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../pause \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
# Возобновление: paused → active
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../resume \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
# Отмена: active|paused → cancelled (терминально)
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../cancel \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
| Действие | Переход | Правила |
|---|---|---|
POST /:id/pause |
active → paused |
Только из active. Иначе 409 INVALID_STATE |
POST /:id/resume |
paused → active |
Только из paused. Иначе 409 INVALID_STATE |
POST /:id/cancel |
active / paused → cancelled |
Терминально. Из cancelled / expired → 409 INVALID_STATE. Отменить pending_binding через этот эндпоинт нельзя (только active/paused) |
Ошибки переходов:
| HTTP | Code | Причина |
|---|---|---|
| 404 | SUBSCRIPTION_NOT_FOUND |
Подписка не найдена / чужая / другого режима |
| 409 | INVALID_STATE |
Переход недопустим из текущего статуса |
| 409 | CONFLICT |
Подписку одновременно меняли — повтори запрос |
Webhooks по подпискам¶
Списания. Каждое списание — это платёж, поэтому его изменения приходят обычным событием payment.status_changed (тот же payload и та же проверка подписи, что у платежей — см. Webhooks и Verify signature). Отличить списание можно по payment_method: "recurring"; сопоставить с подпиской — по полю subscription_id в самом payload'е ("sub_..."), а с периодом — по period_ref ("2026-07"), без обращения к API. Свой external_id для этого больше не нужен.
{
"event": "payment.status_changed",
"payment_id": "pay_660e8400-...",
"external_id": "sub-charge-2026-07",
"status": "paid",
"previous_status": "pending",
"amount_rub": "500.00",
"actual_amount_rub": "500.00",
"amount_usdt": null,
"payment_method": "recurring",
"subscription_id": "sub_550e8400-...",
"period_ref": "2026-07",
"timestamp": "2026-07-18T10:00:12Z",
"metadata": null
}
Изменения статуса самой подписки. Когда подписка меняет статус — подтверждение мандата (active), пауза (paused), отмена мерчантом или отзыв на стороне банка/плательщика (cancelled) — прилетает событие subscription.status_changed (та же проверка подписи, что у остальных webhook'ов).
{
"event": "subscription.status_changed",
"subscription_id": "sub_550e8400-...",
"external_id": "sub-order-12345",
"status": "active",
"past_due": false,
"binding_url": "https://sub.nspk.ru/abc-def-ghi",
"binding_url_pending": false,
"previous_status": "pending_binding",
"timestamp": "2026-07-19T10:00:00Z"
}
Это же событие приходит, когда меняется под-статус past_due (банк начал или прекратил взыскание по мандату). В таком случае status не меняется, и previous_status равен status — читай past_due. Подробнее — про past_due.
Тем же событием приезжает ссылка привязки, если на момент ответа POST /subscriptions она ещё не была готова: status остаётся pending_binding (и previous_status равен ему), а в payload'е приходит заполненный binding_url и binding_url_pending: false. Новых типов событий мы для этого не заводили — набор событий закрыт, и обработчик, который уже слушает subscription.status_changed, получает ссылку без единой правки маршрутизации. Подробнее — binding_url_pending.
Это удобно, чтобы узнавать об активации мандата (клиент подтвердил привязку), о просрочке и об отзыве подписки на стороне банка/плательщика — без опроса. Как fallback (или для проверки текущего состояния) всегда доступен GET /subscriptions/:id.
Порядок событий при привязке не гарантирован
Подтверждение мандата порождает два независимых события: активацию подписки и списание первого периода. Они приходят разными webhook'ами и могут прийти в любом порядке — payment.status_changed (payment_method: "recurring", external_id: null) вполне может опередить subscription.status_changed → active.
Обработчик должен переживать оба порядка: не отбрасывай списание из-за того, что подписка у тебя ещё числится pending_binding, и не считай отсутствие списаний на момент активации доказательством того, что первый период не оплачен. Правда о списаниях — в GET /subscriptions/:id/charges.
Неуспешные списания (dunning). Отдельного subscription.charge_failed нет — но это и не нужно: каждое списание это платёж, и его провал приходит обычным payment.status_changed со status: "failed" и payment_method: "recurring" (см. выше). Если банк перевёл мандат во взыскание, дополнительно поднимается флаг past_due у подписки (и приходит subscription.status_changed) — это ловит и те случаи, когда неудачное списание инициировала сама платёжная система, а не ты. Подписка и период указаны в том же payload'е — поля subscription_id и period_ref; свой external_id для сопоставления не нужен.
Sandbox¶
Подписки полностью работают в sandbox (ключи xh_test_...): ответы содержат "sandbox": true, реальные СБП-мандаты не создаются, внешняя платёжная система не вызывается.
binding_url — ссылка вида https://app.x-hub.online/sandbox/bind/sbx_sub_<hash>. Открывать её не нужно: реального плательщика в песочнице нет, мандат подтверждается сам. Если открыть — страница честно об этом скажет («мандат уже подтверждён»), никаких действий она не предлагает и статус не меняет. Текущее состояние подписки читай через GET /api/v1/subscriptions/:id.
| Sandbox | Production | |
|---|---|---|
Статус сразу после POST /subscriptions |
active |
pending_binding |
| Шаг привязки клиентом | нет — мандат подтверждается сам | клиент подтверждает мандат по binding_url |
binding_url |
заглушка на app.x-hub.online |
реальная ссылка СБП |
binding_url_pending |
всегда false — заглушка готова сразу |
обычно false, но изредка true: ссылка выпускается асинхронно (см. binding_url_pending) |
| Списание при привязке | не происходит | происходит: первый период списывается сразу |
subscription.status_changed (pending_binding → active) |
приходит | приходит |
POST /:id/charges |
работает сразу после создания | работает после активации мандата |
Главное расхождение песочницы с боем — списание при привязке
В песочнице нет плательщика, поэтому дебета при подтверждении мандата не происходит: сразу после POST /subscriptions список GET /:id/charges пуст, и твой первый POST /charges создаёт первое и единственное списание периода.
В бою всё иначе: к моменту, когда подписка станет active, за текущий период уже есть списание, сделанное платёжной системой. Интеграция, которую ты отладил в песочнице по схеме «создал подписку → сразу списал», в бою даст двойной дебет клиента (либо, если период совпал, вернёт 200 с чужим id платежа).
Поэтому сценарий «первое списание» песочницей не проверяется. Обязательно предусмотри в коде ветку из раздела Как это работает — проверку GET /:id/charges перед первым собственным списанием — даже если в песочнице она всегда срабатывает вхолостую.
Остальная механика песочницы совпадает с боевой: идемпотентность по периоду, cap_amount_rub / cap_window_rub, lifecycle-переходы pause / resume / cancel и вебхуки работают так же.
Списание в песочнице само становится paid через 30 секунд¶
POST /:id/charges в песочнице возвращает платёж в статусе pending — это нормально и это не тупик. Песочное списание — обычный песочный платёж, поэтому на него действуют те же magic-токены и авто-симуляция, что и на POST /payments:
Что передал в external_id списания |
Через сколько | Итоговый статус |
|---|---|---|
| (ничего) | 30 сек | paid |
xhub_test_paid |
5 сек | paid |
xhub_test_fail |
5 сек | failed c failure_reason: "insufficient_funds" |
xhub_test_expire |
60 сек | expired |
xhub_test_mismatch |
5 сек | amount_mismatch |
xhub_test_manual |
— | остаётся pending, пока не нажмёшь кнопку на песочной странице |
Не опрашивай /charges в цикле, ожидая мгновенного paid
Типичная ошибка: сразу после POST /charges начать поллить GET /subscriptions/:id/charges, увидеть pending и решить, что списание не работает. Просто подожди — по умолчанию через 30 секунд прилетит payment.status_changed со status: paid и payment_method: "recurring".
Провал списания тестируется ТОЛЬКО магическим токеном
В песочнице нет плательщика, у которого могут кончиться деньги, и нет банка, который может отозвать мандат, — поэтому «естественного» провала списания не существует. Единственный способ прогнать свою ветку dunning'а:
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_.../charges \
-H "X-Api-Key: xh_test_..." -H "X-Api-Secret: ..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "amount_rub": "500.00", "external_id": "sub-charge-2026-07 xhub_test_fail" }'
Через 5 секунд придёт payment.status_changed со status: "failed", payment_method: "recurring", subscription_id и failure_reason: "insufficient_funds" — на этом payload'е и стоит писать ветку dunning'а. Полный список причин и рекомендованных действий — причина отказа.
Токен ищется как отдельное слово (\bxhub_test_fail\b), поэтому его можно дописать к своему external_id через пробел или дефис — сопоставление на твоей стороне не сломается. У списаний description не передаётся, так что токен кладётся только в external_id.
failed-списание освобождает период. Деньги не двигались, поэтому повторный POST /charges в том же периоде создаст новое списание, а не вернёт старое. Это верно и в бою.
Обмены (Exchange)¶
X-Hub поддерживает два направления обменов:
- USDT → RUB — основной поток: клиент присылает USDT, X-Hub конвертирует и отправляет RUB клиенту на карту по СБП. Создаётся мерчантом через
POST /api/v1/exchanges(см. ниже). Предназначено для мерчантов-обменников. - RUB → USDT — обратный поток: клиент платит рубли по СБП, X-Hub отправляет USDT на крипто-кошелёк клиента. Создаётся X-Hub для тебя по запросу — публичного API для этого направления нет. Мерчант видит статусы через те же webhooks
exchange.status_changed.
Доступно только для мерчантов с настроенной поддержкой нужного направления (сообщается при подключении).
RUB → USDT настраивается X-Hub по твоему запросу. Если ты работаешь как обменник и тебе нужно чтобы клиенты платили RUB и получали USDT — напиши менеджеру X-Hub, мы создадим обмен от твоего имени. Статус-переходы узнаешь через тот же webhook
exchange.status_changed.
Жизненный цикл обмена¶
USDT → RUB (создаётся мерчантом):
awaiting_usdt → usdt_received → rub_sending → completed
↓
partial (недополнение USDT — ждём доплату)
↓
expired (таймаут 15 минут)
RUB → USDT (создаётся X-Hub):
awaiting_rub → rub_received → usdt_sending → completed
↓
partial (клиент недоплатил RUB)
Финальные статусы: completed, failed, expired, cancelled.
Подробные переходы¶
| Из | В | Условие |
|---|---|---|
| pending | awaiting_usdt | X-Hub получил крипто-адрес для депозита клиента (USDT → RUB) |
| awaiting_usdt | usdt_received | Клиент прислал USDT на адрес |
| awaiting_usdt | partial | Клиент прислал меньше запрошенной суммы |
| awaiting_usdt / partial | expired | Прошло 15 минут, USDT не пришли полностью |
| usdt_received | rub_sending | X-Hub инициировал SBP-выплату |
| rub_sending | completed | Клиент получил RUB на карту |
| pending | awaiting_rub | X-Hub создал SBP-инвойс для приёма RUB от клиента (RUB → USDT) |
| awaiting_rub | rub_received | Клиент оплатил через СБП |
| awaiting_rub / rub_received | partial | Клиент недоплатил |
| rub_received | usdt_sending | X-Hub инициировал отправку USDT на кошелёк клиента (после hold-периода защиты от chargeback, ~5 мин) |
| usdt_sending | completed | USDT подтверждены в блокчейне |
| * | cancelled | Мерчант отменил обмен до получения средств |
| * | failed | Техническая ошибка (bank rejected, validation) |
Создать обмен USDT → RUB — POST /exchanges¶
Доступно только для направления USDT → RUB. Обмены RUB → USDT создаются X-Hub по запросу — публичного POST-эндпоинта нет.
curl -X POST https://api.x-hub.online/api/v1/exchanges \
-H "X-Api-Key: xh_live_..." \
-H "X-Api-Secret: ..." \
-H "Idempotency-Key: f47ac10b-58cc-4372-a567-0e02b2c3d479" \
-H "Content-Type: application/json" \
-d '{
"amount_usdt": "100.00",
"client_phone": "+79991234567",
"client_bank_id": "bank100000000111",
"external_id": "EXCH-2026-001"
}'
Headers¶
| Header | Обяз. | |
|---|---|---|
X-Api-Key |
✓ | |
X-Api-Secret |
✓ | |
Idempotency-Key |
✓ | UUID. Проверяется до чтения тела: без него — 400 MISSING_IDEMPOTENCY_KEY, не-UUID — 400 VALIDATION_ERROR |
Content-Type |
✓ | application/json. Без него тело не разбирается вовсе и запрос получает 400 VALIDATION_ERROR — как будто ты не передал ни одного поля |
Параметры тела¶
| Поле | Обязательно | Описание |
|---|---|---|
amount_usdt |
да | Сумма USDT которую клиент пришлёт. Decimal, до 8 знаков после точки, строго больше нуля. Минимум — чтобы итоговый RUB был ≥ 1000 |
client_phone |
да | Телефон клиента, формат +79991234567 (+ и 10–15 цифр) |
client_bank_id |
да | ID банка СБП, в который зачислять рубли клиенту. Формат: bank<цифры>. Справочник доступных банков запрашивайте у команды X-Hub — на MVP передаётся списком, публичный endpoint будет в следующей версии API. Примеры: bank100000000111 (Сбер), bank100000000008 (Альфа-Банк). |
external_id |
нет | Ваш ID обмена (до 40 символов, логируется в реквизитах СБП) |
idempotency_key |
нет | Legacy, не используйте. Поле принимается ради обратной совместимости, но идемпотентность задаёт заголовок Idempotency-Key — при расхождении побеждает он, а без заголовка запрос вообще не дойдёт до тела |
Ответ (201)¶
{
"id": "exch_abc123-...",
"status": "awaiting_usdt",
"crypto_deposit_address": "TQr...",
"network": "tron",
"amount_usdt": "100.00",
"amount_rub": "8245.00",
"rate": "82.45",
"expires_at": "2026-04-19T15:16:00Z",
"external_id": "EXCH-2026-001"
}
Что делать дальше¶
- Покажите клиенту
crypto_deposit_addressи суммуamount_usdt - Клиент присылает USDT на адрес (сеть
tron) - Банковская сеть определяет поступление → X-Hub фиксирует факт оплаты
- X-Hub инициирует SBP-выплату клиенту на телефон+банк
- Клиент получает
amount_rubна карту - Вы получаете webhook
exchange.status_changedсо статусомcompleted+ заработанные USDT на баланс мерчанта
Ошибки¶
| Код | HTTP | Причина |
|---|---|---|
MISSING_IDEMPOTENCY_KEY |
400 | Не передан заголовок Idempotency-Key. Проверяется первым — до тела запроса |
VALIDATION_ERROR |
400 | Невалидное тело. Самая частая причина — забытый Content-Type: application/json: без него тело не разбирается и все поля выглядят отсутствующими |
FEATURE_NOT_ENABLED |
400 | У вас не подключён USDT→RUB обмен — свяжитесь с X-Hub |
FEATURE_DISABLED |
503 | Направление обменов временно выключено на стороне X-Hub целиком (не про твой аккаунт). Это не ошибка запроса — повтори позже; сроки уточняй у команды |
CLIENT_NOT_REGISTERED |
422 | Клиент не прошёл KYC — запустите /kyc/register |
CLIENT_BLOCKED |
403 | Клиент заблокирован платёжной системой |
RATE_INVALID |
502 | Не удалось получить курс для обмена — повторите через 30–60 сек |
PROVIDER_UNAVAILABLE |
503 | Временная ошибка внешней платёжной системы — повторите через минуту |
EXCHANGE_IN_PROGRESS |
409 | У клиента уже есть активный обмен — дождитесь завершения |
AMOUNT_TOO_LOW |
400 | Итоговый amount_rub < 1000 ₽ |
AMOUNT_TOO_HIGH |
400 | amount_rub > 1 000 000 ₽ |
IDEMPOTENCY_TERMINAL |
409 | Обмен с этим ключом идемпотентности уже завершён — используйте новый ключ |
FEATURE_NOT_ENABLED (400) и FEATURE_DISABLED (503) — разные вещи
400 FEATURE_NOT_ENABLED — опция обменов не подключена твоему аккаунту. Само по себе не пройдёт: нужно обращение в X-Hub.
503 FEATURE_DISABLED — направление выключено у всех, рубильником на нашей стороне (инцидент, работы у платёжной системы). Твоя интеграция исправна; ретрай с тем же Idempotency-Key безопасен. Не пиши в поддержку по первому такому ответу — сначала подожди.
Получить обмен — GET /exchanges/:id¶
curl https://api.x-hub.online/api/v1/exchanges/exch_abc123 \
-H "X-Api-Key: xh_live_..." \
-H "X-Api-Secret: ..."
Ответ:
{
"id": "exch_abc123",
"status": "completed",
"amount_usdt": "100.00",
"amount_usdt_received": "100.00",
"amount_rub": "8000.00",
"rate": "80.00",
"crypto_deposit_address": "TR...",
"network": "tron",
"client_phone": "+79990001234",
"expires_at": "2026-04-20T11:00:00Z",
"completed_at": "2026-04-20T10:15:00Z",
"external_id": "order-42",
"created_at": "2026-04-20T10:00:00Z"
}
Поля amount_usdt_received, completed_at — null пока обмен не в финальном статусе.
Список обменов — GET /exchanges¶
Параметры:
- page — номер страницы (1+)
- per_page — размер (1-100, default 20)
- status — фильтр по статусу (см. жизненный цикл)
Отменить обмен — POST /exchanges/:id/cancel¶
Работает только для awaiting_usdt / pending. После получения USDT отменить нельзя.
curl -X POST https://api.x-hub.online/api/v1/exchanges/exch_abc123/cancel \
-H "X-Api-Key: xh_live_..." \
-H "X-Api-Secret: ..."
Ответ 200:
Webhook exchange.status_changed¶
X-Hub отправляет webhook на ваш webhook_url при финальных статусах (completed, failed, expired, cancelled). Промежуточные статусы (usdt_received, rub_sending, partial) — внутренние.
{
"event": "exchange.status_changed",
"exchange_id": "exch_abc123-...",
"external_id": "EXCH-2026-001",
"direction": "usdt_to_rub",
"status": "completed",
"previous_status": "rub_sending",
"amount_usdt": "100.00",
"amount_usdt_received": "100.00",
"amount_rub": "8245.00",
"rate": "82.45",
"client_phone": "+79991234567",
"crypto_deposit_address": "TQr...",
"network": "tron",
"expires_at": "2026-04-19T15:16:00Z",
"timestamp": "2026-04-19T15:05:12Z"
}
Поле direction — usdt_to_rub или rub_to_usdt. Для RUB→USDT direction статусы: awaiting_rub, rub_received, usdt_sending, completed. Для USDT→RUB остаются awaiting_usdt, usdt_received, rub_sending, completed. Используй direction чтобы не полагаться на угадывание по статусу.
Верификация подписи — как для платежей.
Экономика обмена¶
Курс клиенту: rateClient = rateProvider × (1 − markupPercent/100). Пример:
- Базовый курс: 85 RUB/USDT
- Ваш
markup_percent: 3% - Клиенту: 85 × 0.97 = 82.45 RUB/USDT
- Клиент прислал 100 USDT → получил 8 245 RUB
- Платёж закрыт на 8 500 RUB (100 × 85 по базовому курсу)
- Остаток 255 RUB — ваш заработок, конвертируется в ≈ 3 USDT и зачисляется на ваш баланс как
EXCHANGE_MERCHANT_GAIN
Важные ограничения¶
- Минимум 1000 RUB, максимум 1 000 000 RUB на обмен
- Один активный обмен на клиента (по
client_phone) - Таймаут 15 минут — если USDT не получены полностью, статус →
expired - Сеть USDT — только
tron(TRC20) - KYC обязателен — клиент должен быть зарегистрирован через /kyc/register
KYC (Know Your Customer)¶
KYC — верификация конечного клиента мерчанта (того кто платит). Не мерчанта. Нужен только для:
- Платежей на суммы выше настроенного порога (если настроено);
- Обменов (
/exchanges) в обе стороны — USDT↔RUB; - Некоторых категорий мерчантов по решению X-Hub.
Если твой аккаунт не требует KYC — пропусти этот раздел. Все три KYC-эндпоинта вернут {"status": "not_required"} или 400 KYC_NOT_REQUIRED.
Два способа интеграции KYC¶
У мерчанта два варианта — выбери один:
Способ 1: Hosted redirect (простой)¶
X-Hub предоставляет готовую страницу верификации. Мерчант перенаправляет своего клиента туда, клиент заполняет форму (телефон, селфи, паспорт, адрес), после верификации возвращается на сайт мерчанта.
Query-параметры:
| Параметр | Обяз. | Описание |
|---|---|---|
phone |
— | Телефон клиента в формате +7XXXXXXXXXX (можно не передавать — клиент введёт сам) |
redirect |
— | URL, куда вернуть клиента после завершения. Только HTTPS, только same-origin с твоим доменом. Если не указан — редирект на / |
Плюсы: ничего не надо писать на стороне мерчанта. Минусы: клиент уходит с твоего сайта, меньше контроля над UX.
Способ 2: API-first (встроенный в UI мерчанта)¶
Мерчант делает KYC-форму внутри своего приложения (свой UI, свой дизайн), а вызывает наши API для загрузки документов и регистрации. Клиент не покидает сайт мерчанта.
Flow (со стороны backend мерчанта):
POST /v1/kyc/check→ проверить, нужен ли KYC и не пройден ли уже;POST /v1/kyc/upload-url× 3 → получить signed URL на селфи / паспорт / адрес;- Фронтенд мерчанта загружает файлы напрямую на upload URL (PUT, без прохождения через backend);
POST /v1/kyc/register→ передатьfile_key'и +phone;- Периодически
POST /v1/kyc/check→ дождатьсяstatus: verified.
Плюсы: полный контроль UX и брендинга. Минусы: больше кода на стороне мерчанта, своя форма загрузки файлов.
Детали всех endpoints — ниже.
Когда нужен KYC¶
Если при создании платежа вы получаете ошибку:
— значит для вашего аккаунта настроена обязательная KYC-проверка. Используйте flow ниже.
Если ошибки нет¶
Если при создании платежа вы НЕ получаете KYC_REQUIRED — KYC не требуется. Можете пропустить этот раздел. Все три KYC-эндпоинта вернут {"status": "not_required"} или 400 KYC_NOT_REQUIRED.
Проверить KYC — POST /kyc/check¶
Проверяет статус KYC-верификации клиента по номеру телефона.
curl -X POST https://api.x-hub.online/api/v1/kyc/check \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Content-Type: application/json" \
-d '{"phone": "+79991234567"}'
Body¶
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
phone |
string | ✓ | Телефон клиента в формате +7XXXXXXXXXX |
Ответы¶
KYC не требуется (для вашего аккаунта не настроен):
KYC верифицирован:
KYC не пройден (нужна регистрация):
KYC в обработке:
KYC отклонён:
Ошибки KYC-эндпоинтов¶
Одинаковы для всех трёх (/kyc/check, /kyc/upload-url, /kyc/register):
| HTTP | Code | Причина | Что делать |
|---|---|---|---|
| 400 | SANDBOX_NOT_SUPPORTED |
KYC в песочнице недоступен для конфигурации твоего аккаунта | Тестируй KYC в боевом режиме или напиши в X-Hub |
| 400 | KYC_NOT_REQUIRED |
KYC для аккаунта не настроен (/upload-url, /register). У /kyc/check тот же случай даёт 200 {"status":"not_required"} |
Просто создавай платежи, KYC не нужен |
| 400 | VALIDATION_ERROR |
Невалидный phone / content_type / состав ключей файлов |
Проверь формат |
| 403 | PROVIDER_DISABLED |
KYC выключен для твоего мерчанта рубильником на нашей стороне. Отдаётся до любой другой проверки — то есть даже валидный запрос получит 403 |
Это не ошибка запроса и не «не пройден KYC». Ретрай не поможет: напиши в X-Hub |
| 502 | PROVIDER_ERROR |
Проверяющая сторона не ответила (сеть, таймаут, её 5xx) |
Временная ошибка. Ретрай через 30–60 сек |
403 PROVIDER_DISABLED легко спутать с отказом в верификации
Это не «клиент не прошёл KYC» — отказ по клиенту приходит как {"status": "failed"} с 200. 403 PROVIDER_DISABLED означает, что весь KYC-контур твоего аккаунта отключён у нас, и ни один клиент верифицироваться не сможет. Если он появился внезапно — не переписывай интеграцию, сначала спроси команду X-Hub.
Получить URL для загрузки документов — POST /kyc/upload-url¶
Получает pre-signed URL для загрузки документов KYC (фото паспорта, селфи).
curl -X POST https://api.x-hub.online/api/v1/kyc/upload-url \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Content-Type: application/json" \
-d '{"content_type": "image/jpeg"}'
Body¶
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
content_type |
string | ✓ | MIME-тип файла (image/jpeg, image/png, application/pdf) |
Ответ¶
Загрузи файл по upload_url (PUT), затем используй file_key в POST /kyc/register.
Зарегистрировать KYC — POST /kyc/register¶
Отправляет данные KYC-верификации в X-Hub.
curl -X POST https://api.x-hub.online/api/v1/kyc/register \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Content-Type: application/json" \
-d '{
"phone": "+79991234567",
"selfie_key": "kyc/selfie-abc123",
"document_key": "kyc/doc-def456",
"address_key": "kyc/addr-ghi789"
}'
Body¶
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
phone |
string | ✓ | Телефон клиента в формате +7XXXXXXXXXX. Единственное поле, обязательное всегда |
selfie_key |
string | * | file_key от upload-url (селфи) |
document_key |
string | * | file_key от upload-url (документ) |
address_key |
string | file_key от upload-url (подтверждение адреса) |
|
email |
string | * | E-mail клиента |
user_type |
string | * | individual или legal_entity |
Какие из полей со звёздочкой нужны — зависит от KYC-конфигурации аккаунта
Конфигураций две, и обязательный набор у них разный:
- загрузка документов — нужны
selfie_key+document_key(плюс опциональноaddress_key); - переадресация клиента на форму верификации — нужны
email+user_type, файлы не нужны; в ответе придёт{"status": "required", "kycUrl": "..."}, и клиента надо отправить по этому адресу.
Валидация тела не проверяет комбинацию: запрос без нужных полей пройдёт схему и упрётся уже в проверяющую сторону (502 PROVIDER_ERROR или отказ по клиенту). Какая конфигурация у тебя — уточни при подключении.
Ответ¶
Либо {"status": "required", "kycUrl": "..."} — если клиента нужно отправить на форму верификации, либо {"status": "verified", "accountNumber": "..."} — если он уже верифицирован.
Повторный register по тому же телефону безопасен
Если клиент уже верифицирован (или ему уже выдана ссылка на верификацию), повторный вызов вернёт сохранённый результат, не запуская проверку заново. Исключение — клиент, которому ранее отказали: такой запрос уйдёт на новую проверку.
После регистрации — периодически проверяй статус через POST /kyc/check.
Интеграция KYC + платежи¶
Если для вашего аккаунта требуется KYC, при создании платежа передавай client_phone:
curl -X POST https://api.x-hub.online/api/v1/payments \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_rub": "500.00",
"payment_method": "sbp",
"client_phone": "+79991234567"
}'
Если KYC не пройден, API вернёт:
400 KYC_REQUIRED— клиент не верифицирован400 KYC_PROCESSING— верификация в процессе, попробуй позже400 KYC_FAILED— верификация отклонена
Ошибки¶
Формат¶
{
"error": {
"code": "PAYMENT_NOT_FOUND",
"message": "Payment with id 'pay_xxx' not found",
"details": {}
}
}
Таблица¶
| HTTP | Code | Когда | Что делать |
|---|---|---|---|
| 400 | VALIDATION_ERROR |
Невалидные параметры запроса | Проверь details, исправь запрос |
| 400 | MISSING_IDEMPOTENCY_KEY |
Нет заголовка Idempotency-Key на POST |
Добавь UUID |
| 400 | KYC_REQUIRED |
Клиент не прошёл KYC (если для аккаунта настроена KYC-проверка) | Отправь клиента на KYC-верификацию |
| 400 | KYC_PROCESSING |
KYC-верификация ещё обрабатывается | Повтори через несколько минут |
| 400 | KYC_FAILED |
KYC-верификация отклонена | Клиент должен пройти KYC заново |
| 400 | KYC_NOT_REQUIRED |
Вызов KYC-эндпоинта, когда KYC не настроен для аккаунта | KYC не нужен, просто создавай платёж |
| 502 | KYC_PROVIDER_ERROR |
Проверяющая сторона не ответила при создании платежа (не при вызове /kyc/*) |
Временная ошибка. Ретрай через 30–60 сек с тем же Idempotency-Key |
| 403 | PROVIDER_DISABLED |
KYC-контур мерчанта отключён рубильником X-Hub. Возвращают все /kyc/* — до любых других проверок |
Ретрай не поможет — свяжись с X-Hub. См. Ошибки KYC |
| 403 | QR_PAYMENTS_DISABLED |
Аккаунт настроен только на рекуррентные списания — разовые платежи по QR ему запрещены | Не создавай POST /payments; работай через /subscriptions. Нужен приём разовых — напиши в X-Hub |
| 400 | SANDBOX_NOT_SUPPORTED |
Эндпоинт отключён в sandbox для твоего аккаунта (у части конфигураций — /kyc/*) |
Тестируй этот флоу в боевом режиме или свяжись с X-Hub |
| 400 | AMOUNT_TOO_LOW |
Сумма меньше минимальной для операции (пример: обмен < 1000 ₽) | Увеличь сумму |
| 400 | AMOUNT_TOO_HIGH |
Сумма превышает максимум | Разбей на несколько операций или свяжись с X-Hub |
| 422 | CLIENT_NOT_REGISTERED |
Клиент не прошёл KYC-регистрацию для USDT→RUB | Сначала зарегистрируй клиента через /kyc/register |
| 403 | CLIENT_BLOCKED |
Аккаунт клиента заблокирован | Свяжись с X-Hub |
| 401 | UNAUTHORIZED |
Неверные X-Api-Key / X-Api-Secret |
Проверь ключи |
| 401 | MODE_MISMATCH |
Ключ не-активного режима (test/live) — активен другой режим | Переключи режим в кабинете или используй ключи активного режима |
| 403 | IP_NOT_WHITELISTED |
IP-адрес не в whitelist мерчанта | Добавь IP в настройках или свяжись с X-Hub |
| 404 | PAYMENT_NOT_FOUND |
Платёж не существует / чужой | Проверь ID |
| 404 | NOT_FOUND |
Обмен не существует / чужой | Проверь ID |
| 410 | WITHDRAWAL_DISCONTINUED |
Запрос к выведенным из эксплуатации эндпоинтам /withdrawals |
Ничего не делать — выплаты происходят автоматически, см. Выплаты |
| 409 | EXCHANGE_IN_PROGRESS |
Обмен уже в обработке | Дождись финального статуса |
| 409 | INVALID_STATUS |
Операция невалидна для текущего статуса (напр. попытка отменить уже завершённый обмен) | Проверь статус перед действием |
| 409 | IDEMPOTENCY_TERMINAL |
Повтор запроса с тем же idempotency_key после финального статуса |
Используй новый ключ |
| 502 | RATE_INVALID |
Временная ошибка получения курса для обмена | Ретрай через 30-60 сек |
| 503 | PROVIDER_UNAVAILABLE |
Внешняя платёжная система временно недоступна. Операция могла пройти — мы не увидели ответ | Ретрай с тем же Idempotency-Key через 30-60 сек. Второй платёж не создастся |
| 503 | PROVIDER_NOT_CONFIGURED |
Платёжная система не настроена на инстансе | Свяжись с X-Hub |
| 400 | FEATURE_NOT_ENABLED |
Опция не подключена на твоём аккаунте (например USDT→RUB или KYC) | Свяжись с X-Hub для подключения |
| 503 | FEATURE_DISABLED |
Направление выключено у всех рубильником X-Hub (инцидент / работы). Твоя интеграция исправна | Подожди и ретрай. В поддержку — если держится долго |
| 429 | RATE_LIMIT_EXCEEDED |
Превышен лимит. Создание платежа: 120 req/min на ключ или 120 req/min на IP. Остальные вызовы: 60 req/min на ключ или 300 req/min на IP | Жди Retry-After секунд, см. Rate Limiting |
| 500 | INTERNAL_ERROR |
Серверная ошибка | Ретрай через 30 сек, если повторяется — пиши команде |
| 404 | SUBSCRIPTION_NOT_FOUND |
Подписка не существует / чужая / другого режима | Проверь ID |
| 409 | CHARGE_MANDATE_INACTIVE |
Списание по неактивному мандату (подписка не active) |
Дождись активации / возобнови подписку |
| 409 | CAP_EXCEEDED |
Списание превышает cap_amount_rub или cap_window_rub |
Уменьши сумму или измени потолки (PATCH) |
| 409 | INVALID_STATE |
Недопустимый lifecycle-переход подписки (pause/resume/cancel) | Проверь текущий status |
| 501 | RECURRING_NOT_SUPPORTED |
Списывать по мандату нечем. В бою — опция не подключена аккаунту; в песочнице — не подняты песочные подписки | См. Подписки — причины разные, ответ один |
| 502 | PROVIDER_ERROR |
Операция отклонена платёжной системой. На POST /subscriptions — не удалось инициировать привязку мандата; на POST /:id/charges — отказ по списанию, включая отказ банка; на /kyc/* — проверяющая сторона не ответила |
Для привязки и KYC — повтори через 30–60 сек. Для списания это чаще окончательный отказ, см. Ошибки списания |
Validation errors¶
При 400 VALIDATION_ERROR в details — массив проблем:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{"path": "amount_rub", "message": "Must be a decimal string with up to 2 decimal places"},
{"path": "payment_method", "message": "Must be 'sbp'"}
]
}
}
Retry-стратегия¶
| Код | Retry? | Как |
|---|---|---|
| 4xx | нет | Fix запрос, ретрай не поможет. Исключения — 429 ниже и 403 PROVIDER_DISABLED / 403 QR_PAYMENTS_DISABLED (их лечит не ретрай, а обращение в X-Hub) |
| 429 | да | Жди Retry-After / X-RateLimit-Reset |
| 500 | да | Exponential backoff: 1s, 2s, 4s, 8s, max 5 попыток |
| 501 | нет | Возможность не подключена — ретрай ничего не изменит |
| 502 | зависит | PROVIDER_ERROR на привязке мандата и на KYC — ретрай (5s, 15s, 60s). PROVIDER_ERROR на списании — это отказ, а не сбой: ретрай даст то же самое |
| 503 | да | PROVIDER_UNAVAILABLE / PROVIDER_NOT_CONFIGURED / FEATURE_DISABLED — backoff 30–60 сек, обязательно с тем же Idempotency-Key |
Tip
Всегда используй Idempotency-Key на ретраях POST — иначе можно создать дубль платежа.
Примеры: Node.js¶
Рабочий код на Node.js 18+ с нативным fetch и crypto. Без зависимостей.
Клиент X-Hub¶
// xhub-client.js
import crypto from 'crypto';
const BASE_URL = 'https://api.x-hub.online/api/v1';
export async function createPayment({ amountRub, externalId, description, metadata }) {
const res = await fetch(`${BASE_URL}/payments`, {
method: 'POST',
headers: {
'X-Api-Key': process.env.XHUB_API_KEY,
'X-Api-Secret': process.env.XHUB_API_SECRET,
'Idempotency-Key': crypto.randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount_rub: amountRub,
external_id: externalId,
payment_method: 'sbp',
description,
metadata,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`X-Hub ${res.status}: ${err.error?.code}: ${err.error?.message}`);
}
return res.json();
}
export async function getPayment(id) {
const res = await fetch(`${BASE_URL}/payments/${id}`, {
headers: {
'X-Api-Key': process.env.XHUB_API_KEY,
'X-Api-Secret': process.env.XHUB_API_SECRET,
},
});
if (!res.ok) throw new Error(`X-Hub ${res.status}`);
return res.json();
}
export async function getBalance() {
const res = await fetch(`${BASE_URL}/balance`, {
headers: {
'X-Api-Key': process.env.XHUB_API_KEY,
'X-Api-Secret': process.env.XHUB_API_SECRET,
},
});
return res.json();
}
Webhook receiver (Express)¶
// webhook-server.js
import express from 'express';
import crypto from 'crypto';
import { grantAccess } from './business-logic.js';
const app = express();
const processedWebhooks = new Set(); // в проде — Redis/DB
app.post(
'/webhook/xhub',
express.raw({ type: 'application/json', limit: '100kb' }),
async (req, res) => {
try {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
// Подпись: HMAC-SHA256(timestamp + "." + body, secret)
const signatureData = timestamp + '.' + req.body.toString('utf-8');
const expected =
'sha256=' +
crypto
.createHmac('sha256', process.env.XHUB_WEBHOOK_SECRET)
.update(signatureData)
.digest('hex');
if (
!signature ||
signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf-8'));
const dedupeKey = `${event.payment_id || event.withdrawal_id}:${event.status}`;
if (processedWebhooks.has(dedupeKey)) {
return res.status(200).send('Already processed');
}
processedWebhooks.add(dedupeKey);
if (event.event === 'payment.status_changed') {
if (event.status === 'paid') {
await grantAccess(event.external_id, event.metadata);
}
}
res.status(200).send('OK');
} catch (err) {
console.error('Webhook error:', err);
res.status(500).send('Internal error');
}
},
);
app.listen(8080);
Примеры: Python¶
Python 3.10+ с requests и flask.
Клиент X-Hub¶
# xhub_client.py
import os, uuid, requests
BASE_URL = 'https://api.x-hub.online/api/v1'
HEADERS = {
'X-Api-Key': os.environ['XHUB_API_KEY'],
'X-Api-Secret': os.environ['XHUB_API_SECRET'],
}
class XHubError(Exception):
pass
def create_payment(amount_rub, external_id, description, metadata=None):
r = requests.post(
f'{BASE_URL}/payments',
headers={
**HEADERS,
'Idempotency-Key': str(uuid.uuid4()),
'Content-Type': 'application/json',
},
json={
'amount_rub': amount_rub,
'external_id': external_id,
'payment_method': 'sbp',
'description': description,
'metadata': metadata or {},
},
timeout=15,
)
if not r.ok:
raise XHubError(f'X-Hub {r.status_code}: {r.json().get("error")}')
return r.json()
def get_balance():
r = requests.get(f'{BASE_URL}/balance', headers=HEADERS, timeout=10)
r.raise_for_status()
return r.json()
Webhook receiver (Flask)¶
# webhook_server.py
import os, hmac, hashlib, json
from flask import Flask, request, abort
app = Flask(__name__)
processed = set() # в проде — Redis / БД
WEBHOOK_SECRET = os.environ['XHUB_WEBHOOK_SECRET']
@app.route('/webhook/xhub', methods=['POST'])
def webhook():
raw = request.get_data()
sig = request.headers.get('X-Webhook-Signature', '')
timestamp = request.headers.get('X-Webhook-Timestamp', '')
# Подпись: HMAC-SHA256(timestamp + "." + body, secret)
signature_data = timestamp.encode() + b'.' + raw
expected = 'sha256=' + hmac.new(
WEBHOOK_SECRET.encode(), signature_data, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401, 'Invalid signature')
event = json.loads(raw)
dedupe_key = f"{event.get('payment_id') or event.get('withdrawal_id')}:{event.get('status')}"
if dedupe_key in processed:
return 'OK', 200
processed.add(dedupe_key)
if event['event'] == 'payment.status_changed':
if event['status'] == 'paid':
grant_access(event['external_id'], event.get('metadata', {}))
return 'OK', 200
def grant_access(external_id, metadata):
print(f'Granting access for {external_id}')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
Чеклист перед запуском¶
Обязательно¶
- [ ] API-ключи хранятся в переменных окружения (не в коде)
- [ ] Webhook endpoint доступен по HTTPS
- [ ] Webhook подпись верифицируется через HMAC-SHA256
- [ ] Обработка вебхуков идемпотентна (дубликаты не ломают логику)
- [ ] Webhook отвечает за < 10 секунд
- [ ]
Idempotency-Keyпередаётся в каждом POST-запросе (и переиспользуется при ретраях того же заказа) - [ ] Во всех POST-запросах есть
Content-Type: application/json - [ ] Обработаны все статусы платежа (paid, settled, failed, cancelled, expired, amount_mismatch, refunded)
- [ ] Обработчик переживает повторную смену статуса:
expired → paid,amount_mismatch → paid/failed,paid/settled → refunded - [ ] Пометка заказа по
expiredобратима — поздняя оплата не оставит клиента без товара - [ ] Реализован fallback — GET /payments/:id если webhook не пришёл
- [ ] Ожидание вебхука не завязано на «10 минут» — доставка может прийти через часы (см. предохранитель)
Рекомендуется¶
- [ ] Логирование всех входящих вебхуков
- [ ] Мониторинг баланса (GET /balance)
- [ ] Алерты при ошибках вебхуков (5xx ответы)
- [ ] Exponential backoff при 429/500 от API
FAQ¶
Можно ли принимать платежи без юрлица в России?¶
Да, X-Hub выступает агрегатором. Мерчанту нужен только USDT-кошелёк для получения выплат.
Какая минимальная сумма платежа?¶
1 рубль (1.00 ₽).
Как быстро деньги появятся на балансе?¶
Рубли зачисляются мгновенно после оплаты клиентом (статус paid). Конвертация в расчётную валюту происходит на следующий день в 12:00 UTC (T+1 settlement).
Можно ли отменить платёж?¶
Нет — эндпоинта отмены платежа в API нет. После создания платёж либо оплачивается клиентом, либо истекает по expires_at (по умолчанию 15 минут).
Учти: expired — не финал. Если клиент заплатит позже, платёж может подняться в paid в течение 6 часов от создания — см. expired.
Как сделать возврат клиенту?¶
Через поддержку. Публичного API для возвратов нет — эндпоинта, которым ты мог бы инициировать возврат сам, в API не существует. Напиши команде X-Hub с номером платежа.
Пока возврат идёт, платёж отдаётся с "refund_pending": true — и в GET /payments/:id, и в webhook payment.status_changed. Основной status при этом НЕ меняется: платёж всё ещё paid / settled, потому что деньги ещё не вернулись, а возврат может и не состояться. Не жди нового значения статуса — ориентируйся на этот флаг.
Когда возврат исполнен, платёж переходит в refunded, флаг снимается, и приходит refund.completed (по заявочному возврату) либо payment.status_changed со status: "refunded" — см. Какие статусы означают неуспех.
Сколько списывается с баланса. Комиссия X-Hub берётся за успешный платёж и на возврате не пересматривается: возврат её не отменяет и не уменьшает. Отдельного «сбора за операцию возврата» нет — ни в каком виде.
- возврат до ежедневного расчёта: начисления ещё не было, с тебя удерживается только комиссия за платёж;
- возврат после расчёта: начисленное по платежу возвращается, плюс та же комиссия за платёж.
Комиссия считается от полной суммы платежа и при частичном возврате не уменьшается: вернули 300 ₽ из 1000 ₽ — комиссия та же, что и при полном возврате. Ставка — terms.markup_percent в GET /cabinet/me.
Что делать если пришёл amount_mismatch?¶
Клиент заплатил не ту сумму. Средства в безопасности. Свяжитесь с поддержкой X-Hub для разрешения.
Как я получаю выплаты?¶
Автоматически. После подтверждения платежей средства зачисляются на баланс и выплачиваются ежедневным расчётом (settlement) — создавать запросы на вывод не нужно, ручные запросы POST /api/v1/withdrawals не поддерживаются (410 WITHDRAWAL_DISCONTINUED). Баланс виден в GET /api/v1/balance и в кабинете.
Webhook не приходит — что делать?¶
- Убедитесь что URL доступен по HTTPS и не отвечает редиректом —
3xxсчитается провалом доставки. - Проверьте что сервер отвечает
200за < 10 секунд. - Убедитесь, что в кабинете заданы и
webhook_url, иwebhook_secret. Пока пары нет, события копятся и не отправляются вовсе; как только конфигурация полная — уезжают сразу. - Дайте времени больше, чем кажется. Быстрая лестница — 10 попыток с суммарными паузами ~8.5 минут, но если ваш endpoint падал, предохранитель добавляет паузы, которые попытками не считаются, и доставка может прийти спустя часы. А денежное уведомление, отбитое вашим
4xx, ретраится по медленному графику до 41 часа. - Как fallback — используйте
GET /payments/:id. Это единственный способ узнать статус здесь и сейчас; вебхук ждать не нужно. - Если событие всё-таки потеряно окончательно — напишите в X-Hub, доставку можно переотправить вручную.
Можно ли использовать HTTP вместо HTTPS для вебхуков?¶
Нет, только HTTPS в production. Используйте сервисы вроде ngrok для локальной разработки.
Поддержка¶
Вопросы, проблемы — пиши в ваш чат с командой X-Hub.