Кабинет партнёра → Merchant → «Получить API-ключ». Ключ имеет вид smk_abcdefghij_ещё48символов и отображается один раз. При утрате выпустите новый: предыдущий ключ сразу станет недействительным.
Добавьте один заголовок
Authorization: Bearer ваш_ключ.
Проверьте доступ
Запросите баланс. Ответ с полем data означает, что ключ принят.
{
"data": [
{
"asset_id": "usdt", // всегда usdt — другого баланса нет
"available_atomic": "15000000", // 15 USDT в минимальных единицах (6 знаков)
"available": "15", // то же значение в десятичном виде
"held_atomic": "0", // заморожено под незавершённые выплаты
"held": "0"
}
]
}
Если запрос не выполнен
Что пришло
Почему
401 unauthorized
Ключ не передан, содержит пробел или обрезан. После Bearer должен быть полный идентификатор smk_…
401 key_inactive
Ключ отозван: выпущен новый, а в запросе указан предыдущий.
Пять правил
Большинство ошибок интеграции связано с нарушением одного из этих пунктов.
1. Суммы передавайте строкой
Передавайте либо amount — десятичное значение строкой: "0.001", "15", либо amount_atomic — целое в минимальных единицах: "100000" для 0.001 BTC. Укажите ровно одно поле. В ответах возвращаются обе формы: рядом с amount_atomic есть amount.
пример
"amount": "0.001" // так можно
"amount_atomic": "100000" // и так можно
// оба сразу — ошибка 422
2. asset_id берите только из /assets
Допустимые коды: usdt, btc, xmr. Произвольные значения и идентификаторы из блокчейн-эксплорера отклоняются с кодом 422.
Уникальный идентификатор попытки. Если соединение прервалось и результат неизвестен, повторите запрос с тем же ключом: вернётся исходный результат, повторная операция не создастся. Обязателен для POST /invoices, POST /payments, POST /payouts/preview, POST /payouts.
пример
Idempotency-Key: order-42-payment // первая попытка → 201, создано
Idempotency-Key: order-42-payment // повтор → 200, то же самое
Idempotency-Key: order-42-payment // но с другой суммой → 409 conflict
4. Пустых полей в ответе нет
Поля со значением null и пустые строки в ответ не включаются. У неоплаченного платежа отсутствуют actual_amount, credited_amount и fee. Проверяйте наличие ключа, а не его значение.
пример
// неоплаченный платёж:
{ "payment_id": "pay_…", "status": "awaiting_deposit" }
// поля credited_amount здесь просто нет
5. Не закрывайте заказ по статусу expired
Статус expired не означает, что перевод уже не поступит. Адрес отслеживается ещё около 6 часов, опоздавший перевод будет зачислен. Финальные статусы с зачислением: confirmed, confirmed_underpaid, confirmed_overpaid.
Сценарии с примерами
Каждый сценарий описывает полный цикл: последовательность запросов, формат ответа и назначение полей. Комментарии в примерах JSON добавлены для пояснения и в реальных ответах отсутствуют.
Приём оплаты
Клиент платит BTC за заказ на 60 USD
Заказ order-42. Требуется получить около 60 USDT; при курсе BTC около 60 000 ожидаемая сумма от клиента — 0.001 BTC.
1
Получите список доступных активов
Запрос возвращает код актива и decimals — число знаков после запятой.
{
"data": [
{
"asset_id": "btc", // ← значение для последующих запросов
"symbol": "BTC", // для отображения пользователю
"network": "bitcoin", // используется в выплатах
"decimals": 8, // 1 BTC = 100000000 атомарных единиц
"price_usd": "60000" // справочное значение, не для расчётов
},
{
"asset_id": "usdt",
"symbol": "USDT",
"network": "TRON",
"decimals": 6,
"price_usd": "1.00"
}
]
}
2
Фиксируем курс на 10 минут
Фиксирует курс на время, пока клиент находится на странице оплаты. Платёж и адрес на этом шаге не создаются: выполняется только расчёт. В ответе — сумма USDT к зачислению за 0.001 BTC.
Ожидаемая сумма BTC от клиента. Передаётся строкой, не числом.
Idempotency-Key
Необязателен: операция не создаётся. Можно указать, чтобы повторно получить тот же расчёт.
ответ
{
"data": {
"credit_lock_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
// ← сохраните для следующего шага
"asset_id": "btc",
"expected_amount": "0.001", // ожидаемая сумма от клиента
"expected_amount_atomic": "100000",
"credit_usdt": "59.4", // ← столько будет зачислено на баланс
"credit_usdt_atomic": "59400000",
"expires_at": "2026-08-11T17:20:00+00:00"
// ← после этого времени credit-preview недействителен
}
}
Клиенту покажите expected_amount — сколько BTC отправить.
3
Создаём платёж и получаем адрес
На этом шаге создаётся платёж и выдаётся адрес для перевода. Сумму в запросе указывать не нужно — она уже задана в credit-preview на шаге 2.
Укажите клиенту сеть TRC20. Перевод USDT в другой сети на этот адрес не будет зачислен.
Клиент прислал меньше, чем нужно
Ожидалось 0.001 BTC, поступило 0.0009 — например, кошелёк клиента удержал комиссию сети из суммы.
1
Проверяем платёж
Если поступило меньше expected_amount, но сумма достаточна для зачисления, статус будет confirmed_underpaid, а credited_amount окажется ниже расчётного. При слишком малой сумме зачисление не выполняется.
{
"data": {
"payment_id": "pay_01HZX...",
"status": "expired", // окно истекло
"asset_id": "btc",
"expected_amount": "0.001",
"deposit_address": "bc1qexampledepositaddress000000000",
"expires_at": "2026-08-11T18:00:00+00:00"
// полей actual_amount и credited_amount нет — перевод не поступал
}
}
2
Клиент заплатил с опозданием
Если перевод поступит в дополнительном окне наблюдения, платёж перейдёт в confirmed. Не удаляйте заказ по expired: отметьте его как просроченный и продолжайте проверять статус.
Недоплата не закрывает счёт: статус underpaid, тот же адрес ждёт доплату (USDT — 24 часа). Если остаток не пришёл вовремя, счёт завершается статусом partially_paid.
Важно
Не называйте внутренние маршруты в интерфейсе клиента. На странице оплаты только адрес, сумма и сеть.
Возврата через API нет. Второй завершённый перевод — отдельное зачисление, не отмена первого.
Выплаты
Исходящие переводы со своего USDT-баланса.
Отправляем клиенту 15 USDT в BTC
Поле amount — сумма списания с вашего баланса, а не сумма к получению. Сумму получателя возвращает preview.
1
Проверьте доступный баланс
Используйте available, а не сумму available и held. Held — резерв под незавершённые выплаты, к расходу недоступен.
Сумма списания USDT с баланса. Это не сумма к зачислению получателю.
destination_asset_id
Актив получателя. Код из GET /assets.
destination_network
Сеть получателя. Для BTC — bitcoin. Значение берите из поля network в GET /assets.
destination_address
Адрес получателя. Проверьте его до отправки: отменить перевод нельзя.
Idempotency-Key
Обязателен. Используйте отдельный ключ для preview.
ответ
{
"data": {
"preview_id": "c3d4e5f6-7890-abcd-ef12-34567890abcd",
// ← идентификатор для следующего шага
"from_asset_id": "usdt",
"from_amount": "15", // сумма списания
"to_asset_id": "btc",
"to_amount": "0.000221", // ← сумма к получению
"fee_amount": "0.000019", // комиссия в BTC, уже учтена
"expires_at": "2026-08-11T17:20:00+00:00"
// ← срок исполнения preview
}
}
Отображайте пользователю to_amount как сумму к получению. Если сумма не подходит, не исполняйте preview: по истечении expires_at он станет недействительным.
3
Исполните выплату
На этом шаге выполняется списание и отправка. Адрес и сеть необходимо повторить в запросе — это защита от подмены между шагами.
Отказ по бизнес-правилу. Формат ответа совпадает с ошибками валидации.
details.amount
Ошибка привязана к полю — её можно отобразить в форме.
ответ
{
"error": {
"code": "validation_error",
"message": "The request is invalid.",
"details": {
"amount": ["Недостаточно средств на балансе."]
// ← причина отказа относится к полю amount
}
}
}
Резерв не создаётся. Повтор с тем же Idempotency-Key безопасен; предварительно пополните баланс.
Важно
Учитывайте held: если часть средств зарезервирована под другую выплату, available будет меньше ожидаемого.
Служебные сценарии
История платежей
Ledger — журнал операций: зачисления, списания и резервы фиксируются отдельными записями.
1
Получите историю постранично
Записи возвращаются от новых к старым. За один запрос — до 100 записей, следующая страница — по курсору.
Операция уже существовала, в ответе возвращён исходный объект.
ответ
{
"data": {
"payout_id": "payout_01HZX...", // ← тот же идентификатор, что в первом ответе
"external_id": "wd-77",
"debit_amount": "15",
"status": "processing", // статус мог измениться за время ожидания
"created_at": "2026-08-11T17:10:00+00:00"
}
}
HTTP 201 — операция создана в этом запросе. HTTP 200 — операция уже существовала. Оба кода означают успех.
2
Если параметры изменились
Повтор того же ключа с другой суммой или адресом будет отклонён. Это защита от расхождения параметров.
Если это новая выплата, укажите новый Idempotency-Key.
ответ
{
"error": {
"code": "conflict",
"message": "Idempotency-Key was used with different parameters."
}
}
Важно
Сформируйте ключ один раз и сохраните его вместе с заказом. Генерация нового ключа перед каждой отправкой отключает защиту от дублей.
При HTTP 429 повторите запрос с тем же ключом после паузы в несколько секунд.
Вебхуки
Когда меняется инвойс, депозит, выплата, конвертация или баланс, SnapEX отправляет POST-запрос на ваш URL. Вебхук — сигнал, а не источник истины: проверяйте подпись, а для важных решений перечитывайте объект через API.
Настройка
Укажите URL в кабинете
Кабинет партнёра → Инвойсы → карточка «Webhook»: введите URL и нажмите «Сохранить». Один URL на аккаунт, на него приходят все типы событий из таблицы ниже.
Сохраните секрет подписи
При первом сохранении кабинет один раз показывает секрет whsec_…. Храните его только на своём сервере. При смене URL секрет остаётся прежним.
Требования к URL
Абсолютный https:// адрес с публичным хостом: без login:password в URL, не localhost, хост не должен указывать на частный или зарезервированный IP. URL проверяется при сохранении и повторно перед каждой попыткой доставки.
Что приходит
Заголовок
Что произошло
Webhook-Id
Уникальный id события (UUID), совпадает с id в теле. Не меняется при повторах и ручной переотправке — по нему убирайте дубли.
Webhook-Timestamp
Unix-время в секундах, когда отправлена эта попытка. У каждого повтора свежее значение и свежая подпись.
Webhook-Signature
v1= и hex (в нижнем регистре) от HMAC-SHA256 строки «{Webhook-Timestamp}.{сырое тело}». Ключ — секрет целиком, вместе с префиксом whsec_.
{
"id": "6f1c2a8e-3b4d-4f5a-9c7e-1a2b3c4d5e6f", // = Webhook-Id
"type": "invoice.paid", // тип события, см. таблицу ниже
"created_at": "2026-09-25T12:00:10+00:00", // когда произошло событие
"data": { // объект на момент события
"invoice_id": "INV-8K2M1Q0P",
"environment": "live",
"amount_usd": "15.00",
"charge_usd": "15.00",
"credit_usd": "13.85",
"status": "paid",
"external_id": "order-15",
"paid_at": "2026-09-25T12:00:10+00:00"
}
}
Состав data зависит от типа: invoice.* — инвойс в том же виде, что GET /invoices/{id} (attempts пустой, текущая попытка — в payment); payment.* — депозит (payment_id, external_id, status, суммы); payout.* — выплата (payout_id, external_id, status, суммы); conversion.* — конвертация (conversion_id, status, суммы); balance.changed — asset_id, entry_type, available_atomic и held_atomic после изменения.
Типы событий
Событие
Что произошло
invoice.created
Инвойс создан, способ оплаты ещё не выбран (open).
invoice.awaiting
Выбран способ оплаты, выдан адрес (awaiting).
invoice.underpaid
Пришла часть суммы, тот же адрес ждёт доплату (underpaid).
invoice.paid
Полная сумма оплачена и зачислена (paid, финальный). Единственный сигнал для отгрузки заказа.
invoice.partially_paid
Клиент оплатил только часть, окно доплаты закончилось, полученная часть зачислена вам (partially_paid, финальный).
invoice.expired
Срок счёта истёк без оплаты (expired, финальный).
invoice.extra_payment
После оплаты счёта пришёл ещё один перевод; он зачислен отдельно.
payment.created
Создан депозит.
payment.detected
Перевод виден в сети, но ещё не зачислен.
payment.credited
Депозит зачислен (один из статусов confirmed*).
payment.expired
Окно оплаты депозита истекло.
payout.created
Создана выплата, средства зарезервированы.
payout.processing
Выплата отправляется.
payout.completed
Средства доставлены получателю.
payout.failed
Выплата не прошла.
conversion.completed
Конвертация завершена.
conversion.failed
Конвертация не прошла полностью или частично.
balance.changed
Записано движение по балансу: зачисление, резерв или списание.
Отгружайте заказ только по invoice.paid — он отправляется только когда оплачена полная сумма. invoice.underpaid и invoice.partially_paid означают, что сумма неполная.
Инвойсы с environment=test — симуляция: они проходят те же статусы и шлют те же вебхуки (data.environment = "test"), но никогда не меняют реальный баланс.
Доставка и повторы
Успех — любой ответ 2xx. Любой другой код, таймаут или сетевая ошибка — неудача. Тело ответа не анализируется.
Таймауты: 5 с на соединение, 15 с на весь запрос.
Редиректы не выполняются: 301/302 считается неудачей. Указывайте конечный URL.
До 5 попыток на событие: первая, затем повторы примерно через 10 с, 1 мин, 5 мин и 15 мин после каждой неудачи. После пятой неудачи автоматическая доставка прекращается.
В той же карточке — «Последние доставки»: событие, HTTP-код или ошибка, время и номер попытки. Кнопка «Повторить» отправляет событие ещё раз с тем же Webhook-Id.
Доставка «как минимум один раз»: одно событие может прийти несколько раз, а события — не по порядку (повтор старого события может прийти после нового).
Проверка подписи
Прочитайте сырое тело запроса как байты, до разбора JSON. Пересериализованный JSON с подписью не совпадёт.
Посчитайте HMAC-SHA256 от «{Webhook-Timestamp}.{сырое тело}» с ключом — вашим секретом whsec_…, закодируйте в hex.
Сравните «v1=» + hex с заголовком Webhook-Signature функцией постоянного времени: hash_equals, crypto.timingSafeEqual, hmac.compare_digest.
Отклоняйте Webhook-Timestamp, отличающийся от ваших часов больше чем на 5 минут (рекомендация). У повторов свежая метка времени, поэтому они не пострадают.
PHP
<?php
$secret = getenv('SNAPEX_WEBHOOK_SECRET'); // whsec_… из кабинета
$body = file_get_contents('php://input'); // сырое тело, до разбора JSON
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
if (! ctype_digit($timestamp)
|| abs(time() - (int) $timestamp) > 300
|| ! hash_equals($expected, $signature)) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// пропустите, если этот Webhook-Id уже обработан; тяжёлую работу — в очередь
http_response_code(200);
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = process.env.SNAPEX_WEBHOOK_SECRET; // whsec_… из кабинета
// сырое тело, до разбора JSON
app.post('/hooks/snapex', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('Webhook-Timestamp') || '';
const signature = Buffer.from(req.get('Webhook-Signature') || '');
const expected = Buffer.from('v1=' + crypto.createHmac('sha256', secret)
.update(timestamp + '.').update(req.body).digest('hex'));
const ok = /^\d+$/.test(timestamp)
&& Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300
&& signature.length === expected.length
&& crypto.timingSafeEqual(signature, expected);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// пропустите, если этот Webhook-Id уже обработан; тяжёлую работу — в очередь
res.sendStatus(200);
});
Python
import hashlib, hmac, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SNAPEX_WEBHOOK_SECRET"].encode() # whsec_… из кабинета
@app.post("/hooks/snapex")
def snapex_webhook():
body = request.get_data() # сырое тело, до разбора JSON
timestamp = request.headers.get("Webhook-Timestamp", "")
signature = request.headers.get("Webhook-Signature", "")
expected = "v1=" + hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if not (timestamp.isascii() and timestamp.isdigit()) \
or abs(time.time() - int(timestamp)) > 300 \
or not hmac.compare_digest(expected.encode(), signature.encode()):
abort(400)
event = request.get_json()
# пропустите, если этот Webhook-Id уже обработан; тяжёлую работу — в очередь
return "", 200
Чек-лист обработчика
Проверяйте подпись по сырому телу сравнением постоянного времени; при несовпадении отвечайте 400.
Отклоняйте запросы с Webhook-Timestamp старше 5 минут.
Храните обработанные Webhook-Id и игнорируйте повторы. Не полагайтесь на порядок: сравнивайте статус в событии с тем, что у вас уже записано.
Отвечайте 2xx быстро, заметно быстрее 15 секунд, а тяжёлую работу делайте асинхронно.
После вебхука можно перечитать объект через API для подтверждения: GET /invoices/{id}, GET /payments/{id}, GET /payouts/{id}.
Статусы
Бейдж «финал» означает, что статус больше не изменится. Единственное исключение — expired у пополнений: опоздавший перевод всё ещё может прийти.
Пополнения
Статус
Что произошло
Что делать
awaiting_deposit
Адрес выдан, ожидается перевод
Отобразите клиенту адрес и сумму
detected
Перевод обнаружен в сети, зачисление ещё не выполнено
Ожидайте. Заказ не закрывайте
processing
Выполняется конвертация в USDT
Ожидайте
confirmed
Поступила ожидаемая сумма, средства зачислены
Закройте заказ
финал
confirmed_underpaid
Поступило меньше ожидаемого, в допустимых пределах
Сверьте credited_amount и определите, достаточна ли сумма
финал
confirmed_overpaid
Поступило больше ожидаемого, зачислено по credit-preview
Закройте заказ. По превышению обратитесь в поддержку
финал
confirmed_mismatch
Сохранён для совместимости
На новых платежах не устанавливается
финал
manual_review
Требуется ручная проверка
Дождитесь разбора, повторный платёж не создавайте
expired
Срок оплаты истёк, адрес отслеживается ещё ~6 ч
Не удаляйте заказ, продолжайте проверять статус
failed
Сбой на стороне провайдера
Создайте новый платёж
финал
cancelled
Платёж отменён
Создайте новый платёж
финал
refunded
Возврат выполнен провайдером
Сверьте баланс; при расхождениях обратитесь в поддержку
финал
Инвойсы
Статус
Что произошло
Что делать
open
Счёт создан, способ оплаты ещё не выбран
Отдайте клиенту pay_url или выберите asset_id сами
awaiting
Адрес выдан, ожидается перевод
Показывайте адрес и сумму, опрашивайте статус
underpaid
Пришла часть суммы, адрес тот же
Попросите доплатить остаток на этот адрес
paid
Первый полный перевод зачислен
Закройте заказ
финал
partially_paid
Клиент оплатил только часть, окно доплаты закончилось, полученная часть зачислена вам
Не отгружайте как оплаченный заказ; остаток решайте с клиентом
финал
expired
Срок счёта истёк
Создайте новый инвойс
финал
Выплаты
Статус
Что произошло
Что делать
reserved
Средства зарезервированы под выплату
Ожидайте
submitting
Заявка передана провайдеру
Ожидайте
processing
Провайдер отправляет перевод в сеть
Ожидайте
completed
Средства доставлены получателю
Завершено
финал
manual_review
Результат неоднозначен
Повторную выплату не создавайте, дождитесь разбора
failed
Перевод не выполнен, резерв, как правило, возвращён
Проверьте баланс; при необходимости повторите с новым Idempotency-Key
финал
Ошибки
Успешный ответ всегда лежит в data. Ошибка — в error. Больше форматов нет.
как выглядит ошибка
{
"error": {
"code": "validation_error", // машиночитаемый код, см. таблицу ниже
"message": "The request is invalid.",
"details": { // есть не всегда
"destination_address": ["The destination address field is required."]
// ключ — имя поля, которое не прошло
}
}
}
HTTP
code
Когда
Что делать
401
unauthorized
Ключ не передан или неверный
Проверьте заголовок Authorization: Bearer smk_… целиком, без пробелов и переносов
401
key_inactive
Ключ отозван
Предыдущий ключ отозван. Используйте актуальный ключ из кабинета
401
key_expired
У ключа истёк срок
Выпустите новый ключ
404
not_found
Идентификатор не найден или принадлежит другому мерчанту
Используйте payment_id или payout_id из ответа вашего запроса
409
conflict
Idempotency-Key занят другой операцией или external_id уже использован
Для новой операции укажите новый ключ. Для повтора — тот же ключ и те же параметры
422
validation_error
Некорректные поля, истёкший credit-preview, недостаточно средств или отсутствует Idempotency-Key
Причина указана в details: поле и описание
429
rate_limited
Превышен лимит запросов
Повторите запрос позже с тем же Idempotency-Key
500
internal_error
Внутренняя ошибка сервера
Повторите позже с тем же Idempotency-Key. При устойчивой ошибке обратитесь в поддержку и укажите X-Request-Id