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 URL | https://app.x-hub.online/sandbox/pay/{payment_id} — рабочая песочная страница оплаты (не заглушка), см. ниже. Точный адрес всегда приходит в payment_url |
Реальная ссылка СБП |
| Смена статуса | Авто-симуляция magic-токенами (см. ниже) или кнопками на песочной странице оплаты — в любой момент, без участия админа X-Hub | Автоматически по факту оплаты |
| Реальные USDT | Нет, только на тестовом балансе | Да |
| Settlement (T+1) | Пропускается | Выполняется |
| 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 перед тем как переносить интеграцию в бой. Отдельно включать подписки для песочницы не нужно. В боевом режиме подписки сейчас недоступны — см. Подписки.
Flow
- Создаёте платёж через API как в prod:
POST /api/v1/payments - В ответе видите
"sandbox": trueиpayment_urlна песочную страницу оплаты - Дальше — два пути, они друг друга не исключают:
- ничего не делать: sandbox сам переведёт платёж в терминальный статус (по умолчанию — happy-path
paidчерез 30 секунд, см. magic-токены); - открыть
payment_urlи нажать нужную кнопку — статус сменится немедленно. Кто сработал первым, тот и выиграл: второй путь после этого просто ничего не делает (idempotent).
- ничего не делать: sandbox сам переведёт платёж в терминальный статус (по умолчанию — happy-path
- Ваш webhook endpoint получает payload и обрабатывает как обычно.
- Проверяете ваш обработчик, баланс, интеграцию.
Magic-токены — сценарии ошибок
Чтобы протестировать не только happy-path, передавайте ключевое слово
в external_id или description. Sandbox опознаёт его и
автоматически триггерит нужный статус:
| Ключевое слово | Delay | Итог |
|---|---|---|
| (ничего) | 30 сек | paid ← default |
xhub_test_paid |
5 сек | paid (быстро) |
xhub_test_fail |
5 сек | failed c failure_reason: "insufficient_funds" |
xhub_test_expire |
60 сек | expired |
xhub_test_mismatch |
5 сек | amount_mismatch (фактическая сумма = amount_rub - 1) |
xhub_test_manual |
— | авто-симуляция выключена: статус двигаешь сам кнопками на песочной странице оплаты |
xhub_test_fail намеренно отдаёт самую частую и самую дорогую причину — «не хватило денег». Это тот случай, где подписку отменять не надо, а надо повторить списание через день-два; написать эту ветку удобнее здесь, чем на живых подписчиках. Полный список кодов — причина отказа.
Поиск токена — case-insensitive и word-boundary (regex \b<token>\b): токен должен быть отдельным «словом», отделённым пробелом, дефисом или пунктуацией. Например description: "Заказ #42 — xhub_test_fail" сработает, а description: "prexhub_test_failpost" — нет (токен склеен с другими символами).
Если токенов в external_id/description несколько, побеждает первый по порядку из таблицы (paid → fail → expire → mismatch); xhub_test_manual имеет приоритет над всеми и просто отключает автомат.
Песочная страница оплаты
payment_url песочного платежа ведёт на рабочую песочную страницу оплаты на app.x-hub.online. Открой его в браузере — увидишь сумму, статус, обратный отсчёт до expires_at и три кнопки:
| Кнопка | Итоговый статус |
|---|---|
| Имитировать успешную оплату | paid |
| Имитировать ошибку | failed |
| Имитировать истечение срока | expired |
Каждое нажатие проходит тот же путь, что и настоящая оплата, — то есть уведомление придёт настоящее, с подписью и с sandbox: true. Страница сама опрашивает статус раз в 3 секунды, поэтому смена статуса авто-симуляцией видна на ней без перезагрузки.
Статус amount_mismatch кнопкой не ставится — его даёт только magic-токен xhub_test_mismatch (или админ X-Hub по запросу).
Те же действия из кода: /api/sandbox/*
Страница — тонкая обёртка над двумя публичными endpoint'ами. Их можно дёргать напрямую из автотестов: авторизация не нужна, заголовки X-Api-Key / X-Api-Secret / Idempotency-Key не передаются.
# Прочитать состояние песочного платежа
curl https://api.x-hub.online/api/sandbox/payment-info/550e8400-e29b-41d4-a716-446655440000
# Перевести его в нужный статус
curl -X POST https://api.x-hub.online/api/sandbox/simulate/550e8400-e29b-41d4-a716-446655440000 \
-H "Content-Type: application/json" \
-d '{"action": "paid"}'
В URL — «голый» UUID, без префикса pay_
В остальном API идентификатор платежа выглядит как pay_550e8400-…. Эти два endpoint'а принимают только UUID-часть — префикс надо отрезать.
GET /api/sandbox/payment-info/{id} → 200:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "PENDING",
"amount_rub": "500.00",
"is_sandbox": true,
"created_at": "2026-04-20T10:00:00.000Z",
"expires_at": "2026-04-20T10:15:00.000Z",
"paid_at": null
}
POST /api/sandbox/simulate/{id} — тело {"action": "paid" | "failed" | "expired"} → 200:
Ошибки:
| HTTP | Code | Причина |
|---|---|---|
| 400 | VALIDATION_ERROR |
action не одно из paid / failed / expired |
| 404 | NOT_FOUND |
Платежа нет или он не песочный. Боевые платежи через эти ручки недоступны в принципе |
| 409 | INVALID_TRANSITION |
Переход недопустим из текущего статуса (например, платёж уже PAID) |
| 409 | STATE_CHANGED |
Статус поменялся параллельно (авто-симуляция успела раньше) — перечитай payment-info |
Не встраивай /api/sandbox/* в боевой код
Эти endpoint'ы существуют только для песочницы и намеренно не требуют авторизации: payment_id — UUIDv4, а боевые платежи отсекаются по is_sandbox. В production аналога нет — платёж туда двигает сама платёжная система.
Практическое следствие: любой, кому известен id твоего песочного платежа, может перевести его в paid. Реальных денег за этим нет, но не используй песочные платежи как доказательство оплаты.
Webhook payload
Тот же формат что в prod, плюс дополнительное поле sandbox: true:
{
"event": "payment.status_changed",
"payment_id": "pay_abc123",
"external_id": "ORD-001",
"status": "paid",
"previous_status": "pending",
"amount_rub": "1000.00",
"actual_amount_rub": null,
"amount_usdt": null,
"payment_method": "sbp",
"metadata": null,
"sandbox": true,
"timestamp": "2026-04-20T10:00:00Z"
}
Подпись через заголовок X-Webhook-Signature: sha256=<hex> — та же HMAC-SHA256, что в production.
Поле amount_usdt в sandbox
В sandbox amount_usdt всегда null — реальная конвертация RUB→USDT не выполняется, settlement пропускается (см. таблицу «Отличия от production» выше). Тестируй только обработку статусов и не опирайся на значение amount_usdt в sandbox.
Переключение Тест↔Боевой
Sandbox и production живут в одном аккаунте — это два слота ключей и переключатель режима, которым ты управляешь сам:
- Подтверждаешь, что интеграция работает в sandbox (ключи
xh_test_...) - В кабинете генерируешь боевые ключи
xh_live_...(второй слот; тестовые ключи никуда не пропадают) - Нажимаешь «Активировать боевой режим» — аккаунт переходит в боевой режим
- Меняешь ключи в своём коде:
xh_test_→xh_live_ - Вернуться в тест можно в любой момент кнопкой «Вернуться в тест» — и обратно, сколько угодно раз
В каждый момент активен один режим. Запрос с ключом не-активного режима → 401 с кодом MODE_MISMATCH и человекочитаемым сообщением, какой режим сейчас активен. Тестовые и боевые данные (платежи, баланс, идемпотентность) полностью изолированы друг от друга.