Документация¶
X-Hub — платёжный шлюз для B2B-мерчантов. Принимаем рубли от российских клиентов через СБП, рассчитываемся с вами в USDT.
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 — они появляются только в твоём браузере, на твоей стороне. Это соответствует индустриальным стандартам (Stripe, PayPal, Adyen) и упрощает compliance-аудит: ты можешь честно подтвердить, что секреты никогда не покидали твою инфраструктуру.
Ротация¶
api_secret— ротируешь сам в том же разделе «API ключи» кнопкой «Перевыпустить». Старый секрет сразу перестаёт работать.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'и копятся в PENDING и не отправляются. Как только ты вставишь URL — прилетят все накопленные события в течение ~5 секунд. - URL можно менять в любой момент — изменение не требует реcтарта интеграции.
- Подробнее про формат webhook payload — в разделе Webhook ниже, про проверку подписи — в Verify signature.
Переход sandbox → production¶
Sandbox-аккаунт и production — отдельные аккаунты с разными ключами:
- В sandbox тестируешь интеграцию под ключами
xh_test_...(без реальных денег). - Когда готов к боевому запуску — X-Hub создаст отдельный production-аккаунт (на тот же или другой email — на твой выбор). Зайдёшь в кабинет, сгенерируешь
xh_live_...ключи, подставишь их в код вместо test-ключей.
Подробнее про sandbox — в разделе 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"}
}
В заголовках 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, но платежи симулируются админом X-Hub.
Как получить¶
Попросите админа X-Hub создать sandbox-аккаунт. После этого на ваш 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/{id} (заглушка) |
Реальная ссылка СБП |
| Смена статуса | Админ вручную через кабинет | Автоматически (callback) |
| Реальные USDT | Нет, только в ledger | Да |
| Settlement (T+1) | Пропускается | Выполняется |
| Anomaly detection | Пропускается | Активно |
| Webhook мерчанту | Присылается | Присылается |
sandbox в API response |
true |
false (всегда присутствует) |
sandbox в webhook payload |
true |
поле отсутствует |
Чего нельзя в sandbox¶
POST /api/v1/exchanges(USDT→RUB) — вернётся400 SANDBOX_NOT_SUPPORTEDPOST /api/v1/kyc/*— вернётся400 SANDBOX_NOT_SUPPORTED
Flow¶
- Создаёте платёж через API как в prod:
POST /api/v1/payments - В ответе видите
"sandbox": trueи fakepayment_url - Через несколько секунд sandbox автоматически дёргает webhook (по умолчанию — happy-path
paidчерез 30 секунд). - Ваш webhook endpoint получает payload и обрабатывает как обычно.
- Проверяете ваш обработчик, баланс, интеграцию.
Magic-токены — сценарии ошибок¶
Чтобы протестировать не только happy-path, передавайте ключевое слово
в external_id или description. Sandbox опознаёт его и
автоматически триггерит нужный статус:
| Ключевое слово | Delay | Итог |
|---|---|---|
| (ничего) | 30 сек | paid ← default |
xhub_test_paid |
5 сек | paid (быстро) |
xhub_test_fail |
5 сек | failed |
xhub_test_expire |
60 сек | expired |
xhub_test_mismatch |
5 сек | amount_mismatch (фактическая сумма = amount_rub - 1) |
xhub_test_manual |
— | ничего не происходит, админ X-Hub кликает кнопку вручную |
Поиск токена — case-insensitive и word-boundary (regex \b<token>\b): токен должен быть отдельным «словом», отделённым пробелом, дефисом или пунктуацией. Например description: "Заказ #42 — xhub_test_fail" сработает, а description: "prexhub_test_failpost" — нет (токен склеен с другими символами).
Ручной override¶
Админ X-Hub в любой момент может вручную дёрнуть статус через кнопки в кабинете (оплатить / ошибка / истечь / расхождение). Если ручной клик сработал раньше авто-таймера — авто-симуляция пропускается (idempotent).
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.
Переход в production¶
Не мигрируем sandbox-мерчанта в prod. Вместо этого:
- Мерчант подтверждает что интеграция работает в sandbox
- Админ X-Hub создаёт новый prod-аккаунт (на тот же email c
+prodalias или на другой) - На email приходит magic-link → мерчант заходит в кабинет → нажимает «Сгенерировать API-ключи» → получает
xh_live_...ключи - Мерчант меняет ключи в своём коде:
xh_test_→xh_live_ - Sandbox-аккаунт остаётся активным — для будущих тестов
Почему так: чистый balance/ledger/аналитика в prod + audit история перехода.
Аутентификация¶
Все запросы к 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).
Как работает¶
- При первом вызове — создаётся новый ресурс, ключ сохраняется в X-Hub на 24 часа (после чего удаляется)
- Повторный вызов с тем же ключом вернёт тот же результат (без дубликата). Статус-код при создании —
201, при idempotent-повторе —200 - Тело запроса при повторе не сверяется — возвращается ответ первого вызова. Генерируй новый
Idempotency-Keyдля каждой логически новой операции - Ключ должен быть уникальным в рамках одного мерчанта; после 24 часов ключ можно переиспользовать
Зачем¶
Предотвращает двойное списание при сетевых сбоях. Если твой POST упал по таймауту — ретрай с тем же ключом безопасен.
Плохо и хорошо¶
// ❌ НЕПРАВИЛЬНО: генерируем UUID на каждый retry
async function createPayment(amount) {
return fetch('...', {
headers: { 'Idempotency-Key': crypto.randomUUID() }, // каждый retry = новый UUID
});
}
// ✅ ПРАВИЛЬНО: UUID генерируется один раз на операцию
async function createPayment(orderId, amount) {
const idempotencyKey = `order-${orderId}-${amount}`; // детерминированный
return fetch('...', {
headers: { 'Idempotency-Key': idempotencyKey },
});
}
Rate Limiting¶
- 60 запросов в минуту на
api_key - При превышении —
429 Too Many Requests
Заголовки ответа (отдаём оба семейства — legacy и draft-6 standard):
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1776166800
RateLimit-Limit: 60
RateLimit-Remaining: 42
RateLimit-Reset: 38
X-RateLimit-Reset— Unix timestamp (секунды с эпохи), когда окно сбрасываетсяRateLimit-Reset(draft-6) — секунды до сброса окна
Если получил 429 — подожди до X-RateLimit-Reset (или RateLimit-Reset секунд) и ретрай.
Платежи¶
Жизненный цикл¶
stateDiagram-v2
[*] --> pending
pending --> processing
pending --> paid: быстрая обработка
pending --> expired
pending --> failed
processing --> paid
processing --> failed
processing --> amount_mismatch
processing --> expired
paid --> settled
paid --> refunded: банк вернул клиенту до settlement
settled --> refunded: банк вернул клиенту после settlement
amount_mismatch --> paid: admin resolve
expired --> paid: поздняя оплата
| Статус | Значение |
|---|---|
pending |
Создан, ждёт оплаты. Истекает через 15 минут |
processing |
Клиент начал оплату (сканировал QR) |
paid |
Платёж подтверждён банком. USDT в pending_rub |
settled |
Прошёл settlement. USDT на available_balance |
amount_mismatch |
Клиент заплатил не ту сумму. Требует разбора |
expired |
15 минут истекли, клиент не оплатил |
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: client_phone is required for this account. |
Ответ 201¶
{
"id": "pay_550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"amount_rub": "500.00",
"actual_amount_rub": 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,
"metadata": {"user_id": "usr_abc", "plan": "premium"}
}
Что делать: редирект клиента на 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— временные метки
Ошибка 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¶
Клиент не оплатил за 15 минут. Платёж финальный, повторить нельзя (создавай новый).
failed¶
Банк отказал (подозрение на фрод, недостаток средств). Финальный статус.
Обработка отмен, истечений и ошибок¶
Не каждый платёж заканчивается успехом — часть клиентов закроет окно, часть не успеет за 15 минут, часть получит отказ банка, а небольшая доля уже оплаченных платежей может «вернуться» с возвратом. Этот раздел — про то, как корректно обработать каждую ветку у себя.
Какие статусы означают неуспех¶
| Status | Что значит | Деньги списаны? | Что делать |
|---|---|---|---|
cancelled |
Платёж отменён до зачисления (клиент закрыл окно, отменил в банке) | Нет | Просто пометить как failed в своей системе. Дать клиенту создать новый платёж. |
expired |
15-мин TTL истёк, клиент не оплатил | Нет | Дать клиенту повторить — нужен новый платёж (этот терминальный). |
failed |
Банк отказал / antifraud / тех. ошибка | Нет | Уведомить клиента, предложить другой способ. |
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':
// клиент не оплатил — не выдавать, разрешить повтор
markOrderAsAbandoned(event.external_id);
break;
case 'failed':
// банк отказал — алерт клиенту, предложить другой способ
notifyClientOfFailure(event.external_id, event.failure_reason);
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.get('failure_reason'))
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['failure_reason'] ?? null);
} 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. См. Верификация подписи. - Финальные статусы.
settled/cancelled/expired/failed— терминальные, больше не меняются.paidможет перейти вrefunded(минуты-часы-дни после).amount_mismatch— временный, всегда сменится наpaidилиfailedпосле разбора. - Узнавай о сбоях шлюза. Подпишись на
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 секунд
- Всё кроме 2xx считается ошибкой → ретрай
- Идемпотентная обработка (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"}
}
Пример 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 |
Любое изменение статуса платежа |
withdrawal.status_changed |
Любое изменение статуса выплаты |
exchange.status_changed |
Любое изменение статуса обмена (RUB↔USDT) |
gateway.status_changed |
Изменение статуса платёжного шлюза X-Hub: operational / degraded / down |
Смотри payment.status / withdrawal.status / exchange.status чтобы понять текущее состояние. X-Webhook-Timestamp — ISO-8601 в UTC.
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' }),
(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');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
if (event.event === 'payment.status_changed' && event.status === 'paid') {
await grantAccessTo(event.external_id);
}
res.status(200).send('OK');
}
);
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 не ответил 200, X-Hub ретраит с exponential backoff:
| Попытка | Задержка после предыдущей |
|---|---|
| 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 минут. Потом webhook помечается как FAILED и требует ручного разбора.
Идемпотентность¶
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 |
Зарезервировано под активный withdrawal |
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.3385",
"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). Включает все комиссии/наценку 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.05 = 83.3385
merchant_usdt = 1000 / 83.3385 = 11.99919 USDT <-- тебе
xhub_fee = (1000 / 79.37) - 11.99919 = 0.6 USDT (все комиссии X-Hub)
Выплаты (Withdrawals)¶
Вывод USDT с баланса мерчанта на внешний кошелёк.
Минимальная сумма: 10 USDT
Создать выплату — POST /withdrawals¶
curl -X POST https://api.x-hub.online/api/v1/withdrawals \
-H "X-Api-Key: ..." \
-H "X-Api-Secret: ..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_usdt": "500.00000000",
"wallet_address": "TYourTronWalletHereTRC20Abc123",
"network": "TRC20"
}'
Body¶
| Поле | Тип | Обяз. | Описание |
|---|---|---|---|
amount_usdt |
string | ✓ | Decimal, 8 знаков. Минимум "10.00000000", максимум "10000.00000000" (лимит может быть индивидуально увеличен — свяжись с X-Hub) |
wallet_address |
string | ✓ | Адрес получателя (до 256 символов) |
network |
string | Сейчас только "TRC20". По умолчанию "TRC20" — можно не передавать |
Ответ 201¶
{
"id": "wd_abc123-...",
"status": "pending",
"amount_usdt": "500.00000000",
"wallet_address": "TYour...",
"network": "TRC20",
"tx_hash": null,
"created_at": "2026-04-14T13:00:00Z",
"completed_at": null
}
available_usdt мерчанта сразу уменьшается. Сумма перетекает в reserved_usdt до завершения.
Если баланса не хватает — 409 INSUFFICIENT_BALANCE.
Жизненный цикл выплаты¶
stateDiagram-v2
[*] --> pending
pending --> processing
pending --> rejected
processing --> completed
processing --> rejected
| Статус | |
|---|---|
pending |
Принято, ждёт ручного одобрения команды X-Hub |
processing |
Отправляется в сеть TRC20 |
completed |
Транзакция подтверждена, tx_hash в ответе |
rejected |
Отказ (сумма вернётся в available_usdt) |
Время обработки: обычно в течение 24 часов в рабочие дни.
Получить выплату — GET /withdrawals/:id¶
curl https://api.x-hub.online/api/v1/withdrawals/wd_abc123-... \
-H "X-Api-Key: ..." -H "X-Api-Secret: ..."
Ответ:
{
"id": "wd_abc123-...",
"status": "completed",
"amount_usdt": "500.00000000",
"wallet_address": "TYour...",
"network": "TRC20",
"tx_hash": "c1a2b3...",
"created_at": "2026-04-14T13:00:00Z",
"completed_at": "2026-04-14T13:05:00Z"
}
Список выплат — GET /withdrawals¶
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"
}
Верификация подписи — как для платежей.
Обмены (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" \
-d '{
"amount_usdt": "100.00",
"client_phone": "+79991234567",
"client_bank_id": "bank100000000111",
"external_id": "EXCH-2026-001"
}'
Параметры¶
| Поле | Обязательно | Описание |
|---|---|---|
amount_usdt |
да | Сумма USDT которую клиент пришлёт. Минимум чтобы итоговый RUB был ≥ 1000 |
client_phone |
да | Телефон клиента, формат +79991234567 |
client_bank_id |
да | ID банка СБП, в который зачислять рубли клиенту. Формат: bank<цифры>. Справочник доступных банков запрашивайте у команды X-Hub — на MVP передаётся списком, публичный endpoint будет в следующей версии API. Примеры: bank100000000111 (Сбер), bank100000000008 (Альфа-Банк). |
external_id |
нет | Ваш ID обмена (до 40 символов, логируется в реквизитах СБП) |
idempotency_key |
нет | UUID для повторных попыток. Повторный POST с тем же ключом вернёт тот же обмен |
Ответ (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 | Причина |
|---|---|---|
FEATURE_NOT_ENABLED |
400 | У вас не подключён USDT→RUB обмен — свяжитесь с X-Hub |
CLIENT_NOT_REGISTERED |
422 | Клиент не прошёл KYC — запустите /kyc/register |
CLIENT_BLOCKED |
403 | Клиент заблокирован платёжной системой |
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 | Обмен с этим 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 отклонён:
Получить 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 (подтверждение адреса) |
Ответ¶
После регистрации — периодически проверяй статус через 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 | INVALID_JSON |
Тело запроса — битый JSON | Исправь сериализацию |
| 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 не нужен, просто создавай платёж |
| 400 | SANDBOX_NOT_SUPPORTED |
Эндпоинт недоступен в sandbox (например /exchanges, /kyc) |
В sandbox используй только /payments и /withdrawals |
| 400 | AMOUNT_TOO_LOW |
Сумма меньше минимальной для операции (пример: обмен < 1000 ₽) | Увеличь сумму |
| 400 | AMOUNT_TOO_HIGH |
Сумма превышает максимум | Разбей на несколько операций или свяжись с X-Hub |
| 400 | CLIENT_NOT_REGISTERED |
Клиент не прошёл KYC-регистрацию для USDT→RUB | Сначала зарегистрируй клиента через /kyc/register |
| 400 | CLIENT_BLOCKED |
Аккаунт клиента заблокирован | Свяжись с X-Hub |
| 401 | UNAUTHORIZED |
Неверные X-Api-Key / X-Api-Secret |
Проверь ключи |
| 403 | IP_NOT_WHITELISTED |
IP-адрес не в whitelist мерчанта | Добавь IP в настройках или свяжись с X-Hub |
| 400 | MISSING_IDEMPOTENCY_KEY |
На POST /payments, /withdrawals, /exchanges не прислан Idempotency-Key |
Добавь UUID-v4 заголовок |
| 404 | PAYMENT_NOT_FOUND |
Платёж не существует / чужой | Проверь ID |
| 404 | WITHDRAWAL_NOT_FOUND |
Выплата не существует / чужая | Проверь ID |
| 404 | NOT_FOUND |
Обмен не существует / чужой | Проверь ID |
| 409 | INSUFFICIENT_BALANCE |
Мало USDT для выплаты | Дождись settlement или уменьши сумму |
| 409 | EXCHANGE_IN_PROGRESS |
Обмен уже в обработке | Дождись финального статуса |
| 409 | INVALID_STATUS |
Операция невалидна для текущего статуса (напр. попытка отменить уже завершённый обмен) | Проверь статус перед действием |
| 409 | IDEMPOTENCY_TERMINAL |
Повтор запроса с тем же idempotency_key после финального статуса |
Используй новый ключ |
| 502 | RATE_INVALID |
Временная ошибка получения курса для обмена | Ретрай через 30-60 сек |
| 503 | PROVIDER_UNAVAILABLE |
Внешняя платёжная система временно недоступна | Ретрай через 30-60 сек |
| 503 | PROVIDER_NOT_CONFIGURED |
Платёжная система не настроена на инстансе | Свяжись с X-Hub |
| 400 | FEATURE_NOT_ENABLED |
Опция не подключена на твоём аккаунте (например USDT→RUB или KYC) | Свяжись с X-Hub для подключения |
| 413 | PAYLOAD_TOO_LARGE |
Тело запроса превысило лимит | Уменьши metadata (до 4KB) и размер body |
| 429 | RATE_LIMIT_EXCEEDED |
Превышен лимит 60 req/min | Жди до X-RateLimit-Reset |
| 500 | INTERNAL_ERROR |
Серверная ошибка | Ретрай через 30 сек, если повторяется — пиши команде |
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 | да | Жди X-RateLimit-Reset |
| 500 | да | Exponential backoff: 1s, 2s, 4s, 8s, max 5 попыток |
| 502 | да | Exponential backoff: 5s, 15s, 60s |
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-запросе - [ ] Обработаны все статусы платежа (paid, failed, expired, amount_mismatch)
- [ ] Реализован fallback — GET /payments/:id если webhook не пришёл
Рекомендуется¶
- [ ] Логирование всех входящих вебхуков
- [ ] Мониторинг баланса (GET /balance)
- [ ] Алерты при ошибках вебхуков (5xx ответы)
- [ ] Exponential backoff при 429/500 от API
FAQ¶
Можно ли принимать платежи без юрлица в России?¶
Да, X-Hub выступает агрегатором. Мерчанту нужен только USDT-кошелёк для получения выплат.
Какая минимальная сумма платежа?¶
1 рубль (1.00 ₽).
Как быстро деньги появятся на балансе?¶
Рубли зачисляются мгновенно после оплаты клиентом (статус paid). Конвертация в USDT происходит на следующий день в 12:00 UTC (T+1 settlement).
Можно ли отменить платёж?¶
Нет. После создания платёж либо оплачивается клиентом, либо истекает через 15 минут. Возвраты обрабатываются через поддержку.
Что делать если пришёл amount_mismatch?¶
Клиент заплатил не ту сумму. Средства в безопасности. Свяжитесь с поддержкой X-Hub для разрешения.
Как часто можно выводить USDT?¶
Без ограничений по частоте. Минимальная сумма вывода — 10 USDT. Обработка обычно в течение 24 часов в рабочие дни.
Webhook не приходит — что делать?¶
- Убедитесь что URL доступен по HTTPS
- Проверьте что сервер отвечает 200 за < 10 секунд
- X-Hub ретраит до 10 раз в течение ~8.5 минут (exponential backoff)
- Как fallback — используйте GET /payments/:id для проверки статуса
Можно ли использовать HTTP вместо HTTPS для вебхуков?¶
Нет, только HTTPS в production. Используйте сервисы вроде ngrok для локальной разработки.
Поддержка¶
Вопросы, проблемы — пиши в ваш чат с командой X-Hub.