Sandbox
Песочница - изолированное окружение для разработки, автотестов и приёмки. Реальных операций в ней не происходит: денег нет, транзакций в блокчейне нет, платные вызовы не тарифицируются. При этом контракт API, коды ошибок, проверки и вебхуки - те же самые, что в бою, поэтому собранная в песочнице интеграция работает на боевом ключе без переделок.
Как включить
Окружение определяется префиксом ключа - отдельного заголовка или параметра нет.
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Тестовый ключ создаётся в личном кабинете: API-ключи → переключатель MAIN / SANDBOX → Sandbox → Сгенерировать.
- Клиенты, созданные ключом
sk_test_, получаютenv='sandbox'. - Боевой ключ таких клиентов не видит -
404, и наоборот. - Балансы, история, подписки на вебхуки, журнал AML-проверок - отдельные в каждом окружении.
- Мастер-счёт в песочнице синтетический: реальных денег тестовый ключ не касается вовсе.
Чем песочница отличается от боевого окружения
Адреса пополнения
optional
SBX и признаком sandbox: true в ответе. Кастоди за ним нет - не отправляйте на него реальные средства. Адрес детерминирован: один и тот же клиент в одной сети всегда получает один и тот же адрес.Депозиты
optional
simulate-deposit (см. ниже). Принудительная сверка POST /wallet/sync в песочнице всегда возвращает new_deposits: 0 - сверять нечего.Выводы
optional
sbx:…: стадии processing → sending → sent → confirmed проигрываются сразу, вместе с вебхуками customer.withdrawal.sent и customer.withdrawal.confirmed. Ждать подтверждений сети не нужно.Комплаенс (AML)
optional
risk_score. Это позволяет писать стабильные автотесты на реакцию «чистый / рисковый». Внешние источники данных не опрашиваются.Тарификация
optional
price равно null. Ошибку 402 service_balance_insufficient тестовым ключом воспроизвести нельзя.Мастер-счёт
optional
sandbox: true: баланс нулевой, леджер пуст, внутренние переводы не двигают денег, но отвечают той же формой, что и в бою (с wallet_balance и duplicate). Витрина тарифа (service-pricing) при этом отдаёт настоящие действующие условия.Расчёты charge / payout
optional
simulate-deposit, чтобы было что списывать.Вебхуки
optional
env в теле равно sandbox.Проверки и лимиты
optional
Курсы
optional
| Name | Type | Required | Description |
|---|---|---|---|
Адреса пополнения
|
optional |
Возвращается синтетический адрес с префиксом SBX и признаком sandbox: true в ответе. Кастоди за ним нет - не отправляйте на него реальные средства. Адрес детерминирован: один и тот же клиент в одной сети всегда получает один и тот же адрес.
|
|
Депозиты
|
optional |
Приходят только по вызову simulate-deposit (см. ниже). Принудительная сверка POST /wallet/sync в песочнице всегда возвращает new_deposits: 0 - сверять нечего.
|
|
Выводы
|
optional |
Заявка проходит все те же проверки, а затем автоматически завершается синтетическим хешем вида sbx:…: стадии processing → sending → sent → confirmed проигрываются сразу, вместе с вебхуками customer.withdrawal.sent и customer.withdrawal.confirmed. Ждать подтверждений сети не нужно.
|
|
Комплаенс (AML)
|
optional |
Отчёт вычисляется детерминированно по самому адресу: один и тот же адрес всегда даёт один и тот же risk_score. Это позволяет писать стабильные автотесты на реакцию «чистый / рисковый». Внешние источники данных не опрашиваются.
|
|
Тарификация
|
optional |
Платные вызовы в песочнице бесплатны: с мастер-счёта ничего не списывается, в ответе AML-проверки поле price равно null. Ошибку 402 service_balance_insufficient тестовым ключом воспроизвести нельзя.
|
|
Мастер-счёт
|
optional |
Все эндпоинты раздела отвечают синтетикой с признаком sandbox: true: баланс нулевой, леджер пуст, внутренние переводы не двигают денег, но отвечают той же формой, что и в бою (с wallet_balance и duplicate). Витрина тарифа (service-pricing) при этом отдаёт настоящие действующие условия.
|
|
Расчёты charge / payout
|
optional |
Работают на синтетических остатках клиента: сделайте simulate-deposit, чтобы было что списывать.
|
|
Вебхуки
|
optional |
Работают полноценно: подписка, подпись, лестница повторов. Подписка, созданная тестовым ключом, получает только sandbox-события, поле env в теле равно sandbox.
|
|
Проверки и лимиты
|
optional | Действуют те же: минимальные суммы, лимиты на операцию и суточные, формат адреса, блокировка клиента, ожидание комплаенс-вердикта, лимиты частоты запросов. Граничные случаи проверяются в песочнице точно так же, как в бою. | |
Курсы
|
optional | Настоящие: математика пересчёта тестируется на живых значениях. |
403 sandbox_unsupported или 404: приём платежей в рублях и анонимный своп работают только с ключом sk_live_. Ошибку нехватки средств на мастер-счёте (402) и реальную скорость подтверждений сети песочница тоже не воспроизводит.
Симуляция депозита
/api/v1/test/customers/{uuid}/simulate-deposit
Сымитировать поступление на кошелёк клиента
pending, вебхук customer.deposit.detected, комплаенс-проверка. Боевым ключом маршрут отвечает 404.Body
coin
string
required
USDT.network
string
optional
amount
number
required
| Name | Type | Required | Description |
|---|---|---|---|
coin
|
string
|
required |
Тикер монеты, например USDT.
|
network
|
string
|
optional | Сеть, если тикер существует в нескольких. |
amount
|
number
|
required | Сумма поступления, больше нуля. |
Responses
{
"data": {
"id": "1e7b6a54-0c2f-4b39-9d81-6f2a4c8e0b17",
"network": "TRON",
"asset": "USDT",
"direction": "in",
"amount": "100.000000000000000000",
"address": "SBXTRON4F2A9C1E7B6A540C2F4B399D816F2A4C",
"counterparty": "SBXSENDER",
"tx_hash": "sbx:1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d",
"status": "pending",
"confirmations": 0,
"aml_status": "pending",
"frozen": false,
"fee": { "platform": "0.000000000000000000", "merchant": "0.000000000000000000" },
"confirmed_at": null,
"created_at": "2026-08-17T12:45:00+00:00"
},
"sandbox": true
}
{
"error": {
"code": "not_found",
"message": "Ресурс не найден.",
"request_id": "req_Zx8Cv6Bn4Mk2Lj0Hg9Fd7Sa5"
}
}
Узнаваемые значения песочницы
Адрес кошелька
optional
SBX, далее имя сети и детерминированный хвост: SBXTRON4F2A9C…. Ни в одном блокчейн-обозревателе не существует.Хеш транзакции
optional
sbx:. Тоже недействителен вне песочницы.Отправитель
optional
counterparty равен SBXSENDER.Признак ответа
optional
sandbox: true на верхнем уровне (или в объекте адреса) - удобная страховка от случайного запуска тестов боевым ключом.SMS-код KYC
optional
000000 - единственный код, который проходит проверку.| Name | Type | Required | Description |
|---|---|---|---|
Адрес кошелька
|
optional |
Начинается с SBX, далее имя сети и детерминированный хвост: SBXTRON4F2A9C…. Ни в одном блокчейн-обозревателе не существует.
|
|
Хеш транзакции
|
optional |
Начинается с sbx:. Тоже недействителен вне песочницы.
|
|
Отправитель
|
optional |
У симулированного поступления counterparty равен SBXSENDER.
|
|
Признак ответа
|
optional |
Синтетические ответы содержат поле sandbox: true на верхнем уровне (или в объекте адреса) - удобная страховка от случайного запуска тестов боевым ключом.
|
|
SMS-код KYC
|
optional |
000000 - единственный код, который проходит проверку.
|
Сквозной сценарий
Минимальная приёмка контура кошельков: клиент → адрес → депозит → баланс → вывод → расчёт. Занимает несколько секунд и не требует ожиданий - подтверждения в песочнице мгновенные.
KEY="sk_test_..."
B="https://fin-os.io/api/v1"
H="Authorization: Bearer $KEY"
# 1. Клиент (телефон обязателен)
UUID=$(curl -s -X POST $B/customers -H "$H" -H "Content-Type: application/json" \
-d '{"name":"Sandbox Test","phone":"+79001112233"}' | jq -r .data.uuid)
# 2. Адрес пополнения - синтетический, SBX...
curl -s -X POST $B/customers/$UUID/wallet/addresses -H "$H" \
-H "Content-Type: application/json" -d '{"chain":"TRON"}'
# 3. Депозит без блокчейна
curl -s -X POST $B/test/customers/$UUID/simulate-deposit -H "$H" \
-H "Content-Type: application/json" -d '{"coin":"USDT","network":"TRON","amount":"100"}'
# 4. Баланс: 100 USDT_TRON
curl -s -X GET $B/customers/$UUID/wallet -H "$H"
# 5. Предпросмотр и вывод: завершится сразу, status=confirmed.
# Скрин депозита асинхронный даже в песочнице: ответ 409
# aml_screening_in_progress = подождите секунду и повторите
# вывод с тем же Idempotency-Key
curl -s -G $B/customers/$UUID/wallet/withdraw/quote -H "$H" \
--data-urlencode "coin=USDT" --data-urlencode "network=TRON" \
--data-urlencode "address=TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb" \
--data-urlencode "amount=20"
curl -s -X POST $B/customers/$UUID/wallet/withdraw -H "$H" \
-H "Content-Type: application/json" -H "Idempotency-Key: sbx-wd-1" \
-d '{"coin":"USDT","network":"TRON","address":"TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb","amount":"20"}'
# 6. Расчёт: списать с клиента в свою пользу
curl -s -X POST $B/customers/$UUID/wallet/charge -H "$H" \
-H "Content-Type: application/json" -H "Idempotency-Key: sbx-chg-1" \
-d '{"coin":"USDT","network":"TRON","amount":"5","reason":"Комиссия сервиса"}'
# 7. Бесплатная AML-проверка (price=null)
curl -s -X POST $B/aml/checks -H "$H" \
-H "Content-Type: application/json" \
-d '{"address":"TXYZ1234567890abcdefGHIJKLmnop","network":"tron"}'
Чек-лист перед боевым запуском
- Обработчик вебхуков проверяет подпись и отвечает
2xxбыстрее 10 секунд - проверьте вызовомPOST /api/v1/webhooks/{id}/test. - Повторная доставка события не приводит к двойной обработке (дедуп по
id). - Денежные вызовы отправляются с
Idempotency-Key, и ретрай использует тот же ключ. - Разбор ошибок ветвится по
error.code, а не по текстуmessage. - Стадия вывода читается из поля
state, завершение - изstatus. - Мастер-счёт пополнен: в бою первая же AML-проверка на пустом счёте вернёт
402. - Подписки на вебхуки созданы боевым ключом отдельно: sandbox-подписки в бою не работают.
Очистка данных
Sandbox-клиенты и их операции автоматически не удаляются. Удалить клиента можно вызовом DELETE /api/v1/customers/{uuid}, но помните об ограничениях: клиент с выданными адресами, историей движений или ненулевым остатком не удаляется (см. Customers). Для полной очистки окружения напишите на admin@fin-os.io.
Примеры кода
# Минимальная приёмка контура кошельков в песочнице
KEY="sk_test_..."
B="https://fin-os.io/api/v1"
H="Authorization: Bearer $KEY"
UUID=$(curl -s -X POST $B/customers -H "$H" -H "Content-Type: application/json" \
-d '{"name":"Sandbox Test","phone":"+79001112233"}' | jq -r .data.uuid)
curl -s -X POST $B/customers/$UUID/wallet/addresses -H "$H" \
-H "Content-Type: application/json" -d '{"chain":"TRON"}' | jq .data
curl -s -X POST $B/test/customers/$UUID/simulate-deposit -H "$H" \
-H "Content-Type: application/json" -d '{"coin":"USDT","network":"TRON","amount":"100"}' | jq .data.status
curl -s -X GET $B/customers/$UUID/wallet -H "$H" | jq '.data[] | select(.currency=="USDT_TRON")'
// CI-прогон контура кошельков на тестовом ключе
const B = 'https://fin-os.io/api/v1';
const KEY = process.env.FINOS_SANDBOX_KEY; // sk_test_...
const H = { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' };
async function call(path, opts = {}) {
const res = await fetch(`${B}${path}`, { ...opts, headers: { ...H, ...(opts.headers || {}) } });
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error.message), body.error);
return body;
}
export async function walletsSmokeTest() {
const { data: customer } = await call('/customers', {
method: 'POST', body: JSON.stringify({ name: 'CI Smoke', phone: `+7900${Date.now() % 10000000}` }),
});
const { data: address } = await call(`/customers/${customer.uuid}/wallet/addresses`, {
method: 'POST', body: JSON.stringify({ chain: 'TRON' }),
});
console.assert(address.address.startsWith('SBX'), 'ожидался синтетический адрес');
await call(`/test/customers/${customer.uuid}/simulate-deposit`, {
method: 'POST', body: JSON.stringify({ coin: 'USDT', network: 'TRON', amount: '100' }),
});
// В песочнице вывод завершается сразу: status=confirmed, state=confirmed.
// Скрин депозита асинхронный: 409 aml_screening_in_progress - это не ошибка,
// а «подождите» - повторяем с тем же Idempotency-Key.
let wd;
for (let attempt = 0; ; attempt++) {
try {
({ data: wd } = await call(`/customers/${customer.uuid}/wallet/withdraw`, {
method: 'POST',
headers: { 'Idempotency-Key': `ci-${customer.uuid}` },
body: JSON.stringify({ coin: 'USDT', network: 'TRON', address: 'TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb', amount: '20' }),
}));
break;
} catch (e) {
if (e.code !== 'aml_screening_in_progress' || attempt >= 10) throw e;
await new Promise(r => setTimeout(r, 1000));
}
}
console.assert(wd.status === 'confirmed', 'вывод должен закрыться сразу');
}
// Pest / PHPUnit: смоук контура кошельков
test('sandbox wallets flow completes', function () {
$api = Http::withToken(config('services.finos.sandbox_key')) // sk_test_
->acceptJson()
->baseUrl('https://fin-os.io/api/v1');
$customer = $api->post('/customers', [
'name' => 'CI Smoke',
'phone' => '+7900' . random_int(1000000, 9999999),
])->json('data');
$uuid = $customer['uuid'];
$address = $api->post("/customers/{$uuid}/wallet/addresses", ['chain' => 'TRON'])->json('data');
expect($address['address'])->toStartWith('SBX');
expect($address['sandbox'])->toBeTrue();
$api->post("/test/customers/{$uuid}/simulate-deposit", [
'coin' => 'USDT', 'network' => 'TRON', 'amount' => 100,
])->assertStatus(201);
$balance = collect($api->get("/customers/{$uuid}/wallet")->json('data'))
->firstWhere('currency', 'USDT_TRON');
expect(bccomp($balance['available'], '100', 10))->toBe(0);
// Бесплатно: в песочнице price всегда null
$check = $api->post('/aml/checks', ['address' => 'TXYZ1234567890abcdefGHIJKLmnop', 'network' => 'tron'])->json('data');
expect($check['price'])->toBeNull();
});
# pytest: смоук контура кошельков
import os, random, requests
B = 'https://fin-os.io/api/v1'
KEY = os.environ['FINOS_SANDBOX_KEY'] # sk_test_...
H = {'Authorization': f'Bearer {KEY}'}
def test_sandbox_wallets_flow():
phone = f'+7900{random.randint(1000000, 9999999)}'
customer = requests.post(f'{B}/customers', headers=H,
json={'name': 'CI Smoke', 'phone': phone}).json()['data']
uuid = customer['uuid']
address = requests.post(f'{B}/customers/{uuid}/wallet/addresses',
headers=H, json={'chain': 'TRON'}).json()['data']
assert address['address'].startswith('SBX')
r = requests.post(f'{B}/test/customers/{uuid}/simulate-deposit', headers=H,
json={'coin': 'USDT', 'network': 'TRON', 'amount': 100})
assert r.status_code == 201
assert r.json()['sandbox'] is True
# Вывод в песочнице закрывается сразу
wd = requests.post(f'{B}/customers/{uuid}/wallet/withdraw',
headers={**H, 'Idempotency-Key': f'ci-{uuid}'},
json={'coin': 'USDT', 'network': 'TRON',
'address': 'TWd4WrZ9wn84f5x1hZhL4DHvk738ns5jwb',
'amount': '20'}).json()['data']
assert wd['status'] == 'confirmed'
assert wd['state'] == 'confirmed'