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

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

X-Hub — платёжный шлюз для B2B-мерчантов. Принимаем рубли от российских клиентов через СБП, рассчитываемся с вами по фиксированному курсу на следующий рабочий день.

Base URL: https://api.x-hub.online/api/v1

Что получает мерчант

  • Приём платежей СБП без юр.лица в РФ
  • USDT → RUB обмены для обменников (клиент шлёт USDT, получает RUB на карту)
  • Автоматические ежедневные выплаты USDT (TRC20) на твой кошелёк
  • Webhook-уведомления о каждом изменении статуса
  • Идемпотентность, HMAC-подпись, ретраи из коробки
  • Markup-курс фиксируется на сутки (T+1 settlement)

Ключевые концепции

Термин Что это
Payment Заявка на приём рублей. Создаётся тобой, оплачивается клиентом через СБП
Exchange Обмен USDT → RUB. Клиент шлёт USDT, получает RUB на карту по СБП
Webhook HTTP POST от X-Hub на твой URL при изменении статуса платежа/обмена
Settlement Ежедневная конвертация pending RUB → available USDT по фиксированному курсу
Withdrawal Автоматическая выплата USDT с баланса на твой кошелёк — происходит сама, без запросов с твоей стороны
Markup Твой процент сверх биржевого курса (договаривается при подключении)

Быстрый старт

Создадим первый платёж и получим webhook за 5 минут.

Что нужно

  • api_key — публичный идентификатор. Строка вида xh_live_... (продакшн) или xh_test_... (sandbox)
  • api_secret — секрет, хранится на твоём бэкенде
  • webhook_secret — для верификации webhook'ов
  • Публичный URL для webhook'ов (HTTPS, работающий 24/7)

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

  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 хранится на нашей стороне: он нужен серверу X-Hub, чтобы подписывать каждый webhook.

Ключи у тебя раздельные для теста и боя, а webhook_secret — общий

api_key + api_secret живут в двух независимых слотах: тестовом и боевом. Когда ты генерируешь ключи второго слота, webhook_secret не выдаётся повторно — он один на оба режима, не меняется, и в открытом виде показывается ровно один раз, при самой первой генерации. Если ты его потерял — перевыпусти его отдельно (см. ниже); повторно «подсмотреть» существующий нельзя. Так сделано намеренно: этим секретом подписываются уведомления об оплате, и любой, у кого есть доступ к твоему кабинету, иначе мог бы получить его, просто нажав «сгенерировать ключи» в тестовом режиме.

Ротация

  • api_secret — ротируешь сам в том же разделе «API ключи» кнопкой «Перевыпустить». Старый секрет сразу перестаёт работать. Ротируется секрет того слота, в котором ты сейчас работаешь; при подозрении на утечку боевого ключа можно (и нужно) сначала перевести аккаунт в тестовый режим — приём боевых платежей остановится, — а потом сменить именно боевой секрет.
  • webhook_secret — перевыпускается отдельной кнопкой в блоке Webhook. Старый секрет сразу перестаёт работать, поэтому меняй его синхронно со своим бэкендом (лучше в окно низкого трафика).
  • api_key — стабильный публичный идентификатор. Ротация не предусмотрена (для смены нужен новый merchant-аккаунт).
  • Emergency reset — если потерял доступ к кабинету, попроси X-Hub через поддержку перевыпустить api_secret — тебе передадут его разово через защищённый канал. Уточни, какой слот меняем: тестовый или боевой.

Храни api_secret и webhook_secret только на бэкенде

Никогда не отдавай клиенту/браузеру. При компрометации — перевыпусти через кабинет.

Настройка Webhook URL

После генерации ключей укажи URL твоего сервера, куда X-Hub будет присылать события платежей:

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

Важно:

  • Пока webhook_url не указан (или не сгенерирован webhook_secret), webhook'и копятся в PENDING и не отправляются. Как только конфигурация становится полной (URL + секрет), накопленные события отпускаются сразу — в ближайшем цикле доставки, а не через отложенную паузу. Новые события после этого доставляются в течение ~5 секунд.
  • Если webhook так и не настроен, накопленные события живут 30 суток, после чего помечаются FAILED и не доставляются уже никогда.
  • URL можно менять в любой момент — изменение не требует реcтарта интеграции.
  • Подробнее про формат webhook payload — в разделе Webhook ниже, про проверку подписи — в Verify signature.

Переход sandbox → production

У тебя один аккаунт и два слота ключей: тестовые xh_test_... и боевые xh_live_...:

  • В sandbox тестируешь интеграцию под ключами xh_test_... (без реальных денег).
  • Когда готов к боевому запуску — сам переключаешь режим в кабинете кнопкой «Активировать боевой режим», генерируешь xh_live_... ключи и подставляешь их в код вместо test-ключей.
  • Запрос с ключом не-активного режима вернёт 401 с кодом MODE_MISMATCH и понятным сообщением, какой режим сейчас активен.

Подробнее — в разделе Sandbox ниже.

Сетевые требования

Входящие webhook'и (X-Hub → твой сервер)

X-Hub отправляет webhook'и с фиксированного IP 72.56.237.132. Если твой webhook-endpoint закрыт файрволом — добавь этот IP в whitelist.

Исходящие запросы (твой сервер → X-Hub)

Если X-Hub выдал тебе IP-whitelist (не всем мерчантам — по запросу), то запросы с других IP получат 403 IP_NOT_WHITELISTED. По умолчанию whitelist не активен.

Production endpoint: https://api.x-hub.online | Sandbox: тот же URL, разный префикс ключа.

1. Создать платёж

curl -X POST https://api.x-hub.online/api/v1/payments \
  -H "X-Api-Key: xh_test_abc123..." \
  -H "X-Api-Secret: YOUR_API_SECRET" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_rub": "500.00",
    "external_id": "order-12345",
    "payment_method": "sbp",
    "description": "VPN подписка 1 месяц"
  }'

Ответ:

{
  "id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "amount_rub": "500.00",
  "payment_url": "https://paywidget.org/abc-def-ghi",
  "expires_at": "2026-04-14T13:15:00Z"
}

Что дальше: редирект клиента на payment_url или встраивание iframe.

2. Клиент оплачивает

Клиент видит QR-код СБП, оплачивает через приложение банка. X-Hub получает подтверждение, меняет статус на paid.

3. Получить webhook

X-Hub делает POST на твой webhook_url с payload:

{
  "event": "payment.status_changed",
  "payment_id": "pay_550e8400-...",
  "external_id": "order-12345",
  "status": "paid",
  "previous_status": "processing",
  "amount_rub": "500.00",
  "actual_amount_rub": null,
  "amount_usdt": null,
  "payment_method": "sbp",
  "timestamp": "2026-04-14T13:05:12Z",
  "metadata": {"user_id": "usr_abc"}
}

В payload payment.status_changed есть поле refund_pending (true, пока по платежу идёт незавершённый возврат; присутствует всегда).

У неуспешного списания в payload приходит failure_reasonпочему не прошло. От причины зависит, что делать: не хватило денег → повторить через день-два и подписку не отменять; согласие на автосписание отозвано → просить привязать заново. Поле приходит только у status: "failed"; у остальных статусов его в payload нет. Коды и действия →

У рекуррентного списания в payload дополнительно приходят два поля: "subscription_id": "sub_..." — подписка (и её пользователь), и "period_ref": "2026-08" — период, который это списание занимает. По ним событие сопоставляется с подпиской и периодом без обращения к API. Оба поля приходят при любом изменении статуса списания: оплата, отказ, истечение, расчёт — и в песочнице тоже. У обычных (не подписочных) платежей этих полей в payload нет.

В заголовках X-Webhook-Signature и X-Webhook-Timestamp — HMAC-SHA256 от timestamp + "." + body, ключ = твой webhook_secret. Как проверить подпись →

4. Проверить статус вручную

curl https://api.x-hub.online/api/v1/payments/pay_550e8400-... \
  -H "X-Api-Key: xh_test_abc123..." \
  -H "X-Api-Secret: YOUR_API_SECRET"

Sandbox (тестовый режим)

Sandbox позволяет протестировать интеграцию до реальных платежей — без реальных денег и вызовов внешних платёжных систем. Все endpoints работают как в prod, но платежи симулируются: по умолчанию автоматически (magic-токены, см. ниже), а вручную — кнопками на песочной странице оплаты, которая открывается по payment_url. Админ X-Hub для этого не нужен.

Как получить

Попросите админа X-Hub создать аккаунт — новый аккаунт стартует в тестовом режиме. После этого на ваш email придёт magic-link → зайдите в кабинет → нажмите «Сгенерировать API-ключи». Sandbox-ключи имеют префикс xh_test_ (production — xh_live_). Процесс тот же что в «Как получить ключи» выше, отличается только префикс.

Отличия от production

Поле Sandbox Production
API key prefix xh_test_... xh_live_...
Payment-gateway fake (без HTTP) Реальный платёжный шлюз
Payment URL https://app.x-hub.online/sandbox/pay/{payment_id}рабочая песочная страница оплаты (не заглушка), см. ниже. Точный адрес всегда приходит в payment_url Реальная ссылка СБП
Смена статуса Авто-симуляция magic-токенами (см. ниже) или кнопками на песочной странице оплаты — в любой момент, без участия админа X-Hub Автоматически (callback)
Реальные USDT Нет, только в ledger Да
Settlement (T+1) Пропускается Выполняется
Anomaly detection Пропускается Активно
Webhook мерчанту Присылается Присылается
sandbox в API response true false (всегда присутствует)
sandbox в webhook payload true поле отсутствует

Обмены и KYC в sandbox

  • Обмены (POST /api/v1/exchanges) полностью работают и в sandbox — на изолированном тестовом леджере. Если опция обменов не включена на аккаунте — 400 FEATURE_NOT_ENABLED (как и в production).
  • KYC (/api/v1/kyc/*) — зависит от конфигурации аккаунта: у части аккаунтов sandbox-KYC отключён (400 SANDBOX_NOT_SUPPORTED), у остальных работает.
  • Подписки (/api/v1/subscriptions) работают в sandbox, но с важным расхождением с боем — списания при привязке мандата в песочнице не происходит, а сами списания становятся paid автоматически через 30 секунд. Обязательно прочитай Подписки → Sandbox перед тем как переносить интеграцию в бой. Если POST /subscriptions в песочнице отвечает 501 RECURRING_NOT_SUPPORTED — это, скорее всего, не про твой аккаунт, см. Подписки.

Flow

  1. Создаёте платёж через API как в prod: POST /api/v1/payments
  2. В ответе видите "sandbox": true и payment_url на песочную страницу оплаты
  3. Дальше — два пути, они друг друга не исключают:
    • ничего не делать: sandbox сам переведёт платёж в терминальный статус (по умолчанию — happy-path paid через 30 секунд, см. magic-токены);
    • открыть payment_url и нажать нужную кнопку — статус сменится немедленно. Кто сработал первым, тот и выиграл: второй путь после этого просто ничего не делает (idempotent).
  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 c failure_reason: "insufficient_funds"
xhub_test_expire 60 сек expired
xhub_test_mismatch 5 сек amount_mismatch (фактическая сумма = amount_rub - 1)
xhub_test_manual авто-симуляция выключена: статус двигаешь сам кнопками на песочной странице оплаты

xhub_test_fail намеренно отдаёт самую частую и самую дорогую причину — «не хватило денег». Это тот случай, где подписку отменять не надо, а надо повторить списание через день-два; написать эту ветку удобнее здесь, чем на живых подписчиках. Полный список кодов — причина отказа.

Поиск токена — case-insensitive и word-boundary (regex \b<token>\b): токен должен быть отдельным «словом», отделённым пробелом, дефисом или пунктуацией. Например description: "Заказ #42 — xhub_test_fail" сработает, а description: "prexhub_test_failpost" — нет (токен склеен с другими символами).

Если токенов в external_id/description несколько, побеждает первый по порядку из таблицы (paidfailexpiremismatch); xhub_test_manual имеет приоритет над всеми и просто отключает автомат.

Песочная страница оплаты

payment_url песочного платежа ведёт на рабочий симулятор оплаты на app.x-hub.online. Открой его в браузере — увидишь сумму, статус, обратный отсчёт до expires_at и три кнопки:

Кнопка Итоговый статус
Имитировать успешную оплату paid
Имитировать ошибку failed
Имитировать истечение срока expired

Каждое нажатие проходит ту же машину состояний, тот же леджер и тот же outbox вебхуков, что и боевой callback, — то есть уведомление придёт настоящее, с подписью и с sandbox: true. Страница сама опрашивает статус раз в 3 секунды, поэтому смена статуса авто-симуляцией видна на ней без перезагрузки.

Статус amount_mismatch кнопкой не ставится — его даёт только magic-токен xhub_test_mismatch (или админ X-Hub по запросу).

Те же действия из кода: /api/sandbox/*

Страница — тонкая обёртка над двумя публичными endpoint'ами. Их можно дёргать напрямую из автотестов: авторизация не нужна, заголовки X-Api-Key / X-Api-Secret / Idempotency-Key не передаются.

# Прочитать состояние песочного платежа
curl https://api.x-hub.online/api/sandbox/payment-info/550e8400-e29b-41d4-a716-446655440000

# Перевести его в нужный статус
curl -X POST https://api.x-hub.online/api/sandbox/simulate/550e8400-e29b-41d4-a716-446655440000 \
  -H "Content-Type: application/json" \
  -d '{"action": "paid"}'

В URL — «голый» UUID, без префикса pay_

В остальном API идентификатор платежа выглядит как pay_550e8400-…. Эти два endpoint'а принимают только UUID-часть — префикс надо отрезать.

GET /api/sandbox/payment-info/{id}200:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "PENDING",
  "amount_rub": "500.00",
  "is_sandbox": true,
  "created_at": "2026-04-20T10:00:00.000Z",
  "expires_at": "2026-04-20T10:15:00.000Z",
  "paid_at": null
}

POST /api/sandbox/simulate/{id} — тело {"action": "paid" | "failed" | "expired"}200:

{ "ok": true, "previous_status": "PENDING", "new_status": "PAID" }

Ошибки:

HTTP Code Причина
400 VALIDATION_ERROR action не одно из paid / failed / expired
404 NOT_FOUND Платежа нет или он не песочный. Боевые платежи через эти ручки недоступны в принципе
409 INVALID_TRANSITION Переход недопустим из текущего статуса (например, платёж уже PAID)
409 STATE_CHANGED Статус поменялся параллельно (авто-симуляция успела раньше) — перечитай payment-info

Не встраивай /api/sandbox/* в боевой код

Эти endpoint'ы существуют только для песочницы и намеренно не требуют авторизации: payment_id — UUIDv4, а боевые платежи отсекаются по is_sandbox. В production аналога нет — платёж туда двигает сама платёжная система.

Практическое следствие: любой, кому известен id твоего песочного платежа, может перевести его в paid. Реальных денег за этим нет, но не используй песочные платежи как доказательство оплаты.

Webhook payload

Тот же формат что в prod, плюс дополнительное поле sandbox: true:

{
  "event": "payment.status_changed",
  "payment_id": "pay_abc123",
  "external_id": "ORD-001",
  "status": "paid",
  "previous_status": "pending",
  "amount_rub": "1000.00",
  "actual_amount_rub": null,
  "amount_usdt": null,
  "payment_method": "sbp",
  "metadata": null,
  "sandbox": true,
  "timestamp": "2026-04-20T10:00:00Z"
}

Подпись через заголовок X-Webhook-Signature: sha256=<hex> — та же HMAC-SHA256, что в production.

Поле amount_usdt в sandbox

В sandbox amount_usdt всегда null — реальная конвертация RUB→USDT не выполняется, settlement пропускается (см. таблицу «Отличия от production» выше). Тестируй только обработку статусов и не опирайся на значение amount_usdt в sandbox.

Переключение Тест↔Боевой

Sandbox и production живут в одном аккаунте — это два слота ключей и переключатель режима, которым ты управляешь сам:

  1. Подтверждаешь, что интеграция работает в sandbox (ключи xh_test_...)
  2. В кабинете генерируешь боевые ключи xh_live_... (второй слот; тестовые ключи никуда не пропадают)
  3. Нажимаешь «Активировать боевой режим» — аккаунт переходит в боевой режим
  4. Меняешь ключи в своём коде: xh_test_xh_live_
  5. Вернуться в тест можно в любой момент кнопкой «Вернуться в тест» — и обратно, сколько угодно раз

В каждый момент активен один режим. Запрос с ключом не-активного режима → 401 с кодом MODE_MISMATCH и человекочитаемым сообщением, какой режим сейчас активен. Тестовые и боевые данные (платежи, баланс, идемпотентность) полностью изолированы друг от друга.


Аутентификация

Все запросы к API (кроме health-check) требуют два заголовка:

Header Значение
X-Api-Key Публичный идентификатор мерчанта. Строка, начинающаяся с xh_live_ (продакшн) или xh_test_ (sandbox)
X-Api-Secret Секрет мерчанта (64 hex символа)

При невалидных ключах (в т.ч. без префикса xh_live_/xh_test_) — 401 Unauthorized.

Где хранить ключи

  • api_key — можно логировать, не секрет
  • api_secretтолько на твоём бэкенде в env/secret manager, не в git
  • webhook_secretтолько на твоём бэкенде, для проверки HMAC подписи webhook'ов

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

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

Idempotency-Key

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

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

Формат: UUID (любая версия, RFC 4122). Не-UUID строка → 400 VALIDATION_ERROR ("Idempotency-Key must be a valid UUID").

Как работает

  • При первом вызове — создаётся новый ресурс
  • Повторный вызов с тем же ключом вернёт исходный ресурс (без дубликата) — в любой момент: уникальность ключа вечная в рамках пары (аккаунт, режим test/live). Статус-код при создании — 201, при idempotent-повторе — 200
  • 24 часа живёт лишь внутренний кэш быстрых повторов — на семантику он не влияет: повтор с тем же ключом хоть через месяц вернёт исходный ресурс, а не создаст новый
  • Тело запроса при повторе не сверяется — возвращается ответ первого вызова. Генерируй новый Idempotency-Key для каждой логически новой операции
  • Идемпотентность изолирована между режимами: тот же ключ в тестовом (xh_test_) и боевом (xh_live_) режимах — это два независимых платежа

Зачем

Предотвращает двойное списание при сетевых сбоях. Если твой POST упал по таймауту — ретрай с тем же ключом безопасен.

Плохо и хорошо

// ❌ НЕПРАВИЛЬНО: генерируем UUID на каждый retry
async function createPayment(amount) {
  return fetch('...', {
    headers: { 'Idempotency-Key': crypto.randomUUID() },  // каждый retry = новый UUID → дубли
  });
}

// ❌ НЕПРАВИЛЬНО: не-UUID строка
// 'Idempotency-Key': `order-${orderId}` → 400 VALIDATION_ERROR

// ✅ ПРАВИЛЬНО: UUID генерируется один раз на заказ,
// сохраняется рядом с заказом в своей БД и переиспользуется при ретраях
async function createPayment(orderId, amount) {
  let idempotencyKey = await db.getIdempotencyKey(orderId);
  if (!idempotencyKey) {
    idempotencyKey = crypto.randomUUID();
    await db.saveIdempotencyKey(orderId, idempotencyKey);
  }
  return fetch('...', {
    headers: { 'Idempotency-Key': idempotencyKey },  // тот же UUID при ретраях того же заказа
  });
}

Rate Limiting

Лимитов четыре, и они разбиты на две независимые корзины: «денежные» ручки (создание приёма денег) и всё остальное в /api/v1. Внутри каждой корзины отдельно считаются лимит на ключ и лимит на IP.

Денежными считаются ровно эти вызовы:

  • POST /payments
  • POST /exchanges
  • POST /subscriptions
  • POST /subscriptions/{id}/charges

Вложенные пути денежными не считаются: например, POST /payments/{id}/refunds идёт в общую корзину.

Корзина Лимит На что считается Окно Порог
Денежные ручки Per-key значение заголовка X-Api-Key 60 сек 120 запросов
Денежные ручки Per-IP IP-адрес, с которого пришёл запрос 60 сек 120 запросов
Всё остальное в /api/v1 Per-key значение заголовка X-Api-Key 60 сек 60 запросов
Всё остальное в /api/v1 Per-IP IP-адрес, с которого пришёл запрос 60 сек 300 запросов

Корзины не сообщаются друг с другом. Шторм опроса статусов выедает только свои 60/300 и не может заблокировать создание платежа — ради этого разделение и сделано.

Для одного IP это ужесточение

Раньше денежные запросы делили общий IP-бюджет в 300 req/min. Теперь у них собственные 120 req/min на адрес. Если несколько твоих сервисов или несколько мерчант-аккаунтов создают платежи через один NAT/egress-адрес, вы упрётесь в лимит раньше, чем до этого изменения. Планируй нагрузку по созданию платежей отдельно от нагрузки по чтению.

При превышении любого — 429 Too Many Requests с кодом RATE_LIMIT_EXCEEDED. Различить, какой именно лимит сработал, можно по message и по значению X-RateLimit-Limit:

{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests. Limit: 60 per minute."}}
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests for API. Limit: 300 per minute per IP."}}
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many payment requests. Limit: 120 per minute. Retry with the same Idempotency-Key."}}
{"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Too many payment requests from this IP. Limit: 120 per minute. Retry with the same Idempotency-Key."}}

Повтор отбитого денежного запроса безопасен: с тем же Idempotency-Key второй платёж не создастся.

Все лимиты работают ДО авторизации

Счётчики увеличиваются на каждом запросе, даже если ключ неверный, отозван или относится к неактивному режиму. Запрос, который в итоге получит 401, всё равно съедает квоту.

Практические следствия:

  • Per-key лимит считается по строке ключа, а не по аккаунту. Тестовый (xh_test_) и боевой (xh_live_) ключи — независимые бюджеты. После ротации ключа новый ключ стартует с чистым счётчиком.
  • Per-IP лимит — общий на весь исходящий IP. Если несколько твоих сервисов (или несколько мерчант-аккаунтов) ходят к нам через один NAT/egress-адрес, они делят одни и те же 300 req/min на чтение и одни и те же 120 req/min на создание платежей. Ретрай-штормы одного сервиса упрутся в лимит у соседнего.

Заголовки ответа (отдаём оба семейства — legacy и draft-6 standard) приходят на каждом ответе, не только на 429:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1776166800
RateLimit-Policy: 60;w=60
RateLimit-Limit: 60
RateLimit-Remaining: 42
RateLimit-Reset: 38
  • X-RateLimit-Reset — Unix timestamp (секунды с эпохи), когда окно сбрасывается
  • RateLimit-Reset (draft-6) — секунды до сброса окна
  • Retry-After (секунды) — добавляется только к ответу 429

Заголовки описывают тот лимит, до которого запрос дошёл последним. Порядок проверки: сначала IP-лимит своей корзины, затем key-лимит своей корзины. Для чтения в штатной ситуации это Limit: 60, для создания платежа — Limit: 120. Если запрос отбит IP-лимитом, до key-счётчика он не доходит — тогда в заголовках будет Limit: 300 (чтение) или Limit: 120 (создание платежа).

Если получил 429 — подожди Retry-After секунд (или до X-RateLimit-Reset) и ретрай.


Платежи

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

stateDiagram-v2
    [*] --> pending
    pending --> processing
    pending --> paid: быстрая обработка
    pending --> expired
    pending --> cancelled
    pending --> failed
    pending --> amount_mismatch
    processing --> paid
    processing --> failed
    processing --> cancelled
    processing --> amount_mismatch
    processing --> expired
    paid --> settled
    paid --> refunded: банк вернул клиенту до settlement
    settled --> refunded: банк вернул клиенту после settlement
    amount_mismatch --> paid: разбор X-Hub
    amount_mismatch --> failed: разбор X-Hub
    expired --> paid: поздняя оплата (окно 6 ч)
    expired --> amount_mismatch: поздняя оплата не на ту сумму

Терминальны только failed, cancelled и refunded — из них переходов нет. Всё остальное, включая expired, может измениться.

Статус Значение
pending Создан, ждёт оплаты. Истекает по умолчанию через 15 минут — точный срок всегда в поле expires_at ответа, ориентируйся на него, а не на константу
processing Клиент начал оплату (сканировал QR)
paid Платёж подтверждён банком. USDT в pending_rub
settled Прошёл settlement. USDT на available_balance
amount_mismatch Клиент заплатил не ту сумму. Требует разбора
expired Время на оплату истекло, клиент не оплатил. Не терминальный — поздняя оплата может поднять платёж в paid, см. ниже
cancelled Платёж отменён до того как деньги дошли — мерчанту ничего не начислялось (terminal)
failed Отказ банка / техническая ошибка
refunded Банк-эквайер вернул деньги клиенту после того как мы засчитали платёж. Терминальный. См. ниже

Возврат после успешной оплаты — refunded

Банк-эквайер может отозвать платёж и вернуть деньги клиенту через минуты, часы, дни или месяцы после того как мы прислали тебе payment.status_changed со status: paid или settled.

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

  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 (точный текст сообщения может отличаться — ориентируйся на код и поле details).
webhook_url string Переопределяет глобальный webhook-URL из кабинета для этого платежа. В production — только публичный HTTPS (не приватные IP, не localhost, не HTTP), иначе 400 VALIDATION_ERROR. В sandbox дополнительно разрешены http:// и localhost. Если не передан — используется глобальный URL из кабинета

Per-payment webhook_url (XHU-28)

По умолчанию все события платежа летят на глобальный webhook_url из кабинета. Поле webhook_url в теле POST /payments позволяет направить события конкретного платежа на другой адрес — удобно для multi-tenant интеграций.

  • Production: только публичный HTTPS. Приватные IP, localhost и http:// отклоняются (400 VALIDATION_ERROR, "webhook_url must be a public HTTPS URL ...").
  • Sandbox: дополнительно разрешены http:// и localhost — для локальной разработки.

Ответ 201

{
  "id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "status": "pending",
  "amount_rub": "500.00",
  "actual_amount_rub": null,
  "amount_requested": "500.00",
  "amount_received": null,
  "amount_usdt": null,
  "external_id": "order-12345",
  "description": "VPN подписка 1 месяц",
  "payment_method": "sbp",
  "payment_url": "https://paywidget.org/abc-def",
  "expires_at": "2026-04-14T13:15:00Z",
  "created_at": "2026-04-14T13:00:00Z",
  "paid_at": null,
  "settled_at": null,
  "subscription_id": null,
  "period_ref": null,
  "refund_pending": false,
  "failure_reason": null,
  "sandbox": false,
  "metadata": {"user_id": "usr_abc", "plan": "premium"}
}
  • amount_requested / amount_received — алиасы amount_rub / actual_amount_rub (запрошенная и фактически полученная сумма).
  • refund_pending — по платежу запрошен возврат, но деньги ещё не вернулись. Поле присутствует всегда (false у обычного платежа). Пока оно true, основной status не меняется — платёж и правда всё ещё оплачен, а возврат может не состояться. Подробнее — возвраты.
  • subscription_id — подписка, по которой сделано списание ("sub_..."). Поле присутствует всегда; у обычного платежа оно null.
  • period_ref — период, который это списание занимает в идемпотентности ("2026-08" / "2026-W32" / "2026-08-07" — формат зависит от interval подписки). Поле присутствует всегда; у обычного платежа оно null. Считать период самому не нужно — см. идемпотентность списания.
  • failure_reasonпочему платёж не прошёл. Поле присутствует всегда; у всех статусов, кроме failed, оно null. Набор значений и рекомендованное действие по каждому — причина отказа.
  • sandboxtrue только в тестовом режиме.

Что делать: редирект клиента на payment_url.

Получить платёж — GET /payments/:id

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

Ответ — тот же формат что и при создании, но с обновлёнными полями:

  • status — актуальный
  • actual_amount_rub — сколько реально заплатил клиент
  • amount_usdt — после settlement, сколько начислено
  • paid_at, settled_at — временные метки
  • subscription_id"sub_..." у списания по подписке, null у обычного платежа
  • period_ref — период списания ("2026-08"), null у обычного платежа
  • refund_pendingtrue, пока по платежу идёт незавершённый возврат
  • failure_reason — причина отказа у failed, null у остальных статусов (подробнее)

Ошибка 404 PAYMENT_NOT_FOUND — если платёж не принадлежит твоему мерчанту.

Список платежей — GET /payments

Query параметры

Параметр Тип По умолчанию
page int 1
per_page int 20 (макс 100)
status string — (все)
from ISO8601
to ISO8601

Пример:

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

Ответ

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

Edge cases

amount_mismatch

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

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

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

Возврат в PAID

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

expired

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

expired — НЕ терминальный статус. Не закрывай заказ намертво

Часть банков подтверждает оплату уже после того, как мы истекли по таймауту. Такую позднюю оплату мы спасаем: перечитываем состояние заказа у платёжной системы и, если оплата подтвердилась, переводим платёж expired → paid — прилетает ещё один payment.status_changed.

Как это работает:

  • Окно спасения — 6 часов от момента создания платежа. Позже мы уже не переспрашиваем.
  • Спасение возможно не на всех рельсах — рассчитывать на него как на гарантию нельзя. Гарантировано другое: expired может смениться, и твой обработчик обязан это пережить.
  • Действуем только на подтверждённую оплату. Отказ, таймаут и промежуточный статус платёж не трогают — честно истёкшие заказы так и остаются expired.
  • Если поздняя оплата пришла на другую сумму, платёж уходит в amount_mismatch (ручной разбор), а не в paid.

Что это значит для интегратора. Если по expired ты необратимо закрываешь заказ (удаляешь корзину, отпускаешь товар, банишь повторную выдачу), то при поздней оплате клиент заплатит, а товара не получит — деньги у нас, обязательство у тебя.

Правильно: по expired помечай заказ как «не оплачен» обратимо и разреши клиенту новую попытку. Если позже придёт paid по тому же payment_id — выдай товар (или, если клиент уже оплатил повторно, оформи возврат через поддержку X-Hub). Разрешённые переходы из expired — только paid и amount_mismatch; никаких других.

failed

Платёж не прошёл. Финальный статус. Почему — в поле failure_reason, и от него зависит, что делать дальше.

Причина отказа — failure_reason

Поле есть в объекте платежа (REST) всегда: у всех статусов, кроме failed, оно null. В webhook-payload оно приходит только у status: "failed" — так же, как subscription_id и period_ref приходят только у списаний по подписке.

failure_reason Что произошло Что делать
insufficient_funds На счёте плательщика не хватило денег Повторить списание через 1–3 дня. Подписку не отменять — деньги на счёте появляются. Это самый частый отказ у рекуррента.
declined_by_bank Банк плательщика отклонил операцию: лимит, антифрод, блокировка карты Повторить один раз позже. Если снова — попросить плательщика сменить способ оплаты.
mandate_revoked Согласие на автосписание больше не действует Повторы бессмысленны — попросить плательщика привязать оплату заново.
cancelled_by_customer Плательщик сам отменил платёж Автоповторов не делать — это осознанный отказ.
expired Счёт не оплатили до истечения срока Выставить новый счёт / прислать новую ссылку. Платёжное средство тут ни при чём.
unknown Причину распознать не удалось Повторить один раз. Если повторяется — написать в поддержку с payment_id.

Почему failure_reason: "expired" бывает у статуса failed

«Срок истёк» приезжает к тебе двумя разными путями, и это одно и то же событие:

  • наш TTL (15 минут) закрывает неоплаченный счёт → status: "expired", поля failure_reason нет;
  • платёжная система сообщила, что счёт у неё истёк → status: "failed" + failure_reason: "expired".

Действие в обоих случаях одинаковое — выставить новый счёт. Если у тебя ветка expired уже написана, направь в неё и этот случай.

Обрабатывай по белому списку

Набор кодов будет пополняться по мере того, как мы уточняем словари рельс. Ветку default пиши как «повторить один раз, дальше в поддержку» (то есть как unknown), а не как «отменить подписку»: незнакомый код почти всегда означает исправимую ситуацию.

Проверить свою обработку можно в песочнице: magic-токен xhub_test_fail возвращает failure_reason: "insufficient_funds" — тот случай, ради которого ветку повторного списания и стоит написать.


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

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

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

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

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':
      // клиент не оплатил — не выдавать, разрешить повтор.
      // ВАЖНО: пометка должна быть ОБРАТИМОЙ. `expired` не терминален —
      // поздняя оплата поднимет этот же payment_id в `paid` (окно 6 часов),
      // и тогда сюда прилетит второй webhook с успехом.
      markOrderAsAbandoned(event.external_id);
      break;

    case 'failed':
      // Платёж не прошёл. ЧТО делать — зависит от причины, а не от статуса:
      // на одинаковый `failed` бывает и «повтори через день, подписчик
      // останется», и «повторы бессмысленны».
      switch (event.failure_reason) {
        case 'insufficient_funds':
          // денег не хватило — подписку НЕ отменяем, пробуем позже
          scheduleRetry(event.subscription_id, { inDays: 2 });
          break;
        case 'mandate_revoked':
          // согласие отозвано — повторы не помогут, нужна новая привязка
          askCustomerToRebind(event.subscription_id);
          break;
        case 'expired':
          // «срок истёк» у платёжной системы — то же, что наш status:"expired"
          markOrderAsAbandoned(event.external_id);
          break;
        case 'cancelled_by_customer':
          // осознанный отказ — автоповторов не делаем
          markOrderAsAbandoned(event.external_id);
          break;
        default:
          // `declined_by_bank`, `unknown` и любой БУДУЩИЙ код: одна попытка,
          // потом человек. Отменять подписку по незнакомому коду нельзя —
          // почти всегда ситуация исправима.
          notifyClientOfFailure(event.external_id, event.payment_id);
          scheduleRetry(event.subscription_id, { inDays: 1, maxAttempts: 1 });
      }
      break;

    case 'refunded':
      // CRITICAL: возврат после успеха.
      // Если товар уже выдан — отозвать доступ или выставить долг клиенту.
      handleRefundAfterFulfillment(event.external_id, event.payment_id);
      alertOps('Refund-after-paid: ' + event.payment_id);
      break;

    case 'amount_mismatch':
      // ждать ручного разбора X-Hub — следующий webhook прилетит
      // с финальным статусом (paid или failed)
      pauseOrderUntilResolved(event.external_id);
      break;

    default:
      // неизвестный статус — лог + 200 (чтобы не словить retry-storm)
      logUnknownStatus(event);
  }
}

Python

def handle_xhub_webhook(event):
    if event['event'] != 'payment.status_changed':
        return

    status = event['status']
    ext_id = event['external_id']

    if status in ('paid', 'settled'):
        provision_service(event['payment_id'], ext_id)
    elif status in ('cancelled', 'expired'):
        mark_order_as_abandoned(ext_id)
    elif status == 'failed':
        notify_client_of_failure(ext_id, event['payment_id'])
    elif status == 'refunded':
        # CRITICAL: возврат после успеха
        handle_refund_after_fulfillment(ext_id, event['payment_id'])
        alert_ops(f"Refund-after-paid: {event['payment_id']}")
    elif status == 'amount_mismatch':
        pause_order_until_resolved(ext_id)
    else:
        log_unknown_status(event)

PHP

function handleXhubWebhook(array $event): void {
    if ($event['event'] !== 'payment.status_changed') return;

    $status = $event['status'];
    $extId = $event['external_id'];

    if ($status === 'paid' || $status === 'settled') {
        provisionService($event['payment_id'], $extId);
    } elseif ($status === 'cancelled' || $status === 'expired') {
        markOrderAsAbandoned($extId);
    } elseif ($status === 'failed') {
        notifyClientOfFailure($extId, $event['payment_id']);
    } elseif ($status === 'refunded') {
        // CRITICAL: возврат после успеха
        handleRefundAfterFulfillment($extId, $event['payment_id']);
        alertOps('Refund-after-paid: ' . $event['payment_id']);
    } elseif ($status === 'amount_mismatch') {
        pauseOrderUntilResolved($extId);
    } else {
        logUnknownStatus($event);
    }
}

Best practices

  • Идемпотентность через X-Webhook-Id. Тот же X-Webhook-Id — это retry от X-Hub (мы повторим, если твой endpoint не ответил 200 за 10 сек). Запиши X-Webhook-Id в свою БД и не выполняй action дважды для одного и того же id. Подробнее — раздел «Идемпотентность» в Webhooks ниже.
  • Idempotent action как таковой. Одно и то же событие (например paid для одного payment_id) может прийти несколько раз — provisionService() должна быть безопасна для повторного вызова (либо сама проверяет «уже выдали», либо UPSERT-операция).
  • Подпись обязательна. Не доверяй webhook'у безоговорочно — всегда проверяй X-Webhook-Signature. См. Верификация подписи.
  • Финальные статусы — их ровно три. Терминальны только failed, cancelled и refunded. Остальное может измениться:

    • expiredpaid (поздняя оплата, окно 6 часов от создания) или → amount_mismatch (поздняя оплата не на ту сумму). expired терминальным не является — см. expired;
    • paid / settledrefunded (минуты-часы-дни-месяцы спустя);
    • amount_mismatchpaid или failed после разбора.

    Практическое правило: необратимые действия (списать товар со склада, окончательно закрыть заказ) вешай только на failed / cancelled. Всё остальное должно уметь «переиграться». - Узнавай о сбоях шлюза. Подпишись на 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 секунд
  • Ретраи — при 5xx, 429 и сетевых ошибках/таймаутах. Ответ 4xx (кроме 429) помечает обычное событие как FAILED сразу, без ретраев; для денежных событий 4xx не окончателен — см. Retry-стратегия. 3xx-редиректы не следуются и терминальны всегда
  • Endpoint должен быть прямым URL: редирект (даже 301 на тот же хост с www) считается провалом доставки
  • Идемпотентная обработка (webhook может прийти дважды)

Формат payload

Payload — плоский объект (не вложенный). Все поля на верхнем уровне:

{
  "event": "payment.status_changed",
  "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "external_id": "order-12345",
  "status": "paid",
  "previous_status": "processing",
  "amount_rub": "500.00",
  "actual_amount_rub": "500.00",
  "amount_usdt": null,
  "payment_method": "sbp",
  "timestamp": "2026-04-14T13:05:12Z",
  "metadata": {"user_id": "usr_abc"}
}

Пример failed webhook (списание по подписке не прошло — на счёте не хватило денег):

{
  "event": "payment.status_changed",
  "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "external_id": "order-12345",
  "status": "failed",
  "previous_status": "pending",
  "amount_rub": "299.00",
  "actual_amount_rub": null,
  "amount_usdt": null,
  "payment_method": "recurring",
  "subscription_id": "sub_9f1c...",
  "period_ref": "2026-08",
  "refund_pending": false,
  "failure_reason": "insufficient_funds",
  "timestamp": "2026-08-09T04:26:11Z",
  "metadata": {"user_id": "usr_abc"}
}

Пример refunded webhook (банк отозвал платёж после settlement):

{
  "event": "payment.status_changed",
  "payment_id": "pay_550e8400-e29b-41d4-a716-446655440000",
  "external_id": "order-12345",
  "status": "refunded",
  "previous_status": "settled",
  "amount_rub": "500.00",
  "actual_amount_rub": "500.00",
  "amount_usdt": "6.20000000",
  "payment_method": "sbp",
  "timestamp": "2026-05-13T16:12:06Z",
  "metadata": {"user_id": "usr_abc"}
}

События

event Когда
payment.status_changed Любое изменение статуса платежа (в т.ч. списаний по подписке, payment_method: "recurring")
withdrawal.status_changed Любое изменение статуса выплаты
exchange.status_changed Любое изменение статуса обмена (RUB↔USDT)
subscription.status_changed Изменение статуса подписки: подтверждение мандата, пауза, отмена, а также готовность ссылки привязки (см. раздел про подписки ниже)
refund.completed Возврат по платежу исполнен — деньги ушли клиенту
refund.rejected Заявка на возврат отклонена — деньги не двигались
kyc.status_changed Изменение статуса KYC-верификации клиента
gateway.status_changed Изменение статуса платёжного шлюза X-Hub: operational / degraded / down

Смотри payment.status / withdrawal.status / exchange.status чтобы понять текущее состояние. X-Webhook-Timestamp — ISO-8601 в UTC.

refund.completed / refund.rejected — заявочные возвраты

Возврат по уже рассчитанному (settled) платежу инициируется через поддержку X-Hub — публичного API для этого нет (см. FAQ). О судьбе заявки ты узнаёшь этими двумя событиями. Payload одинаковый:

{
  "event": "refund.completed",
  "payment_id": "550e8400-e29b-41d4-a716-446655440000",
  "refund_id": "9c8d4f16-8a7b-b2e6-f0c9-d1235b3fa1e2",
  "amount_rub": "500.00",
  "status": "COMPLETED"
}
Поле Что значит
payment_id Платёж, по которому делался возврат
refund_id Идентификатор заявки на возврат. На один платёж заявок может быть несколько (частичные возвраты) — уведомления по ним независимы
amount_rub Сумма именно этой заявки, а не всего платежа. Частичный возврат — обычное дело
status COMPLETED у refund.completed, REJECTED у refund.rejected

Отличия от остального API — читай внимательно

  • payment_id здесь БЕЗ префикса pay_ — это «голый» UUID, в отличие от payment.status_changed и REST-ответов. Сопоставляя с платежом, отрежь префикс на своей стороне. То же и с refund_id.
  • payment.status_changed по заявочному возврату НЕ приходит вообще — ни при частичном, ни при полном. Даже когда сумма возвратов покрывает платёж целиком и он уходит в refunded, единственное уведомление об этом — refund.completed. Не жди смены статуса платежа: считай возвраты по этим двум событиям.
  • status приходит в ВЕРХНЕМ регистре (COMPLETED / REJECTED), а не в нижнем, как status платежа.
  • Причина отказа в refund.rejected не передаётся — спрашивай у команды X-Hub.

refund.completed — денежное событие (медленная лестница ретраев), refund.rejected — нет.

Это не то же самое, что payment.status_changed со status: refunded: тот приходит, когда деньги отозвал банк-эквайер сам, без заявки с твоей стороны (см. Возврат после успешной оплаты).

gateway.status_changed — статус платёжного шлюза

Когда платёжный шлюз X-Hub деградирует или восстанавливается, мы шлём отдельный webhook. Это позволяет тебе подключить наш статус к своему мониторингу/алертам без необходимости опрашивать /health endpoint в цикле.

{
  "event": "gateway.status_changed",
  "status": "down",
  "error_rate_1h": 0,
  "completion_rate_1h": 0,
  "reason": "completion",
  "since": "2026-05-15T04:00:00.000Z",
  "timestamp": "2026-05-15T04:00:32.144Z"
}

Поля:

  • status — текущий статус шлюза:
    • operational — всё работает штатно
    • degraded — повышенный процент ошибок или сниженная доля успешных оплат
    • down — шлюз временно недоступен / клиенты не могут завершить оплаты
  • error_rate_1h — процент API-ошибок за последний час (0-100)
  • completion_rate_1h — процент успешных оплат за последний час PAID / (PAID+CANCELLED+EXPIRED+FAILED), 0-100. Может быть null — это значит за час было слишком мало финализированных платежей (< 5), чтобы посчитать значимую метрику
  • reason — что вызвало смену статуса:
    • api — повышенный процент ошибок на нашей API-стороне (создание платежей)
    • completion — клиенты сканируют QR, но платежи не доходят (низкая конверсия)
    • both — оба сигнала одновременно
  • since — ISO-8601 timestamp когда текущий статус впервые установился

Webhook отправляется только при переходе через границу 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' }),
  async (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    // Данные для подписи: timestamp + "." + raw_body
    const signatureData = timestamp + '.' + req.body.toString('utf-8');
    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.XHUB_WEBHOOK_SECRET)
      .update(signatureData)
      .digest('hex');

    // timingSafeEqual бросает RangeError, если длины буферов различаются,
    // — поэтому сначала проверяем наличие и длину, и только потом сравниваем.
    if (
      !signature ||
      signature.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    ) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf-8'));
    if (event.event === 'payment.status_changed' && event.status === 'paid') {
      await grantAccessTo(event.external_id);
    }

    res.status(200).send('OK');
  }
);

Обработчик обязан быть async

Внутри есть await grantAccessTo(...). Без async перед (req, res) файл не распарсится — SyntaxError на старте, а не в рантайме.

Python (Flask)

import os, hmac, hashlib
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    raw = request.get_data()
    sig = request.headers.get('X-Webhook-Signature', '')
    timestamp = request.headers.get('X-Webhook-Timestamp', '')

    # Данные для подписи: timestamp + "." + raw_body
    signature_data = timestamp.encode() + b'.' + raw
    expected = 'sha256=' + hmac.new(
        os.environ['XHUB_WEBHOOK_SECRET'].encode(),
        signature_data,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(sig, expected):
        abort(401, 'Invalid signature')

    event = request.get_json()
    if event['event'] == 'payment.status_changed' and event['status'] == 'paid':
        grant_access_to(event['external_id'])

    return 'OK', 200

PHP

$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';

// Данные для подписи: timestamp + "." + raw_body
$signatureData = $timestamp . '.' . $raw;
$expected = 'sha256=' . hash_hmac('sha256', $signatureData, getenv('XHUB_WEBHOOK_SECRET'));

if (!hash_equals($sig, $expected)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($raw, true);
if ($event['event'] === 'payment.status_changed' && $event['status'] === 'paid') {
    grantAccessTo($event['external_id']);
}

http_response_code(200);
echo 'OK';

Retry-стратегия

Лестниц ретрая две, и они независимы. Какая сработает — зависит от того, что ответил твой endpoint и денежное ли это событие.

1. Быстрая лестница — сбои доставки (5xx, 429, сеть, таймаут)

Попытка Задержка после предыдущей
1 сразу
2 +1 сек
3 +2 сек
4 +4 сек
5 +8 сек
6 +16 сек
7 +32 сек
8 +64 сек (~1 мин)
9 +128 сек (~2 мин)
10 +256 сек (~4 мин)

Формула: delay = 2^(attempt-1) секунд. Бюджет — 10 попыток; суммарные паузы дают ~8.5 минут. Потом доставка помечается FAILED и требует ручного разбора.

«~8.5 минут» — это сумма пауз, а не гарантированный срок

Считаются только попытки доставки. Паузы, которые ставит предохранитель, счётчик попыток не увеличивают — при лежащем endpoint'е те же 10 попыток растягиваются на часы и сутки. Не строй логику на «если за 10 минут вебхука не было — его уже не будет»: он может прийти сильно позже. Для проверки состояния используй GET /payments/:id.

2. Медленная лестница — денежное событие + 4xx

Код ответа описывает состояние твоей интеграции, а не нужность уведомления. Поэтому для событий о фактическом движении денег 4xx (кроме 429) больше не окончателен — такая доставка уходит на отдельный редкий график:

Шаг Пауза до следующей попытки
1 15 минут
2 1 час
3 4 часа
4 12 часов
5 24 часа

Итого окно ≈ 41 час, жёсткий потолок по возрасту доставки — 48 часов. После этого — FAILED и адресный алерт нашей дежурной смене (мерчант, платёж, сумма).

Денежными считаются:

  • payment.status_changed со status: paid, settled, refunded;
  • withdrawal.status_changed со status: completed;
  • exchange.status_changed со status: completed;
  • refund.completed.

Всё остальное (в т.ч. amount_mismatch, expired, cancelled, failed, refund.rejected, subscription.status_changed, kyc.status_changed, gateway.status_changed) — неденежное: 4xxFAILED сразу, без ретраев.

Бюджеты двух лестниц раздельны: медленные шаги не расходуют 10 быстрых попыток, и наоборот.

На временных проблемах отвечай 5xx, не 4xx

5xx — «попробуй ещё раз», и ты получишь полную быструю лестницу. 4xx для неденежного события — «не присылай это больше», и ретраев не будет. Если у тебя временный сбой (упала БД, идёт деплой) — верни 5xx.

Предохранитель (circuit breaker)

Чтобы не долбить лежащий endpoint, доставка всему мерчанту временно ставится на паузу:

Переход Условие
работает → пауза 5 доставок за скользящее окно 5 минут и ≥ 80 % из них провалились
пауза → пробная доставка прошло время паузы: сначала 5 минут, дальше — удвоение при каждой неудачной пробе, потолок 30 минут
пробная доставка → работает пробная доставка прошла успешно (счётчики сбрасываются)
пробная доставка → пауза проба провалилась, пауза удваивается

Что важно знать интегратору:

  • Пауза не тратит попытки. Отложенная предохранителем доставка не увеличивает счётчик attempts — именно поэтому реальное окно жизни уведомления измеряется не минутами, а часами и сутками.
  • Предохранитель общий на мерчанта, а не на событие. Пока он открыт, придерживаются все твои уведомления, включая свежие. Как только пробная доставка проходит — очередь разбирается целиком.
  • Пока предохранитель открыт, пробуется одна доставка за цикл. Остальные ждут ~30 секунд и пробуют снова.
  • 4xx предохранитель не открывают. Он реагирует на недоступность endpoint'а (сеть, таймаут, 5xx), а не на ответ «плохой запрос».
  • Потолок — 30 суток. Доставка, чей endpoint всё это время был недоступен, помечается FAILED окончательно.
  • Восстановился раньше и не хочешь ждать паузу — напиши команде X-Hub, предохранитель сбрасывается вручную.

Идемпотентность

Webhook может прийти дважды (retry после таймаута). Твой обработчик должен быть идемпотентным.

async function handleWebhook(event) {
  const paymentId = event.payment_id;

  const alreadyProcessed = await db.webhookLog.findUnique({
    where: {
      paymentId_status: { paymentId, status: event.status }
    }
  });

  if (alreadyProcessed) return;

  await db.webhookLog.create({
    data: { paymentId, status: event.status }
  });

  await grantAccess(event.external_id);
}

Чек-лист

  • [ ] Endpoint отвечает за < 10 секунд
  • [ ] HTTPS с валидным сертификатом
  • [ ] Проверка X-Webhook-Signature с использованием X-Webhook-Timestamp + "." + raw_body
  • [ ] Идемпотентная обработка по X-Webhook-Id (или payment_id + status)
  • [ ] Отвечаешь 200 после успешной обработки (не до)
  • [ ] Логируешь все приходящие webhooks для отладки
  • [ ] Обработчик не падает на неизвестных event типах

Баланс и курсы

GET /balance

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

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 Зарезервировано под выплату в обработке
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.55",
  "updated_at": "2026-04-14T12:00:00Z",
  "note": "Informational rate. Final rate is fixed at T+1 settlement."
}
Поле Описание
pair Всегда "RUB/USDT"
rate_merchant Итоговый курс для мерчанта (RUB за 1 USDT), округлён до 2 знаков. Включает все комиссии/наценку X-Hub — используй его в калькуляторе. Рыночный курс и размер наценки отдельными полями не отдаются
updated_at ISO-8601 UTC. Время последнего обновления курса
note Информационное сообщение

Используй rate_merchant в калькуляторе

Клиент платит 1000₽ → тебе начислится 1000 / rate_merchant USDT (минус флуктуации до settlement).

Пример

Клиент платит 1000₽, R = 79.37 (рыночный курс на T+1), markup = 5%:

merchant_rate = 79.37 / (1 - 0.05) = 83.5474  → в API придёт "83.55"
merchant_usdt = 1000 / 83.5474 = 11.96925338 USDT  <-- тебе
xhub_fee = (1000 / 79.37) - 11.96925338 ≈ 0.63 USDT  (все комиссии X-Hub)

Не считай начисление по rate_merchant до копейки

В ответе курс округлён до 2 знаков, а фактическое начисление считается по полной точности и фиксируется на T+1 settlement. GET /rates — для калькулятора и витрины, не для сверки.


Выплаты (автоматические)

Выплаты USDT происходят автоматически — ежедневным расчётом (settlement). После подтверждения платежей средства зачисляются на твой баланс и выплачиваются без каких-либо запросов с твоей стороны.

  • Баланс виден в GET /api/v1/balance и в кабинете.
  • При выплате может прийти webhook withdrawal.status_changed (payload ниже).

Ручные запросы выплат больше не поддерживаются

Эндпоинты POST /api/v1/withdrawals, GET /api/v1/withdrawals и GET /api/v1/withdrawals/:id выведены из эксплуатации и возвращают 410 с кодом WITHDRAWAL_DISCONTINUED. Создавать запросы на вывод не нужно — выплаты выполняются автоматически.

Webhook выплаты

При смене статуса выплаты — событие withdrawal.status_changed:

{
  "event": "withdrawal.status_changed",
  "withdrawal_id": "wd_abc123-...",
  "status": "completed",
  "previous_status": "processing",
  "amount_usdt": "500.00000000",
  "wallet_address": "TYour...",
  "tx_hash": "c1a2b3...",
  "timestamp": "2026-04-14T13:05:00Z"
}

Верификация подписи — как для платежей.


Подписки / Рекуррентные платежи

Подписки позволяют списывать с клиента деньги повторно по СБП-мандату — клиент один раз подтверждает согласие (мандат) в приложении банка, после чего ты инициируешь списания через API, не прося клиента платить каждый раз заново.

Опция включается на аккаунте

Рекуррентные платежи доступны только если у твоего аккаунта включена соответствующая опция. Если рекуррентные платежи глобально недоступны — вся ветка /subscriptions отвечает 404 NOT_FOUND, как будто эндпоинтов не существует. Если функция доступна, но списывать по мандату некому — POST-эндпоинты вернут 501 RECURRING_NOT_SUPPORTED, а GET (список / получение) работают и возвращают пустые списки. Хочешь подключить — напиши команде X-Hub.

501 RECURRING_NOT_SUPPORTED в песочнице ≠ «опция не включена твоему аккаунту»

У этого ответа две разные причины, и по коду ошибки они неразличимы:

  1. В боевом режиме — рекуррентная рельса твоему аккаунту действительно не назначена. Лечится обращением в X-Hub.
  2. В песочнице — песочная рекуррентная рельса не поднята на инстансе. Твой аккаунт тут ни при чём: песочница всегда ходит на встроенный симулятор, и если он выключен рубильником (это отдельный от самой фичи флаг), симулятор не умеет списаний по мандату → 501.

Различить можно по режиму ключа: если xh_test_... отдаёт 501, а сама ветка /subscriptions при этом жива (GET /subscriptions возвращает 200 с пустым списком, а не 404) — это причина №2, и обращение «включите мне опцию» уйдёт не по адресу. Напиши, что песочные подписки не поднимаются, — формулировка сэкономит круг переписки.

Ещё один ответ из той же серии: 503 PROVIDER_NOT_CONFIGURED — подписка (или мандат) ссылается на рельсу, которой на инстансе сейчас нет. Живой сценарий: подписки завели в песочнице, пока симулятор был включён, потом его выключили — сами подписки остались, а списывать по ним стало нечем.

Base URL тот же: https://api.x-hub.online/api/v1. Аутентификация — те же заголовки X-Api-Key / X-Api-Secret, что и во всём остальном API. Idempotency-Key (UUID) обязателен для POST /subscriptions и POST /subscriptions/:id/charges; lifecycle-эндпоинты (/pause, /resume, /cancel) его не требуют.

Ключевые термины

Термин Что это
Subscription (подписка) Мандат на повторные списания. Создаётся тобой, подтверждается клиентом-плательщиком
Binding (привязка мандата) Шаг подтверждения: клиент переходит по binding_url и разрешает списания в приложении банка. В этот же момент списывается первый период
Charge (списание) Одно списание по активному мандату. Технически — это обычный платёж (Payment) с payment_method: "recurring"
Списание при привязке Списание первого периода, которое инициирует платёжная система в момент подтверждения мандата — не ты. Приходит с external_id: null
Cap (потолок) Лимит суммы: cap_amount_rub — на одно списание, cap_window_rub — суммарно за интервал
Period (период) Календарный интервал списания (день / неделя / месяц). В одном периоде — одно списание (идемпотентность)

Как это работает (общий поток)

  1. Ты создаёшь подписку: POST /v1/subscriptions → получаешь id, status: pending_binding и binding_url. Изредка ссылка ещё не готова в момент ответа — тогда binding_url: null, binding_url_pending: true, и ссылка приходит следом webhook'ом (см. Ссылка привязки готовится).
  2. Отправляешь клиента на binding_url — там он подтверждает мандат в приложении банка.
  3. В момент подтверждения мандата с клиента сразу списывается первый период — на сумму amount_rub подписки. Это списание инициирует платёжная система, не ты. Оно приходит тебе обычным payment.status_changed с payment_method: "recurring" и видно в GET /v1/subscriptions/:id/charges.
  4. Подписка переходит в active — прилетает subscription.status_changed (или узнаёшь опросом GET /v1/subscriptions/:id).
  5. По активной подписке ты инициируешь последующие списания: POST /v1/subscriptions/:id/charges (режим A — списание по твоему триггеру).
  6. Каждое списание — это платёж; его финальный статус приходит webhook'ом payment.status_changed (как у обычных платежей).

Не списывай сам сразу после привязки — клиент заплатит дважды

Шаг 3 — не опечатка. Подтверждение мандата = немедленный дебет первого периода. Если ты, увидев active, тут же дёрнешь POST /:id/charges за тот же период, ты рискуешь списать с клиента второй раз.

Защита на нашей стороне есть — одно списание на период: твой POST /charges за уже занятый период вернёт 200 и тот же самый платёж (тот, что сделала платёжная система при привязке), а не создаст новый. Но полагаться только на неё нельзя: она работает по календарному периоду, поэтому привязка 31-го числа и твоё списание 1-го числа попадут в разные месяцы — и оба спишутся.

Правильный порядок:

  1. Создал подписку → отправил клиента на binding_url.
  2. Дождался subscription.status_changedactive.
  3. Проверил GET /v1/subscriptions/:id/charges. Если там уже есть списание — первый период оплачен, своё списание не делай.
  4. Своё первое POST /charges планируй на следующий период.

Отличить списание при привязке от своего можно по external_id: у списаний, инициированных платёжной системой, он всегда null (см. Как отличить списание при привязке от своего).

Жизненный цикл подписки

stateDiagram-v2
    [*] --> pending_binding
    pending_binding --> active: плательщик подтвердил мандат
    pending_binding --> cancelled: мандат отозван на стороне банка/плательщика<br/>или ссылка привязки так и не выпущена
    active --> paused: POST /pause
    paused --> active: POST /resume
    active --> cancelled: POST /cancel (terminal)
    paused --> cancelled: POST /cancel (terminal)
Статус Значение
pending_binding Подписка создана, ждём подтверждения мандата клиентом. Твои списания через POST /:id/charges невозможны (409 CHARGE_MANDATE_INACTIVE). Но списание при привязке приходит именно в этом статусе — см. предупреждение ниже
active Мандат подтверждён — можно списывать через POST /:id/charges
paused Мерчант приостановил. Твои списания заблокированы (charge → 409). Возобновляется через /resume
cancelled Терминальный. Мандат отменён мерчантом (/cancel), отозван на стороне банка/плательщика или привязка так и не состоялась — ссылка не была выпущена (см. binding_url_pending). Твои списания невозможны
expired Терминальный. Зарезервированный статус — в текущей версии API подписка автоматически в expired не переводится

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

past_due — просрочка по мандату (отдельное поле, не статус)

Если банк не смог списать по мандату, он переходит в взыскание. Мандат при этом остаётся живым — повторное списание может пройти, — поэтому status честно остаётся active, а факт просрочки приходит отдельным булевым полем past_due:

{ "id": "sub_550e8400-...", "status": "active", "past_due": true }

Поле присутствует всегда (у нормальной подписки false) — и в объекте подписки, и в webhook'е subscription.status_changed.

Что с этим делать. past_due: true — сигнал, что клиент фактически не платит, хотя мандат формально активен. Типовая реакция: перевести аккаунт в grace-период, показать клиенту просьбу обновить платёжный способ, а через N дней ограничить сервис. Не полагайся на один только status — по нему такой мандат неотличим от исправного.

Смена past_due присылает subscription.status_changed с тем же status, что и раньше: в таком событии previous_status равен status — это и есть маркер «сменился под-статус, а не статус». Сравнивай past_due, а не только status.

rebind_required — мандат утрачен, нужна новая привязка

Если банк сообщает, что привязка по мандату не найдена (утрачена безвозвратно), повторные списания по этой подписке не пройдут — нужна новая привязка. В отличие от past_due (мандат жив, ретрай уместен), здесь status переходит в paused, а факт приходит отдельным булевым полем rebind_required:

{ "id": "sub_550e8400-...", "status": "paused", "rebind_required": true }

Поле присутствует только со значением true (симметрично past_due, но противоположно по смыслу) — и в объекте подписки (GET /subscriptions/:id), и в webhook'е subscription.status_changed. Отличай от обычной паузы (paused без rebind_required) и от past_due (мандат ещё жив).

Что с этим делать. Не ретрай списание по этой подписке — оно не пройдёт. Предложи клиенту оформить подписку заново (новая привязка мандата); старую можно отменить.

pending_binding не означает «денег не двигали»

Списание первого периода происходит в момент подтверждения мандата и гонится с вебхуком активации подписки. Порядок не гарантирован: платёж-списание может прийти к тебе раньше, чем subscription.status_changedactive, то есть в тот момент, когда GET /subscriptions/:id ещё отдаёт pending_binding.

Поэтому не отбрасывай payment.status_changed с payment_method: "recurring" только на основании того, что подписка ещё не active. Деньги у плательщика уже списаны.

То же касается терминальных статусов: если отзыв мандата не сработал на стороне банка, списание может прийти по cancelled/paused подписке. Такие случаи мы ловим и разбираем вручную — но если увидел такое у себя, напиши нам.

Идентификация режима

Как и в остальном API, в тестовом режиме ответы подписок содержат "sandbox": true; в боевом — поле отсутствует.

Создать подписку — POST /subscriptions

Создаёт подписку и инициирует привязку мандата. Возвращает binding_url, на который надо отправить клиента.

curl -X POST https://api.x-hub.online/api/v1/subscriptions \
  -H "X-Api-Key: xh_live_..." \
  -H "X-Api-Secret: YOUR_API_SECRET" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_rub": "500.00",
    "interval": "month",
    "interval_count": 1,
    "cap_amount_rub": "600.00",
    "cap_window_rub": "600.00",
    "external_id": "sub-order-12345",
    "client_ref": "user_abc",
    "client_phone": "+79991234567"
  }'

Body

Поле Тип Обяз. Описание
amount_rub string Плановая сумма списания. Decimal, до 2 знаков ("500.00")
interval string Период списания: "day", "week" или "month"
interval_count int Множитель периода, 112. По умолчанию 1. Внимание: в текущей версии API он сохраняется и возвращается, но на расчёт периода не влияет — период всегда календарный (interval), см. идемпотентность списания
cap_amount_rub string Потолок на одно списание. Списание с суммой выше → 409 CAP_EXCEEDED
cap_window_rub string Потолок суммарно за период. Если сумма списаний в периоде превысит — 409 CAP_EXCEEDED. Не передан — оконного лимита нет
external_id string Твой ID подписки (для сопоставления). До 256 символов
client_ref string Твой идентификатор клиента-плательщика. До 256 символов. Не возвращается в ответах (§ приватность)
client_phone string Телефон клиента, до 32 символов. Хранится только маской, полный номер наружу не отдаётся

Ответ 201

{
  "id": "sub_550e8400-e29b-41d4-a716-446655440000",
  "status": "pending_binding",
  "amount_rub": "500.00",
  "interval": "month",
  "interval_count": 1,
  "cap_amount_rub": "600.00",
  "cap_window_rub": "600.00",
  "external_id": "sub-order-12345",
  "binding_url": "https://sub.nspk.ru/abc-def-ghi",
  "binding_url_pending": false,
  "current_period_start": null,
  "current_period_end": null,
  "past_due": false
}

Что дальше: отправь клиента на binding_url. current_period_start и current_period_end заполняются в момент активации мандата.

cap_window_rub в ответе

Если ты не передавал cap_window_rub, поле в ответе отсутствует целиком (а не приходит null). Не полагайся на его наличие.

Идемпотентность

Повтор POST /subscriptions с тем же Idempotency-Key вернёт ту же подписку (HTTP 200, без создания второго мандата).

Ссылка привязки может готовиться — binding_url_pending

Ссылку привязки выпускает не X-Hub, а платёжная система, и делает она это асинхронно. Обычно ссылка готова к моменту ответа, но иногда — нет. Раньше мы в такой ситуации отвечали ошибкой; теперь подписка создаётся всегда, а ссылка догоняет отдельным событием.

Признак — булево поле binding_url_pending. Оно присутствует всегда (у обычной подписки false) — и в ответе POST /subscriptions, и в GET /subscriptions/:id, и в webhook'е subscription.status_changed.

{
  "id": "sub_550e8400-...",
  "status": "pending_binding",
  "binding_url": null,
  "binding_url_pending": true
}

Что делать. Ничего не переспрашивай и, главное, не создавай подписку заново — мандат уже существует, повторное создание даст второй мандат на того же плательщика. Дождись webhook'а subscription.status_changed: он придёт с тем же status: "pending_binding" (то есть previous_status будет равен status — та же идиома, что у past_due), но уже с заполненным binding_url и binding_url_pending: false. Опрос GET /subscriptions/:id работает как всегда — как fallback, если webhook'и у тебя ещё не настроены.

Рекомендованный порядок:

  1. POST /subscriptions201.
  2. binding_url не пустой → отправляй клиента, как обычно (это подавляющее большинство случаев).
  3. binding_url пустой и binding_url_pending: true → покажи клиенту «готовим ссылку» и жди события со ссылкой; типовое ожидание — секунды.
  4. Пришло событие со ссылкой → отправляй клиента на неё. Дальше поток обычный.

Если ссылка так и не появится

Мы ждём ссылку до часа. Если платёжная система её так и не выпустит (или сама закроет привязку), подписка переводится в терминальный cancelled — придёт subscription.status_changed с previous_status: "pending_binding". Списаний по такой подписке не было и не будет; чтобы повторить попытку, создай новую подписку (с новым Idempotency-Key).

Как отличить «готовится» от «что-то не так»

binding_url: null возможен по двум причинам, и binding_url_pending их разводит:

binding_url binding_url_pending Что это значит Что делать
ссылка false норма отправить клиента
null true ссылка ещё выпускается ждать событие
null false нештатное: ссылка пришла на неизвестном хосте и подавлена (fail-closed) повторить запрос или написать в X-Hub

Совместимость

Поле только добавлено — существующие интеграции, читающие binding_url, продолжают работать. Если твой код падает на пустом binding_url (например, сразу подставляет его в редирект), добавь проверку binding_url_pending — теперь такой ответ штатно возможен.

Получить подписку — GET /subscriptions/:id

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

Ответ — тот же формат, что при создании, с актуальным status.

Поля ответа

Поле Тип Что означает
id string ID подписки в формате sub_<uuid>
status string Текущий статус (см. жизненный цикл)
past_due bool Мандат в просрочке у банка. Присутствует всегда; status при этом остаётся active — см. про past_due
rebind_required bool Мандат утрачен: нужна новая привязка, списания больше не пройдут. Присутствует только при true; status при этом paused — см. про rebind_required
amount_rub string Плановая сумма списания за период
interval string Календарный интервал: day / week / month
interval_count int Множитель, который ты передал при создании. На расчёт периода не влияет (см. выше)
cap_amount_rub string Потолок на одно списание
cap_window_rub string Оконный потолок. Отсутствует, если не задан
external_id string / null Твой ID подписки
binding_url string / null Ссылка на подтверждение мандата. Может быть null (см. выше)
binding_url_pending bool Ссылка ещё выпускается платёжной системой. Присутствует всегда; при true жди subscription.status_changed со ссылкой и не создавай подписку заново — см. binding_url_pending
current_period_start string / null ISO-8601. Начало текущего периода мандата. До активации null
current_period_end string / null ISO-8601. Конец текущего периода мандата — до этого момента мандат оплачен. До активации null; у мандатов, созданных до 08.08.2026, может оставаться null, пока платёжная система не пришлёт очередное обновление
sandbox bool Только в тестовом режиме, значение true. В боевом поле отсутствует

Чего в объекте подписки НЕТ

В текущей версии API подписка не отдаёт:

  • дату следующего списания — в режиме A следующее списание инициируешь ты сам, поэтому расписание ведёшь у себя;
  • дату последнего списания — бери её из GET /subscriptions/:id/charges (список отсортирован по убыванию created_at, первый элемент — самое свежее списание);
  • client_ref и полный client_phone — они принимаются, но наружу не возвращаются (приватность плательщика).

Период считать вручную не нужно: ключ периода каждого списания приходит готовым в поле period_ref объекта платежа и webhook'а payment.status_changed — см. идемпотентность списания.

Ошибка 404 SUBSCRIPTION_NOT_FOUND — если подписка не принадлежит твоему мерчанту или относится к другому режиму (test/live). Чужая подписка неотличима от несуществующей (никогда не 403).

Список подписок — GET /subscriptions

GET /api/v1/subscriptions?page=1&per_page=20&status=active

Query параметры

Параметр Тип По умолчанию
page int 1
per_page int 20 (макс 100)
status string — (все). Один из pending_binding / active / paused / cancelled / expired

Ответ

{
  "data": [
    { "id": "sub_...", "status": "active", "amount_rub": "500.00", "interval": "month" }
  ],
  "pagination": {
    "page": 1,
    "per_page": 20,
    "total": 12,
    "total_pages": 1
  }
}

Изменить подписку — PATCH /subscriptions/:id

Меняет только суммы/потолки. Прорации нет: новая amount_rub действует со следующего списания, уже созданные списания не пересчитываются. interval, interval_count, external_idне меняются.

curl -X PATCH https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-... \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
  -H "Content-Type: application/json" \
  -d '{ "amount_rub": "550.00", "cap_amount_rub": "700.00" }'

Body (изменяемые поля)

Поле Тип Описание
amount_rub string Новая плановая сумма списания
cap_amount_rub string Новый потолок на одно списание
cap_window_rub string / null Новый оконный потолок. Передай null, чтобы снять оконный лимит

Нужно передать хотя бы одно из трёх полей, иначе 400 VALIDATION_ERROR. Ответ — обновлённый объект подписки.

Список списаний — GET /subscriptions/:id/charges

Возвращает историю списаний (это платежи с payment_method: "recurring") в формате платежей. Сюда попадают и твои списания через POST /:id/charges, и списание, которое платёжная система сделала сама при подтверждении мандата. Сортировка — по убыванию created_at (первый элемент = самое свежее списание).

curl "https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../charges?page=1&per_page=20" \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."

Query параметры

Параметр Тип По умолчанию
page int 1
per_page int 20 (макс 100)

Ответ

{
  "data": [
    {
      "id": "pay_...",
      "status": "paid",
      "amount_rub": "500.00",
      "payment_method": "recurring",
      "external_id": "sub-charge-2026-07",
      "subscription_id": "sub_550e8400-...",
      "period_ref": "2026-07",
      "created_at": "2026-07-18T10:00:00Z"
    }
  ],
  "pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 }
}

Формат каждого элемента — точно такой же, как у обычного платежа (GET /payments/:id), см. Платежи. Отличие только в payment_method: "recurring".

Как отличить списание при привязке от своего

По полю external_id:

external_id Кто инициировал
твоя строка (если ты её передавал в POST /charges) ты — списание режима A
null платёжная система — списание при привязке мандата

Списаниям, которые инициирует платёжная система, external_id присвоить некому, поэтому он всегда null. Обратное, строго говоря, не гарантировано: если ты вызвал POST /charges без external_id, твоё списание тоже придёт с null.

Практический вывод

Всегда передавай external_id в POST /:id/charges. Тогда правило становится однозначным: external_id == null ⇒ это не твоё списание, а списание при привязке.

Создать списание (режим A) — POST /subscriptions/:id/charges

Инициирует одно списание по активному мандату. Доступно только когда подписка в статусе active.

curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../charges \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_rub": "500.00",
    "external_id": "sub-charge-2026-07"
  }'

Body

Поле Тип Обяз. Описание
amount_rub string Сумма списания. Decimal, до 2 знаков. Должна быть ≤ cap_amount_rub подписки
external_id string Твой ID списания. До 256 символов. Настоятельно рекомендуем передавать — см. как отличить списание при привязке

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

Ответ 201

Ответ — стандартный объект платежа (как у POST /payments), с payment_method: "recurring":

{
  "id": "pay_660e8400-e29b-41d4-a716-446655440111",
  "status": "pending",
  "amount_rub": "500.00",
  "actual_amount_rub": null,
  "amount_usdt": null,
  "external_id": "sub-charge-2026-07",
  "payment_method": "recurring",
  "subscription_id": "sub_550e8400-...",
  "period_ref": "2026-07",
  "created_at": "2026-07-18T10:00:00Z",
  "paid_at": null,
  "settled_at": null,
  "metadata": null
}

Финальный результат списания придёт webhook'ом payment.status_changed (paid / failed и т.д.) — как у обычного платежа. Начальный статус в ответе — pending.

Идемпотентность списания

Идемпотентность списания — по периоду, а не только по заголовку

В одном периоде может быть одно списание. Повторный POST /charges в том же периоде — даже с другим Idempotency-Key — вернёт то же списание (HTTP 200), а не создаст дубль.

Период определяется по времени нашего сервера в момент запроса и считается по UTC от interval подписки:

interval Ключ периода Пример
day YYYY-MM-DD 2026-07-18
week ISO-неделя YYYY-Www 2026-W29
month YYYY-MM 2026-07

interval_count в расчёте периода не участвует: при interval: "month", interval_count: 3 период всё равно календарный месяц, и списывать можно ежемесячно. Ограничение «раз в 3 месяца» реализуй у себя.

Управлять периодом из запроса нельзя — параметра для этого в API нет. Хочешь списать за другой период — дождись его наступления.

Вычислять ключ периода самому не нужно и не надо: он приходит готовым в поле period_ref — и в ответе на POST /charges, и в объекте платежа, и в webhook'е payment.status_changed. Твой расчёт по created_at и наш серверный расчёт разойдутся ровно на границе месяца/ISO-недели и в чужой таймзоне — то есть там, где ошибка стоит двойного списания. Сравнивай period_ref, а не даты.

Границы периода — календарные, а не «раз в 30 дней»

Период идемпотентности не привязан к дате привязки мандата. Мандат, подтверждённый 31 июля, занимает период 2026-07; твоё списание 1 августа попадёт уже в 2026-08 и пройдёт — клиент заплатит дважды за два дня.

Если в первом периоде списание уже сделала платёжная система при привязке (см. общий поток), своё первое списание планируй не «через месяц после привязки», а на следующий календарный период, и перед вызовом сверяйся с GET /:id/charges — по полю period_ref списаний, а не по их датам.

Обратная сторона того же правила: если твоё списание за месяц уже прошло, повтор в том же месяце вернёт 200 и старый платёж. Не считай такой ответ подтверждением нового списания — сверяй id платежа в ответе.

Ошибки списания

HTTP Code Причина Что делать
400 MISSING_IDEMPOTENCY_KEY Не передан заголовок Idempotency-Key Добавь UUID
400 VALIDATION_ERROR Невалидное тело (amount_rub не decimal, забыт Content-Type: application/json) Исправь запрос
404 SUBSCRIPTION_NOT_FOUND Подписка не найдена / чужая / другого режима Проверь ID и режим ключа
409 CHARGE_MANDATE_INACTIVE Подписка не active (pending_binding / paused / cancelled), либо мандат не привязан Дождись активации / resume
409 CAP_EXCEEDED Сумма превышает cap_amount_rub (на списание) или cap_window_rub (за период) Уменьши сумму или подними потолок через PATCH
501 RECURRING_NOT_SUPPORTED Списывать по мандату на этой рельсе нечем. В песочнице обычно значит «песочные подписки не подняты», а не «опция не включена аккаунту» — см. предупреждение выше Платёж не создан, период свободен
503 PROVIDER_NOT_CONFIGURED Рельса этой подписки сейчас не поднята на инстансе Платёж не создан. Повтори позже, напиши в X-Hub если повторяется
503 PROVIDER_UNAVAILABLE Списание не дошло: сеть, таймаут, 5xx у платёжной системы. Дебет мог пройти — мы просто не увидели ответ Платёж остаётся pending. Не создавай второе списание. Повтори тот же запрос — идемпотентность по периоду вернёт то же списание
502 PROVIDER_ERROR Списание отклонено. Сюда попадает реальный отказ банка (нет денег на счёте, мандат отозван на стороне банка), а также осознанный отказ платёжной системы Списание помечено failed, период освобождён. Причину смотри в failure_reason самого списанияGET /subscriptions/:id/charges. От неё зависит, имеет ли смысл повтор

502 PROVIDER_ERROR — это в том числе «банк не дал денег»

Сам HTTP-код на этой ручке общий: и «не хватило средств», и «мандат отозван» приходят как 502 PROVIDER_ERROR. Но причина больше не теряется — она сохраняется у списания в поле failure_reason:

curl "https://api.x-hub.online/api/v1/subscriptions/sub_.../charges?per_page=1" \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."
# → data[0].status == "failed", data[0].failure_reason == "insufficient_funds"

Разбор кодов и рекомендованное действие по каждому — причина отказа. Коротко: insufficient_funds — повторить через 1–3 дня и подписку не отменять; mandate_revoked — повторы бессмысленны, нужна новая привязка.

Не трактуй 502 как «сбой на нашей стороне, надо ретраить вслепую»: для списания это ответ по конкретному клиенту, и что делать — говорит failure_reason. Практическая развилка:

  • 503неопределённость, повторяй тот же запрос;
  • 502отказ, включай свой dunning — но ветку выбирай по failure_reason, а не по факту отказа.

Если отказ повторяется на всех клиентах разом — это уже похоже на нашу проблему, напиши команде X-Hub.

Как связать списание с подпиской

Платёж рекуррентного списания несёт два связующих поля — subscription_id ("sub_...") и period_ref (период, который списание занимает) — и в webhook payment.status_changed (при любом изменении статуса), и в объекте платежа (GET /payments/:id, GET /subscriptions/:id/charges).

Поведение у обоих полей одинаковое, это пара: в webhook у обычных (не подписочных) платежей их нет, в REST-ответе они присутствуют всегда и равны null. Дополнительно списания подписки доступны списком: GET /subscriptions/:id/charges.

Пауза / возобновление / отмена

Три lifecycle-перехода. Все требуют только заголовки авторизации (тело не нужно). Возвращают обновлённый объект подписки.

# Пауза: active → paused
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../pause \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."

# Возобновление: paused → active
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../resume \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."

# Отмена: active|paused → cancelled (терминально)
curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_550e8400-.../cancel \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..."
Действие Переход Правила
POST /:id/pause activepaused Только из active. Иначе 409 INVALID_STATE
POST /:id/resume pausedactive Только из paused. Иначе 409 INVALID_STATE
POST /:id/cancel active / pausedcancelled Терминально. Из cancelled / expired409 INVALID_STATE. Отменить pending_binding через этот эндпоинт нельзя (только active/paused)

Ошибки переходов:

HTTP Code Причина
404 SUBSCRIPTION_NOT_FOUND Подписка не найдена / чужая / другого режима
409 INVALID_STATE Переход недопустим из текущего статуса
409 CONFLICT Подписку одновременно меняли — повтори запрос

Webhooks по подпискам

Списания. Каждое списание — это платёж, поэтому его изменения приходят обычным событием payment.status_changed (тот же payload и та же проверка подписи, что у платежей — см. Webhooks и Verify signature). Отличить списание можно по payment_method: "recurring"; сопоставить с подпиской — по полю subscription_id в самом payload'е ("sub_..."), а с периодом — по period_ref ("2026-07"), без обращения к API. Свой external_id для этого больше не нужен.

{
  "event": "payment.status_changed",
  "payment_id": "pay_660e8400-...",
  "external_id": "sub-charge-2026-07",
  "status": "paid",
  "previous_status": "pending",
  "amount_rub": "500.00",
  "actual_amount_rub": "500.00",
  "amount_usdt": null,
  "payment_method": "recurring",
  "subscription_id": "sub_550e8400-...",
  "period_ref": "2026-07",
  "timestamp": "2026-07-18T10:00:12Z",
  "metadata": null
}

Изменения статуса самой подписки. Когда подписка меняет статус — подтверждение мандата (active), пауза (paused), отмена мерчантом или отзыв на стороне банка/плательщика (cancelled) — прилетает событие subscription.status_changed (та же проверка подписи, что у остальных webhook'ов).

{
  "event": "subscription.status_changed",
  "subscription_id": "sub_550e8400-...",
  "external_id": "sub-order-12345",
  "status": "active",
  "past_due": false,
  "binding_url": "https://sub.nspk.ru/abc-def-ghi",
  "binding_url_pending": false,
  "previous_status": "pending_binding",
  "timestamp": "2026-07-19T10:00:00Z"
}

Это же событие приходит, когда меняется под-статус past_due (банк начал или прекратил взыскание по мандату). В таком случае status не меняется, и previous_status равен status — читай past_due. Подробнее — про past_due.

Тем же событием приезжает ссылка привязки, если на момент ответа POST /subscriptions она ещё не была готова: status остаётся pending_bindingprevious_status равен ему), а в payload'е приходит заполненный binding_url и binding_url_pending: false. Новых типов событий мы для этого не заводили — набор событий закрыт, и обработчик, который уже слушает subscription.status_changed, получает ссылку без единой правки маршрутизации. Подробнее — binding_url_pending.

Это удобно, чтобы узнавать об активации мандата (клиент подтвердил привязку), о просрочке и об отзыве подписки на стороне банка/плательщика — без опроса. Как fallback (или для проверки текущего состояния) всегда доступен GET /subscriptions/:id.

Порядок событий при привязке не гарантирован

Подтверждение мандата порождает два независимых события: активацию подписки и списание первого периода. Они приходят разными webhook'ами и могут прийти в любом порядке — payment.status_changed (payment_method: "recurring", external_id: null) вполне может опередить subscription.status_changedactive.

Обработчик должен переживать оба порядка: не отбрасывай списание из-за того, что подписка у тебя ещё числится pending_binding, и не считай отсутствие списаний на момент активации доказательством того, что первый период не оплачен. Правда о списаниях — в GET /subscriptions/:id/charges.

Неуспешные списания (dunning). Отдельного subscription.charge_failed нет — но это и не нужно: каждое списание это платёж, и его провал приходит обычным payment.status_changed со status: "failed" и payment_method: "recurring" (см. выше). Если банк перевёл мандат во взыскание, дополнительно поднимается флаг past_due у подписки (и приходит subscription.status_changed) — это ловит и те случаи, когда неудачное списание инициировала сама платёжная система, а не ты. Подписка и период указаны в том же payload'е — поля subscription_id и period_ref; свой external_id для сопоставления не нужен.

Sandbox

Подписки полностью работают в sandbox (ключи xh_test_...): ответы содержат "sandbox": true, реальные СБП-мандаты не создаются, внешняя платёжная система не вызывается.

binding_url — ссылка вида https://app.x-hub.online/sandbox/bind/sbx_sub_<hash>. Открывать её не нужно: реального плательщика в песочнице нет, мандат подтверждается сам. Если открыть — страница честно об этом скажет («мандат уже подтверждён»), никаких действий она не предлагает и статус не меняет. Текущее состояние подписки читай через GET /api/v1/subscriptions/:id.

Sandbox Production
Статус сразу после POST /subscriptions active pending_binding
Шаг привязки клиентом нет — мандат подтверждается сам клиент подтверждает мандат по binding_url
binding_url заглушка на app.x-hub.online реальная ссылка СБП
binding_url_pending всегда false — заглушка готова сразу обычно false, но изредка true: ссылка выпускается асинхронно (см. binding_url_pending)
Списание при привязке не происходит происходит: первый период списывается сразу
subscription.status_changed (pending_bindingactive) приходит приходит
POST /:id/charges работает сразу после создания работает после активации мандата

Главное расхождение песочницы с боем — списание при привязке

В песочнице нет плательщика, поэтому дебета при подтверждении мандата не происходит: сразу после POST /subscriptions список GET /:id/charges пуст, и твой первый POST /charges создаёт первое и единственное списание периода.

В бою всё иначе: к моменту, когда подписка станет active, за текущий период уже есть списание, сделанное платёжной системой. Интеграция, которую ты отладил в песочнице по схеме «создал подписку → сразу списал», в бою даст двойной дебет клиента (либо, если период совпал, вернёт 200 с чужим id платежа).

Поэтому сценарий «первое списание» песочницей не проверяется. Обязательно предусмотри в коде ветку из раздела Как это работает — проверку GET /:id/charges перед первым собственным списанием — даже если в песочнице она всегда срабатывает вхолостую.

Остальная механика песочницы совпадает с боевой: идемпотентность по периоду, cap_amount_rub / cap_window_rub, lifecycle-переходы pause / resume / cancel и вебхуки работают так же.

Списание в песочнице само становится paid через 30 секунд

POST /:id/charges в песочнице возвращает платёж в статусе pendingэто нормально и это не тупик. Песочное списание — обычный песочный платёж, поэтому на него действуют те же magic-токены и авто-симуляция, что и на POST /payments:

Что передал в external_id списания Через сколько Итоговый статус
(ничего) 30 сек paid
xhub_test_paid 5 сек paid
xhub_test_fail 5 сек failed c failure_reason: "insufficient_funds"
xhub_test_expire 60 сек expired
xhub_test_mismatch 5 сек amount_mismatch
xhub_test_manual остаётся pending, пока не нажмёшь кнопку на песочной странице

Не опрашивай /charges в цикле, ожидая мгновенного paid

Типичная ошибка: сразу после POST /charges начать поллить GET /subscriptions/:id/charges, увидеть pending и решить, что списание не работает. Просто подожди — по умолчанию через 30 секунд прилетит payment.status_changed со status: paid и payment_method: "recurring".

Провал списания тестируется ТОЛЬКО магическим токеном

В песочнице нет плательщика, у которого могут кончиться деньги, и нет банка, который может отозвать мандат, — поэтому «естественного» провала списания не существует. Единственный способ прогнать свою ветку dunning'а:

curl -X POST https://api.x-hub.online/api/v1/subscriptions/sub_.../charges \
  -H "X-Api-Key: xh_test_..." -H "X-Api-Secret: ..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "amount_rub": "500.00", "external_id": "sub-charge-2026-07 xhub_test_fail" }'

Через 5 секунд придёт payment.status_changed со status: "failed", payment_method: "recurring", subscription_id и failure_reason: "insufficient_funds" — на этом payload'е и стоит писать ветку dunning'а. Полный список причин и рекомендованных действий — причина отказа.

Токен ищется как отдельное слово (\bxhub_test_fail\b), поэтому его можно дописать к своему external_id через пробел или дефис — сопоставление на твоей стороне не сломается. У списаний description не передаётся, так что токен кладётся только в external_id.

failed-списание освобождает период. Деньги не двигались, поэтому повторный POST /charges в том же периоде создаст новое списание, а не вернёт старое. Это верно и в бою.


Обмены (Exchange)

X-Hub поддерживает два направления обменов:

  • USDT → RUB — основной поток: клиент присылает USDT, X-Hub конвертирует и отправляет RUB клиенту на карту по СБП. Создаётся мерчантом через POST /api/v1/exchanges (см. ниже). Предназначено для мерчантов-обменников.
  • RUB → USDT — обратный поток: клиент платит рубли по СБП, X-Hub отправляет USDT на крипто-кошелёк клиента. Создаётся X-Hub для тебя по запросу — публичного API для этого направления нет. Мерчант видит статусы через те же webhooks exchange.status_changed.

Доступно только для мерчантов с настроенной поддержкой нужного направления (сообщается при подключении).

RUB → USDT настраивается X-Hub по твоему запросу. Если ты работаешь как обменник и тебе нужно чтобы клиенты платили RUB и получали USDT — напиши менеджеру X-Hub, мы создадим обмен от твоего имени. Статус-переходы узнаешь через тот же webhook exchange.status_changed.

Жизненный цикл обмена

USDT → RUB (создаётся мерчантом):
awaiting_usdt → usdt_received → rub_sending → completed
                     partial (недополнение USDT — ждём доплату)
                     expired (таймаут 15 минут)

RUB → USDT (создаётся X-Hub):
awaiting_rub → rub_received → usdt_sending → completed
                  partial (клиент недоплатил RUB)

Финальные статусы: completed, failed, expired, cancelled.

Подробные переходы

Из В Условие
pending awaiting_usdt X-Hub получил крипто-адрес для депозита клиента (USDT → RUB)
awaiting_usdt usdt_received Клиент прислал USDT на адрес
awaiting_usdt partial Клиент прислал меньше запрошенной суммы
awaiting_usdt / partial expired Прошло 15 минут, USDT не пришли полностью
usdt_received rub_sending X-Hub инициировал SBP-выплату
rub_sending completed Клиент получил RUB на карту
pending awaiting_rub X-Hub создал SBP-инвойс для приёма RUB от клиента (RUB → USDT)
awaiting_rub rub_received Клиент оплатил через СБП
awaiting_rub / rub_received partial Клиент недоплатил
rub_received usdt_sending X-Hub инициировал отправку USDT на кошелёк клиента (после hold-периода защиты от chargeback, ~5 мин)
usdt_sending completed USDT подтверждены в блокчейне
* cancelled Мерчант отменил обмен до получения средств
* failed Техническая ошибка (bank rejected, validation)

Создать обмен USDT → RUB — POST /exchanges

Доступно только для направления USDT → RUB. Обмены RUB → USDT создаются X-Hub по запросу — публичного POST-эндпоинта нет.

curl -X POST https://api.x-hub.online/api/v1/exchanges \
  -H "X-Api-Key: xh_live_..." \
  -H "X-Api-Secret: ..." \
  -H "Idempotency-Key: f47ac10b-58cc-4372-a567-0e02b2c3d479" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_usdt": "100.00",
    "client_phone": "+79991234567",
    "client_bank_id": "bank100000000111",
    "external_id": "EXCH-2026-001"
  }'

Headers

Header Обяз.
X-Api-Key
X-Api-Secret
Idempotency-Key UUID. Проверяется до чтения тела: без него — 400 MISSING_IDEMPOTENCY_KEY, не-UUID — 400 VALIDATION_ERROR
Content-Type application/json. Без него тело не разбирается вовсе и запрос получает 400 VALIDATION_ERROR — как будто ты не передал ни одного поля

Параметры тела

Поле Обязательно Описание
amount_usdt да Сумма USDT которую клиент пришлёт. Decimal, до 8 знаков после точки, строго больше нуля. Минимум — чтобы итоговый RUB был ≥ 1000
client_phone да Телефон клиента, формат +79991234567 (+ и 10–15 цифр)
client_bank_id да ID банка СБП, в который зачислять рубли клиенту. Формат: bank<цифры>. Справочник доступных банков запрашивайте у команды X-Hub — на MVP передаётся списком, публичный endpoint будет в следующей версии API. Примеры: bank100000000111 (Сбер), bank100000000008 (Альфа-Банк).
external_id нет Ваш ID обмена (до 40 символов, логируется в реквизитах СБП)
idempotency_key нет Legacy, не используйте. Поле принимается ради обратной совместимости, но идемпотентность задаёт заголовок Idempotency-Key — при расхождении побеждает он, а без заголовка запрос вообще не дойдёт до тела

Ответ (201)

{
  "id": "exch_abc123-...",
  "status": "awaiting_usdt",
  "crypto_deposit_address": "TQr...",
  "network": "tron",
  "amount_usdt": "100.00",
  "amount_rub": "8245.00",
  "rate": "82.45",
  "expires_at": "2026-04-19T15:16:00Z",
  "external_id": "EXCH-2026-001"
}

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

  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 Причина
MISSING_IDEMPOTENCY_KEY 400 Не передан заголовок Idempotency-Key. Проверяется первым — до тела запроса
VALIDATION_ERROR 400 Невалидное тело. Самая частая причина — забытый Content-Type: application/json: без него тело не разбирается и все поля выглядят отсутствующими
FEATURE_NOT_ENABLED 400 У вас не подключён USDT→RUB обмен — свяжитесь с X-Hub
FEATURE_DISABLED 503 Направление обменов временно выключено на стороне X-Hub целиком (не про твой аккаунт). Это не ошибка запроса — повтори позже; сроки уточняй у команды
CLIENT_NOT_REGISTERED 422 Клиент не прошёл KYC — запустите /kyc/register
CLIENT_BLOCKED 403 Клиент заблокирован платёжной системой
RATE_INVALID 502 Не удалось получить курс для обмена — повторите через 30–60 сек
PROVIDER_UNAVAILABLE 503 Временная ошибка внешней платёжной системы — повторите через минуту
EXCHANGE_IN_PROGRESS 409 У клиента уже есть активный обмен — дождитесь завершения
AMOUNT_TOO_LOW 400 Итоговый amount_rub < 1000 ₽
AMOUNT_TOO_HIGH 400 amount_rub > 1 000 000 ₽
IDEMPOTENCY_TERMINAL 409 Обмен с этим ключом идемпотентности уже завершён — используйте новый ключ

FEATURE_NOT_ENABLED (400) и FEATURE_DISABLED (503) — разные вещи

400 FEATURE_NOT_ENABLED — опция обменов не подключена твоему аккаунту. Само по себе не пройдёт: нужно обращение в X-Hub.

503 FEATURE_DISABLED — направление выключено у всех, рубильником на нашей стороне (инцидент, работы у платёжной системы). Твоя интеграция исправна; ретрай с тем же Idempotency-Key безопасен. Не пиши в поддержку по первому такому ответу — сначала подожди.

Получить обмен — GET /exchanges/:id

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

Ответ:

{
  "id": "exch_abc123",
  "status": "completed",
  "amount_usdt": "100.00",
  "amount_usdt_received": "100.00",
  "amount_rub": "8000.00",
  "rate": "80.00",
  "crypto_deposit_address": "TR...",
  "network": "tron",
  "client_phone": "+79990001234",
  "expires_at": "2026-04-20T11:00:00Z",
  "completed_at": "2026-04-20T10:15:00Z",
  "external_id": "order-42",
  "created_at": "2026-04-20T10:00:00Z"
}

Поля amount_usdt_received, completed_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": "..."}

Ошибки KYC-эндпоинтов

Одинаковы для всех трёх (/kyc/check, /kyc/upload-url, /kyc/register):

HTTP Code Причина Что делать
400 SANDBOX_NOT_SUPPORTED KYC в песочнице недоступен для конфигурации твоего аккаунта Тестируй KYC в боевом режиме или напиши в X-Hub
400 KYC_NOT_REQUIRED KYC для аккаунта не настроен (/upload-url, /register). У /kyc/check тот же случай даёт 200 {"status":"not_required"} Просто создавай платежи, KYC не нужен
400 VALIDATION_ERROR Невалидный phone / content_type / состав ключей файлов Проверь формат
403 PROVIDER_DISABLED KYC выключен для твоего мерчанта рубильником на нашей стороне. Отдаётся до любой другой проверки — то есть даже валидный запрос получит 403 Это не ошибка запроса и не «не пройден KYC». Ретрай не поможет: напиши в X-Hub
502 PROVIDER_ERROR Проверяющая сторона не ответила (сеть, таймаут, её 5xx) Временная ошибка. Ретрай через 30–60 сек

403 PROVIDER_DISABLED легко спутать с отказом в верификации

Это не «клиент не прошёл KYC» — отказ по клиенту приходит как {"status": "failed"} с 200. 403 PROVIDER_DISABLED означает, что весь KYC-контур твоего аккаунта отключён у нас, и ни один клиент верифицироваться не сможет. Если он появился внезапно — не переписывай интеграцию, сначала спроси команду X-Hub.

Получить URL для загрузки документов — POST /kyc/upload-url

Получает pre-signed URL для загрузки документов KYC (фото паспорта, селфи).

curl -X POST https://api.x-hub.online/api/v1/kyc/upload-url \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
  -H "Content-Type: application/json" \
  -d '{"content_type": "image/jpeg"}'

Body

Поле Тип Обяз. Описание
content_type string MIME-тип файла (image/jpeg, image/png, application/pdf)

Ответ

{
  "upload_url": "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 (подтверждение адреса)
email string * E-mail клиента
user_type string * individual или legal_entity

Какие из полей со звёздочкой нужны — зависит от KYC-конфигурации аккаунта

Конфигураций две, и обязательный набор у них разный:

  • загрузка документов — нужны selfie_key + document_key (плюс опционально address_key);
  • переадресация клиента на форму верификации — нужны email + user_type, файлы не нужны; в ответе придёт {"status": "required", "kycUrl": "..."}, и клиента надо отправить по этому адресу.

Валидация тела не проверяет комбинацию: запрос без нужных полей пройдёт схему и упрётся уже в проверяющую сторону (502 PROVIDER_ERROR или отказ по клиенту). Какая конфигурация у тебя — уточни при подключении.

Ответ

{"status": "processing"}

Либо {"status": "required", "kycUrl": "..."} — если клиента нужно отправить на форму верификации, либо {"status": "verified", "accountNumber": "..."} — если он уже верифицирован.

Повторный register по тому же телефону безопасен

Если клиент уже верифицирован (или ему уже выдана ссылка на верификацию), повторный вызов вернёт сохранённый результат, не запуская проверку заново. Исключение — клиент, которому ранее отказали: такой запрос уйдёт на новую проверку.

После регистрации — периодически проверяй статус через POST /kyc/check.

Интеграция KYC + платежи

Если для вашего аккаунта требуется KYC, при создании платежа передавай client_phone:

curl -X POST https://api.x-hub.online/api/v1/payments \
  -H "X-Api-Key: ..." -H "X-Api-Secret: ..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_rub": "500.00",
    "payment_method": "sbp",
    "client_phone": "+79991234567"
  }'

Если KYC не пройден, API вернёт:

  • 400 KYC_REQUIRED — клиент не верифицирован
  • 400 KYC_PROCESSING — верификация в процессе, попробуй позже
  • 400 KYC_FAILED — верификация отклонена

Ошибки

Формат

{
  "error": {
    "code": "PAYMENT_NOT_FOUND",
    "message": "Payment with id 'pay_xxx' not found",
    "details": {}
  }
}

Таблица

HTTP Code Когда Что делать
400 VALIDATION_ERROR Невалидные параметры запроса Проверь details, исправь запрос
400 MISSING_IDEMPOTENCY_KEY Нет заголовка Idempotency-Key на POST Добавь UUID
400 KYC_REQUIRED Клиент не прошёл KYC (если для аккаунта настроена KYC-проверка) Отправь клиента на KYC-верификацию
400 KYC_PROCESSING KYC-верификация ещё обрабатывается Повтори через несколько минут
400 KYC_FAILED KYC-верификация отклонена Клиент должен пройти KYC заново
400 KYC_NOT_REQUIRED Вызов KYC-эндпоинта, когда KYC не настроен для аккаунта KYC не нужен, просто создавай платёж
502 KYC_PROVIDER_ERROR Проверяющая сторона не ответила при создании платежа (не при вызове /kyc/*) Временная ошибка. Ретрай через 30–60 сек с тем же Idempotency-Key
403 PROVIDER_DISABLED KYC-контур мерчанта отключён рубильником X-Hub. Возвращают все /kyc/* — до любых других проверок Ретрай не поможет — свяжись с X-Hub. См. Ошибки KYC
403 QR_PAYMENTS_DISABLED Аккаунт настроен только на рекуррентные списания — разовые платежи по QR ему запрещены Не создавай POST /payments; работай через /subscriptions. Нужен приём разовых — напиши в X-Hub
400 SANDBOX_NOT_SUPPORTED Эндпоинт отключён в sandbox для твоего аккаунта (у части конфигураций — /kyc/*) Тестируй этот флоу в боевом режиме или свяжись с X-Hub
400 AMOUNT_TOO_LOW Сумма меньше минимальной для операции (пример: обмен < 1000 ₽) Увеличь сумму
400 AMOUNT_TOO_HIGH Сумма превышает максимум Разбей на несколько операций или свяжись с X-Hub
422 CLIENT_NOT_REGISTERED Клиент не прошёл KYC-регистрацию для USDT→RUB Сначала зарегистрируй клиента через /kyc/register
403 CLIENT_BLOCKED Аккаунт клиента заблокирован Свяжись с X-Hub
401 UNAUTHORIZED Неверные X-Api-Key / X-Api-Secret Проверь ключи
401 MODE_MISMATCH Ключ не-активного режима (test/live) — активен другой режим Переключи режим в кабинете или используй ключи активного режима
403 IP_NOT_WHITELISTED IP-адрес не в whitelist мерчанта Добавь IP в настройках или свяжись с X-Hub
404 PAYMENT_NOT_FOUND Платёж не существует / чужой Проверь ID
404 NOT_FOUND Обмен не существует / чужой Проверь ID
410 WITHDRAWAL_DISCONTINUED Запрос к выведенным из эксплуатации эндпоинтам /withdrawals Ничего не делать — выплаты происходят автоматически, см. Выплаты
409 EXCHANGE_IN_PROGRESS Обмен уже в обработке Дождись финального статуса
409 INVALID_STATUS Операция невалидна для текущего статуса (напр. попытка отменить уже завершённый обмен) Проверь статус перед действием
409 IDEMPOTENCY_TERMINAL Повтор запроса с тем же idempotency_key после финального статуса Используй новый ключ
502 RATE_INVALID Временная ошибка получения курса для обмена Ретрай через 30-60 сек
503 PROVIDER_UNAVAILABLE Внешняя платёжная система временно недоступна. Операция могла пройти — мы не увидели ответ Ретрай с тем же Idempotency-Key через 30-60 сек. Второй платёж не создастся
503 PROVIDER_NOT_CONFIGURED Платёжная система не настроена на инстансе Свяжись с X-Hub
400 FEATURE_NOT_ENABLED Опция не подключена на твоём аккаунте (например USDT→RUB или KYC) Свяжись с X-Hub для подключения
503 FEATURE_DISABLED Направление выключено у всех рубильником X-Hub (инцидент / работы). Твоя интеграция исправна Подожди и ретрай. В поддержку — если держится долго
429 RATE_LIMIT_EXCEEDED Превышен лимит. Создание платежа: 120 req/min на ключ или 120 req/min на IP. Остальные вызовы: 60 req/min на ключ или 300 req/min на IP Жди Retry-After секунд, см. Rate Limiting
500 INTERNAL_ERROR Серверная ошибка Ретрай через 30 сек, если повторяется — пиши команде
404 SUBSCRIPTION_NOT_FOUND Подписка не существует / чужая / другого режима Проверь ID
409 CHARGE_MANDATE_INACTIVE Списание по неактивному мандату (подписка не active) Дождись активации / возобнови подписку
409 CAP_EXCEEDED Списание превышает cap_amount_rub или cap_window_rub Уменьши сумму или измени потолки (PATCH)
409 INVALID_STATE Недопустимый lifecycle-переход подписки (pause/resume/cancel) Проверь текущий status
501 RECURRING_NOT_SUPPORTED Списывать по мандату нечем. В бою — опция не подключена аккаунту; в песочнице — не подняты песочные подписки См. Подписки — причины разные, ответ один
502 PROVIDER_ERROR Операция отклонена платёжной системой. На POST /subscriptions — не удалось инициировать привязку мандата; на POST /:id/chargesотказ по списанию, включая отказ банка; на /kyc/* — проверяющая сторона не ответила Для привязки и KYC — повтори через 30–60 сек. Для списания это чаще окончательный отказ, см. Ошибки списания

Validation errors

При 400 VALIDATION_ERROR в details — массив проблем:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "details": [
      {"path": "amount_rub", "message": "Must be a decimal string with up to 2 decimal places"},
      {"path": "payment_method", "message": "Must be 'sbp'"}
    ]
  }
}

Retry-стратегия

Код Retry? Как
4xx нет Fix запрос, ретрай не поможет. Исключения — 429 ниже и 403 PROVIDER_DISABLED / 403 QR_PAYMENTS_DISABLED (их лечит не ретрай, а обращение в X-Hub)
429 да Жди Retry-After / X-RateLimit-Reset
500 да Exponential backoff: 1s, 2s, 4s, 8s, max 5 попыток
501 нет Возможность не подключена — ретрай ничего не изменит
502 зависит PROVIDER_ERROR на привязке мандата и на KYC — ретрай (5s, 15s, 60s). PROVIDER_ERROR на списании — это отказ, а не сбой: ретрай даст то же самое
503 да PROVIDER_UNAVAILABLE / PROVIDER_NOT_CONFIGURED / FEATURE_DISABLED — backoff 30–60 сек, обязательно с тем же Idempotency-Key

Tip

Всегда используй Idempotency-Key на ретраях POST — иначе можно создать дубль платежа.


Примеры: Node.js

Рабочий код на Node.js 18+ с нативным fetch и crypto. Без зависимостей.

Клиент X-Hub

// xhub-client.js
import crypto from 'crypto';

const BASE_URL = 'https://api.x-hub.online/api/v1';

export async function createPayment({ amountRub, externalId, description, metadata }) {
  const res = await fetch(`${BASE_URL}/payments`, {
    method: 'POST',
    headers: {
      'X-Api-Key': process.env.XHUB_API_KEY,
      'X-Api-Secret': process.env.XHUB_API_SECRET,
      'Idempotency-Key': crypto.randomUUID(),
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount_rub: amountRub,
      external_id: externalId,
      payment_method: 'sbp',
      description,
      metadata,
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`X-Hub ${res.status}: ${err.error?.code}: ${err.error?.message}`);
  }

  return res.json();
}

export async function getPayment(id) {
  const res = await fetch(`${BASE_URL}/payments/${id}`, {
    headers: {
      'X-Api-Key': process.env.XHUB_API_KEY,
      'X-Api-Secret': process.env.XHUB_API_SECRET,
    },
  });
  if (!res.ok) throw new Error(`X-Hub ${res.status}`);
  return res.json();
}

export async function getBalance() {
  const res = await fetch(`${BASE_URL}/balance`, {
    headers: {
      'X-Api-Key': process.env.XHUB_API_KEY,
      'X-Api-Secret': process.env.XHUB_API_SECRET,
    },
  });
  return res.json();
}

Webhook receiver (Express)

// webhook-server.js
import express from 'express';
import crypto from 'crypto';
import { grantAccess } from './business-logic.js';

const app = express();
const processedWebhooks = new Set(); // в проде — Redis/DB

app.post(
  '/webhook/xhub',
  express.raw({ type: 'application/json', limit: '100kb' }),
  async (req, res) => {
    try {
      const signature = req.headers['x-webhook-signature'];
      const timestamp = req.headers['x-webhook-timestamp'];

      // Подпись: HMAC-SHA256(timestamp + "." + body, secret)
      const signatureData = timestamp + '.' + req.body.toString('utf-8');
      const expected =
        'sha256=' +
        crypto
          .createHmac('sha256', process.env.XHUB_WEBHOOK_SECRET)
          .update(signatureData)
          .digest('hex');

      if (
        !signature ||
        signature.length !== expected.length ||
        !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
      ) {
        return res.status(401).send('Invalid signature');
      }

      const event = JSON.parse(req.body.toString('utf-8'));
      const dedupeKey = `${event.payment_id || event.withdrawal_id}:${event.status}`;

      if (processedWebhooks.has(dedupeKey)) {
        return res.status(200).send('Already processed');
      }
      processedWebhooks.add(dedupeKey);

      if (event.event === 'payment.status_changed') {
        if (event.status === 'paid') {
          await grantAccess(event.external_id, event.metadata);
        }
      }

      res.status(200).send('OK');
    } catch (err) {
      console.error('Webhook error:', err);
      res.status(500).send('Internal error');
    }
  },
);

app.listen(8080);

Примеры: Python

Python 3.10+ с requests и flask.

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-запросе (и переиспользуется при ретраях того же заказа)
  • [ ] Во всех POST-запросах есть Content-Type: application/json
  • [ ] Обработаны все статусы платежа (paid, settled, failed, cancelled, expired, amount_mismatch, refunded)
  • [ ] Обработчик переживает повторную смену статуса: expired → paid, amount_mismatch → paid/failed, paid/settled → refunded
  • [ ] Пометка заказа по expired обратима — поздняя оплата не оставит клиента без товара
  • [ ] Реализован fallback — GET /payments/:id если webhook не пришёл
  • [ ] Ожидание вебхука не завязано на «10 минут» — доставка может прийти через часы (см. предохранитель)

Рекомендуется

  • [ ] Логирование всех входящих вебхуков
  • [ ] Мониторинг баланса (GET /balance)
  • [ ] Алерты при ошибках вебхуков (5xx ответы)
  • [ ] Exponential backoff при 429/500 от API

FAQ

Можно ли принимать платежи без юрлица в России?

Да, X-Hub выступает агрегатором. Мерчанту нужен только USDT-кошелёк для получения выплат.

Какая минимальная сумма платежа?

1 рубль (1.00 ₽).

Как быстро деньги появятся на балансе?

Рубли зачисляются мгновенно после оплаты клиентом (статус paid). Конвертация в расчётную валюту происходит на следующий день в 12:00 UTC (T+1 settlement).

Можно ли отменить платёж?

Нет — эндпоинта отмены платежа в API нет. После создания платёж либо оплачивается клиентом, либо истекает по expires_at (по умолчанию 15 минут).

Учти: expired — не финал. Если клиент заплатит позже, платёж может подняться в paid в течение 6 часов от создания — см. expired.

Как сделать возврат клиенту?

Через поддержку. Публичного API для возвратов нет — эндпоинта, которым ты мог бы инициировать возврат сам, в API не существует. Напиши команде X-Hub с номером платежа.

Пока возврат идёт, платёж отдаётся с "refund_pending": true — и в GET /payments/:id, и в webhook payment.status_changed. Основной status при этом НЕ меняется: платёж всё ещё paid / settled, потому что деньги ещё не вернулись, а возврат может и не состояться. Не жди нового значения статуса — ориентируйся на этот флаг.

Когда возврат исполнен, платёж переходит в refunded, флаг снимается, и приходит refund.completed (по заявочному возврату) либо payment.status_changed со status: "refunded" — см. Какие статусы означают неуспех.

Сколько списывается с баланса. Комиссия X-Hub берётся за успешный платёж и на возврате не пересматривается: возврат её не отменяет и не уменьшает. Отдельного «сбора за операцию возврата» нет — ни в каком виде.

  • возврат до ежедневного расчёта: начисления ещё не было, с тебя удерживается только комиссия за платёж;
  • возврат после расчёта: начисленное по платежу возвращается, плюс та же комиссия за платёж.

Комиссия считается от полной суммы платежа и при частичном возврате не уменьшается: вернули 300 ₽ из 1000 ₽ — комиссия та же, что и при полном возврате. Ставка — terms.markup_percent в GET /cabinet/me.

Что делать если пришёл amount_mismatch?

Клиент заплатил не ту сумму. Средства в безопасности. Свяжитесь с поддержкой X-Hub для разрешения.

Как я получаю выплаты?

Автоматически. После подтверждения платежей средства зачисляются на баланс и выплачиваются ежедневным расчётом (settlement) — создавать запросы на вывод не нужно, ручные запросы POST /api/v1/withdrawals не поддерживаются (410 WITHDRAWAL_DISCONTINUED). Баланс виден в GET /api/v1/balance и в кабинете.

Webhook не приходит — что делать?

  1. Убедитесь что URL доступен по HTTPS и не отвечает редиректом3xx считается провалом доставки.
  2. Проверьте что сервер отвечает 200 за < 10 секунд.
  3. Убедитесь, что в кабинете заданы и webhook_url, и webhook_secret. Пока пары нет, события копятся и не отправляются вовсе; как только конфигурация полная — уезжают сразу.
  4. Дайте времени больше, чем кажется. Быстрая лестница — 10 попыток с суммарными паузами ~8.5 минут, но если ваш endpoint падал, предохранитель добавляет паузы, которые попытками не считаются, и доставка может прийти спустя часы. А денежное уведомление, отбитое вашим 4xx, ретраится по медленному графику до 41 часа.
  5. Как fallback — используйте GET /payments/:id. Это единственный способ узнать статус здесь и сейчас; вебхук ждать не нужно.
  6. Если событие всё-таки потеряно окончательно — напишите в X-Hub, доставку можно переотправить вручную.

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

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


Поддержка

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