Соглашения 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, успешныеGET—200. Никакого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-параметр; повторы объединяются через ИЛИ:
Разные параметры между собой объединяются через И:
В один фильтр помещается не больше 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
рассчитан на вызовы с вашего сервера, а не со страницы в браузере.