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

Соглашения API

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

Форма запроса

  • Базовый URL https://api.pay.nullswap.com, все эндпоинты — под /api/v1. Два пути лежат вне префикса версии — см. раздел ниже.
  • Тела запросов и ответов — JSON. Отправляйте Content-Type: application/json в каждом POST.
  • Имена полей — camelCase. Значения перечислений — UPPER_SNAKE_CASE, кроме имён событий вебхуков: они в нижнем регистре и через точку (invoice.paid).
  • Идентификаторы — UUID.
  • Метки времени — ISO-8601 с миллисекундами в UTC: 2026-01-01T12:00:00.000Z.
  • Успешные POST отвечают 201, успешные GET200. Никакого 204 нигде нет.

Вне префикса версии

Два эндпоинта не относятся к /api/v1 и не требуют API-ключа. Оба вне версионирования, поэтому при смене версии API никуда не переедут.

Путь Что это
/docs Swagger UI — интерактивный справочник по всем маршрутам с этого сайта
/health Проверка живости, отвечает {"status":"ok"}

/docs собирается из самого API, а не пишется руками, — там стоит уточнять точное имя поля, допустимые значения перечисления или форму ответа. Оттуда же можно отправить настоящий запрос прямо из браузера: нажмите Authorize, вставьте свой ключ sk_live_ — и вызовы пойдут как обычные авторизованные запросы, со всеми последствиями для вашего мерчанта. Это справочник, а не учебник: учебник — этот сайт, и читать их лучше вместе.

Ключ, вставленный в Swagger, работает с адреса вашего браузера

API-ключ можно привязать к списку разрешённых адресов, а запрос из Swagger UI уходит оттуда, где сидите вы, а не с вашего сервера. Если у ключа есть белый список, ждите 403 CallerIpNotAllowedError, пока не попробуете с разрешённого адреса. См. Аутентификацию.

/health сообщает, поднято ли API. О вашем мерчанте он не говорит ничего, и успешный ответ не означает, что ключ рабочий: он нужен ровно для того, чтобы отличить «платформа лежит» от «мой запрос отклонили».

Суммы

Любая денежная величина передаётся через API десятичной строкой с целым числом базовых единиц токена, а рядом идёт decimals, чтобы эту строку можно было показать человеку. JSON-числом — никогда.

Токен Как видит человек decimals Как передаётся
USDT (TRON) 25.50 6 "25500000"
ETH 1.0 18 "1000000000000000000"
BTC 0.005 8 "500000"

Дело в точности: 1000000000000000000 не переживёт JSON-парсер, который хранит числа как IEEE-754 double. А платёжный API, молча округляющий суммы, хуже того, который просто откажется их принимать. Разбирайте такие значения большим целым или десятичным типом произвольной точности — и держите их такими у себя, не давая превратиться во float сразу после разбора ответа.

Ещё две величины не кодируются в базовых единицах, а просто домножаются на масштаб, о чём честно сказано там, где они встречаются:

Поле Значение
TokenResponse.price Цена одного целого токена в USD, умноженная на 1018
TokensRateResponse.rate Сколько единиц целевого токена даёт одна единица исходного, умноженное на 1018

Суммы сопоставимы только внутри одного токена

GET /api/v1/invoices/stats возвращает byToken[].expectedAmount и byToken[].paidAmount в базовых единицах, просуммированных внутри одной группы. Это не деньги в привычном смысле: сложив две группы, вы получите бессмысленное число.

Пагинация

Все списки, которые могут неограниченно расти, отвечают одним и тем же конвертом:

{
  "data": [ /* ... */ ],
  "pagination": {
    "offset": 0,
    "limit": 50,
    "total": 137,
    "order": "DESC"
  }
}
Параметр Тип Примечания
offset целое ≥ 0 Сколько строк пропустить
limit целое ≥ 1 Сколько строк вернуть
order ASC | DESC По умолчанию DESC
sortBy перечисление По умолчанию CREATED_AT

У limit нет ни значения по умолчанию, ни максимума

Опустите его — и получите весь результат целиком в одном ответе: все инвойсы, которые вы когда-либо создавали. Всегда отправляйте limit.

Из этих умолчаний есть два исключения:

  • GET /api/v1/invoices/{invoiceId}/payments по умолчанию отдаёт order=ASC. Историю платежей удобнее читать в том порядке, в котором она складывалась; все остальные списки идут от новых к старым.
  • GET /api/v1/chains, GET /api/v1/tokens и GET /api/v1/merchant/balances не пагинируются. Они отвечают { "data": [...] } или { "items": [...] } без объекта pagination: число строк здесь задаётся размером каталога, а не вашим оборотом.

sortBy везде, где он вообще есть, принимает CREATED_AT и ID, а GET /api/v1/invoices — ещё и EXPIRES_AT. Любое другое значение даёт 400 UnsupportedSortByError.

Фильтрация

Фильтр по нескольким значениям — это повторённый query-параметр; повторы объединяются через ИЛИ:

GET /api/v1/invoices?status=PAID&status=PARTIALLY_PAID&limit=100

Разные параметры между собой объединяются через И:

GET /api/v1/tokens?status=ENABLED&symbol=USDT

В один фильтр помещается не больше 500 значений. Всё, что сверх, отклоняется с 400 TooManyFilterValuesError, — разбивайте длинный список идентификаторов на несколько запросов.

Фильтры по датам (createdFrom, createdTo в GET /api/v1/invoices/stats) принимают метки времени ISO-8601; всё, что разобрать не удалось, даёт 400 InvalidDateFilterError.

Ошибки

У любой ошибки есть HTTP-статус и стабильное машиночитаемое имя в message:

{
  "statusCode": 409,
  "message": "InvoiceWithExternalIdAlreadyExistsError",
  "timestamp": "2026-01-01T00:00:00.000Z"
}

message — это идентификатор, а не фраза. Принимайте решения по нему: по тексту — никогда, по одному лишь статусу — почти никогда, ведь 422 в зависимости от маршрута означает шесть разных вещей. Полный список имён — на странице Ошибки.

Безопасные повторы

Сетевые таймауты неизбежны. Вопрос в другом: продублирует ли повторный запрос свой эффект.

Вызов Безопасно повторять?
Любой GET Да, всегда
POST /api/v1/static-wallets Да — идемпотентен по reference: один и тот же референс всегда даёт один и тот же адрес
POST /api/v1/merchant/withdrawals Да, если вы передали externalId. Без него повтор отправит деньги дважды
POST /api/v1/invoices Нет. Повтор с тем же externalId вернёт 409; см. ниже
POST .../resolve, POST .../refund Повтор вернёт 422: платёж уже перешёл в следующее состояние

externalId у инвойса — это защита, а не ключ идемпотентности

Повторный POST /api/v1/invoices с уже занятым externalId вернёт 409 InvoiceWithExternalIdAlreadyExistsError и не отдаст исходный инвойс. Такое поведение вам и нужно — именно оно не даёт одному заказу получить два адреса для депозита, — но логика повтора обязана его учитывать:

POST /api/v1/invoices        -> 409 InvoiceWithExternalIdAlreadyExistsError
GET  /api/v1/invoices?externalId=order-10241&limit=1   -> инвойс, который вы уже создали

У вывода externalId работает ровно наоборот: это настоящий ключ идемпотентности, и повтор вернёт уже созданный вывод.

502 означает, что внутренний сервис отказал раньше, чем ваш запрос успел что-либо изменить, — такие запросы можно спокойно повторять с нарастающей паузой. 500 от шлюза означает, что сервис за ним не ответил вовремя или был недоступен, и вот здесь результат действительно неизвестен: чтение просто повторите, а перед повтором записи сначала сверьтесь — запросите ресурс через GET или найдите его по своему externalId.

Лимиты и заголовки

Сейчас API не возвращает 429 и не отдаёт ни заголовков X-RateLimit-* и Retry-After, ни идентификаторов для корреляции запросов. Это описание текущего поведения, а не обещание на будущее: ограничивайте параллелизм на своей стороне, повторяйте 5xx с нарастающей паузой и не стройте логику в расчёте на неограниченное число запросов.

CORS разрешает только Content-Type и x-api-key, без credentials. Деталь скорее справочная: API рассчитан на вызовы с вашего сервера, а не со страницы в браузере.