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

Документация

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)

Как получить ключи

  1. Свяжись с командой X-Hub через контакты, которые передал тебе менеджер. Обсудим тариф, настроим нужные опции (приём RUB / обмен USDT→RUB / KYC) и создадим твой аккаунт.

  2. На твой email прилетит magic-link от X-Hub. Кликаешь → попадаешь в свой кабинет на https://app.x-hub.online.

  3. Генерируешь свои ключи сам. В кабинете раздел «API ключи» → большая кнопка «Сгенерировать API-ключи». Нажимаешь и получаешь один раз все три секрета:

    • api_key (префикс xh_test_ для sandbox или xh_live_ для production)
    • api_secret
    • webhook_secret
  4. Сразу сохраняешь в менеджер паролей (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 будет присылать события платежей:

  1. В том же разделе «API ключи» кабинета найди блок Webhook URL
  2. Нажми «Изменить» → вставь URL своего endpoint'а (только HTTPS публичный)
  3. Сохрани

Важно:

  • Пока 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_SUPPORTED
  • POST /api/v1/kyc/* — вернётся 400 SANDBOX_NOT_SUPPORTED

Flow

  1. Создаёте платёж через API как в prod: POST /api/v1/payments
  2. В ответе видите "sandbox": true и fake payment_url
  3. Через несколько секунд sandbox автоматически дёргает webhook (по умолчанию — happy-path paid через 30 секунд).
  4. Ваш webhook endpoint получает payload и обрабатывает как обычно.
  5. Проверяете ваш обработчик, баланс, интеграцию.

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. Вместо этого:

  1. Мерчант подтверждает что интеграция работает в sandbox
  2. Админ X-Hub создаёт новый prod-аккаунт (на тот же email c +prod alias или на другой)
  3. На email приходит magic-link → мерчант заходит в кабинет → нажимает «Сгенерировать API-ключи» → получает xh_live_... ключи
  4. Мерчант меняет ключи в своём коде: xh_test_xh_live_
  5. 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, не в git
  • webhook_secretтолько на твоём бэкенде, для проверки HMAC подписи webhook'ов

Правила хранения

  • Никогда не клади в git
  • Никогда не отдавай клиенту (браузер/мобилка)
  • Не пересылай в чатах
  • Логируй только api_key, не api_secret

Idempotency-Key

Обязательно для всех POST запросов, которые создают ресурсы.

Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

Формат: 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.

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

  1. Эквайер возвращает RUB клиенту со счёта X-Hub
  2. X-Hub переводит платёж в status: refunded (терминальный)
  3. Соответствующая сумма USDT списывается с твоего available_balance
  4. Если выплата за этот платёж ещё PENDING в твоём кабинете — она автоматически пересчитывается (уменьшается)
  5. Если выплата уже была PAID (USDT уже у тебя в кошельке) — мы свяжемся для clawback'а (обычно: вычитаем из следующей выплаты)
  6. Прилетит webhook payment.status_changed со status: refunded и previous_status: paid или settled

Твоя система должна:

  • Идемпотентно обрабатывать refunded (это другое событие чем cancelled — там денег и не было)
  • Откатить выдачу товара/услуги клиенту если возможно
  • Не считать refunded доходом

Создать платёж — POST /payments

Headers

Header Обяз.
X-Api-Key
X-Api-Secret
Idempotency-Key UUID
Content-Type application/json

Body

{
  "amount_rub": "500.00",
  "external_id": "order-12345",
  "payment_method": "sbp",
  "description": "VPN подписка 1 месяц",
  "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

Пример:

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

Ответ

{
  "data": [
    { "id": "pay_...", "status": "paid", "amount_rub": "500.00" }
  ],
  "pagination": {
    "page": 1,
    "per_page": 50,
    "total": 245,
    "total_pages": 5
  }
}

Edge cases

amount_mismatch

Клиент заплатил больше или меньше указанного. В webhook и GET:

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

Что делать: связаться с клиентом / X-Hub команда поможет разрулить. Деньги не теряются — они на нашем транзитном счёте.

Возврат в PAID

После разбора админом X-Hub платёж может быть переведён из amount_mismatch обратно в paid или в failedприлетит ещё один webhook payment.status_changed с новым статусом. Твой обработчик должен быть идемпотентным и готов к такому переходу.

expired

Клиент не оплатил за 15 минут. Платёж финальный, повторить нельзя (создавай новый).

failed

Банк отказал (подозрение на фрод, недостаток средств). Финальный статус.


Обработка отмен, истечений и ошибок

Не каждый платёж заканчивается успехом — часть клиентов закроет окно, часть не успеет за 15 минут, часть получит отказ банка, а небольшая доля уже оплаченных платежей может «вернуться» с возвратом. Этот раздел — про то, как корректно обработать каждую ветку у себя.

Какие статусы означают неуспех

Status Что значит Деньги списаны? Что делать
cancelled Платёж отменён до зачисления (клиент закрыл окно, отменил в банке) Нет Просто пометить как failed в своей системе. Дать клиенту создать новый платёж.
expired 15-мин TTL истёк, клиент не оплатил Нет Дать клиенту повторить — нужен новый платёж (этот терминальный).
failed Банк отказал / antifraud / тех. ошибка Нет Уведомить клиента, предложить другой способ.
refunded Платёж был успешен, потом провайдер вернул деньги клиенту Да, но мы списали обратно с твоего баланса Критично — пересчитать выдачу товара/услуги клиенту (отозвать доступ или выставить долг).
amount_mismatch Фактическая сумма не совпала с ожиданием Деньги на удержании Дождаться разбора админом X-Hub — прилетит следующий webhook с новым статусом (paid или failed).

refundedcancelled

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 проблемы, мы сами пришлём webhook status: 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 отправляется только при переходе через границу operationaldegraded/down. Промежуточные смены degradeddown не дублируются. После отправки 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 — используются для проверки подписи

Подпись вычисляется как:

HMAC-SHA256(key: webhook_secret, data: timestamp + "." + raw_body)

Где 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

Текущий баланс мерчанта.

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

Ответ

{
  "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 — только для справки/калькулятора.

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

Ответ

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

GET /api/v1/withdrawals?page=1&per_page=20&status=completed

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

Что делать дальше

  1. Покажите клиенту crypto_deposit_address и сумму amount_usdt
  2. Клиент присылает USDT на адрес (сеть tron)
  3. Банковская сеть определяет поступление → X-Hub фиксирует факт оплаты
  4. X-Hub инициирует SBP-выплату клиенту на телефон+банк
  5. Клиент получает amount_rub на карту
  6. Вы получаете 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_atnull пока обмен не в финальном статусе.

Список обменов — GET /exchanges

GET /exchanges?page=1&per_page=20&status=completed

Параметры: - 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:

{ "status": "cancelled" }

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

Поле directionusdt_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 предоставляет готовую страницу верификации. Мерчант перенаправляет своего клиента туда, клиент заполняет форму (телефон, селфи, паспорт, адрес), после верификации возвращается на сайт мерчанта.

https://app.x-hub.online/kyc?phone=+79001234567&redirect=https://your-site.com/kyc-done

Query-параметры:

Параметр Обяз. Описание
phone Телефон клиента в формате +7XXXXXXXXXX (можно не передавать — клиент введёт сам)
redirect URL, куда вернуть клиента после завершения. Только HTTPS, только same-origin с твоим доменом. Если не указан — редирект на /

Плюсы: ничего не надо писать на стороне мерчанта. Минусы: клиент уходит с твоего сайта, меньше контроля над UX.

Способ 2: API-first (встроенный в UI мерчанта)

Мерчант делает KYC-форму внутри своего приложения (свой UI, свой дизайн), а вызывает наши API для загрузки документов и регистрации. Клиент не покидает сайт мерчанта.

Flow (со стороны backend мерчанта):

  1. POST /v1/kyc/check → проверить, нужен ли KYC и не пройден ли уже;
  2. POST /v1/kyc/upload-url × 3 → получить signed URL на селфи / паспорт / адрес;
  3. Фронтенд мерчанта загружает файлы напрямую на upload URL (PUT, без прохождения через backend);
  4. POST /v1/kyc/register → передать file_key'и + phone;
  5. Периодически POST /v1/kyc/check → дождаться status: verified.

Плюсы: полный контроль UX и брендинга. Минусы: больше кода на стороне мерчанта, своя форма загрузки файлов.

Детали всех endpoints — ниже.

Когда нужен KYC

Если при создании платежа вы получаете ошибку:

{ "error": { "code": "KYC_REQUIRED", "message": "..." } }

— значит для вашего аккаунта настроена обязательная 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 не требуется (для вашего аккаунта не настроен):

{"status": "not_required"}

KYC верифицирован:

{"status": "verified", "accountNumber": "..."}

KYC не пройден (нужна регистрация):

{"status": "required", "kyc_url": "https://..."}

KYC в обработке:

{"status": "processing"}

KYC отклонён:

{"status": "failed", "error": "..."}

Получить 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": "https://storage.example.com/...",
  "file_key": "kyc/abc123..."
}

Загрузи файл по 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 (подтверждение адреса)

Ответ

{"status": "processing"}

После регистрации — периодически проверяй статус через 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.

pip install 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 не приходит — что делать?

  1. Убедитесь что URL доступен по HTTPS
  2. Проверьте что сервер отвечает 200 за < 10 секунд
  3. X-Hub ретраит до 10 раз в течение ~8.5 минут (exponential backoff)
  4. Как fallback — используйте GET /payments/:id для проверки статуса

Можно ли использовать HTTP вместо HTTPS для вебхуков?

Нет, только HTTPS в production. Используйте сервисы вроде ngrok для локальной разработки.


Поддержка

Вопросы, проблемы — пиши в ваш чат с командой X-Hub.