Инвойсы¶
Инвойс — это запрос на оплату, привязанный к одному заказу. При его создании за инвойсом закрепляется депозитный адрес в выбранной вами сети, и всё, что придёт на этот адрес, засчитывается именно ему и никакому другому.
Создание инвойса¶
curl -X POST https://api.pay.nullswap.com/api/v1/invoices \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"expectedAmount": "25500000",
"expiresInSeconds": 3600,
"externalId": "order-10241"
}'
| Поле | Тип | Обяз. | Примечания |
|---|---|---|---|
chainId |
UUID | да | Сеть, в которой будет платить покупатель. Берётся из GET /api/v1/chains |
tokenId |
UUID | да | Должен относиться к сети chainId, иначе запрос вернёт 404 TokenWithIdNotFoundError |
expectedAmount |
десятичная строка | да | Строго положительная, в базовых единицах токена |
expiresInSeconds |
целое число | да | 60–604800 (от 1 минуты до 7 дней) |
externalId |
строка | null | нет | Ваш собственный номер заказа. Уникален в пределах мерчанта |
acceptsAnyToken |
булево | нет | По умолчанию false. См. Альтернативные токены |
Поля merchantId здесь нет: мерчанта уже определяет ваш API-ключ.
В ответе приходит сам инвойс:
{
"id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"depositWalletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
"depositAddress": "TQ5s...9Xk2",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"expectedAmount": "25500000",
"acceptsAnyToken": false,
"paidAmount": "0",
"pendingAmount": "0",
"externalId": "order-10241",
"status": "WAITING",
"amlStatus": null,
"createdAt": "2026-01-01T12:00:00.000Z",
"expiresAt": "2026-01-01T13:00:00.000Z",
"updatedAt": "2026-01-01T12:00:00.000Z"
}
Покажите покупателю depositAddress и точную сумму expectedAmount, а после наступления expiresAt
уберите их с экрана.
Всегда отправляйте externalId
Именно он не даёт одному заказу получить два депозитных адреса, если запрос на создание отвалился
по таймауту и ваш клиент его повторил. Повторный запрос вернёт
409 InvoiceWithExternalIdAlreadyExistsError вместо второго инвойса — чтобы восстановиться,
найдите исходный через GET /api/v1/invoices?externalId=order-10241&limit=1. Так же его можно
найти и позже, не сохраняя у себя наш идентификатор.
Ошибки¶
Проверки идут именно в таком порядке, так что вы получите ошибку по первому нарушенному правилу:
| Статус | message |
Причина |
|---|---|---|
400 |
InvalidRequestBodyError |
Поля нет или у него неверный JSON-тип |
403 |
MerchantIsBlockedError |
Мерчант заблокирован |
422 |
InvalidInvoiceExpirationError |
expiresInSeconds вне диапазона 60–604800 |
422 |
InvalidInvoiceAmountError |
expectedAmount — не строка с положительным целым числом базовых единиц |
409 |
InvoiceWithExternalIdAlreadyExistsError |
Этот externalId вы уже использовали |
404 |
ChainWithIdNotFoundError |
Неизвестный chainId |
404 |
TokenWithIdNotFoundError |
Неизвестный tokenId или токен относится к другой сети |
502 |
WalletServiceError |
Не удалось выделить депозитный адрес. Запрос можно повторить |
Чтение инвойсов¶
| Метод | Путь | Назначение |
|---|---|---|
GET |
/api/v1/invoices/{invoiceId} |
Один инвойс |
GET |
/api/v1/invoices |
Список с фильтрами и пагинацией |
GET |
/api/v1/invoices/stats |
Агрегированные количества и объёмы |
GET |
/api/v1/invoices/{invoiceId}/payments |
Отдельные ончейн-депозиты — см. Платежи по инвойсу |
GET /api/v1/invoices фильтрует по id, chainId, tokenId, status и externalId — каждый из
них принимает несколько значений — плюс offset, limit, order и sortBy (CREATED_AT,
EXPIRES_AT, ID).
GET /api/v1/invoices/stats принимает chainId, tokenId, createdFrom и createdTo и возвращает
количества, сгруппированные тремя способами:
{
"total": 137,
"byStatus": [{ "status": "PAID", "count": 96 }],
"byToken": [{
"chainId": "1c9a7f36-...",
"tokenId": "3b7e1a48-...",
"count": 96,
"expectedAmount": "2448000000",
"paidAmount": "2451300000"
}],
"byDay": [{ "date": "2026-01-01", "count": 12, "paidCount": 9 }]
}
Опрос — это запасной вариант, а не интеграция
Подпишитесь на вебхуки, а опрос держите как задачу сверки, которую запускают после простоя вашего эндпоинта. Ни long-polling, ни потокового эндпоинта здесь нет.
Жизненный цикл статусов¶
stateDiagram-v2
direction LR
[*] --> WAITING
WAITING --> PAYMENT_DETECTED
WAITING --> PARTIALLY_PAID
WAITING --> PAID
WAITING --> EXPIRED
PAYMENT_DETECTED --> WAITING
PAYMENT_DETECTED --> PARTIALLY_PAID
PAYMENT_DETECTED --> PAID
PAYMENT_DETECTED --> EXPIRED
PARTIALLY_PAID --> WAITING
PARTIALLY_PAID --> PAYMENT_DETECTED
PARTIALLY_PAID --> PAID
PARTIALLY_PAID --> EXPIRED
PAID --> SWAPPING
SWAPPING --> COMPLETED
SWAPPING --> FAILED
COMPLETED --> [*]
FAILED --> [*]
EXPIRED --> [*]
| Статус | Что означает |
|---|---|
WAITING |
Создан, в сети пока ничего не видно |
PAYMENT_DETECTED |
Транзакция видна, но ещё не подтверждена и не прошла скрининг |
PARTIALLY_PAID |
Пришли прошедшие проверку деньги, но paidAmount всё ещё меньше expectedAmount |
PAID |
paidAmount >= expectedAmount — именно по нему нужно исполнять заказ |
SWAPPING |
Средства обмениваются для расчёта |
COMPLETED |
Зачислено на ваш баланс |
FAILED |
Расчёт не удался |
EXPIRED |
expiresAt наступил без достаточной оплаты |
Три вещи, которые на диаграмме есть, но легко ускользают от внимания:
- Статус может откатываться назад — но только до тех пор, пока инвойс не станет
PAID. Транзакция, вытесненная реоргом, забирает свою сумму с собой, и инвойс возвращается изPARTIALLY_PAIDобратно вWAITING. Поэтому обработчик должен считать тело каждого вебхука снимком текущего состояния, а не приращением к предыдущему. - Из
PAIDведёт единственный путь — вSWAPPING. После оплаты инвойс уже не истечёт и не вернётся в частично оплаченное состояние. Деньги, пришедшие позже, запишутся как неожиданный платёж и наpaidAmountне повлияют. SWAPPING,COMPLETEDиFAILEDвыставляет процесс расчёта, а не вы. Ни один доступный мерчанту маршрут их не запускает — вы только наблюдаете за ними через вебхуки или чтение инвойса.
COMPLETED, FAILED и EXPIRED — терминальные состояния. Суммы после этого ещё могут уточняться,
поэтому запоздавшая транзакция по истёкшему инвойсу будет записана, а не потеряна, — но статус уже не
изменится.
Две суммы¶
pendingAmount и paidAmount меняются независимо друг от друга, и перепутать их — значит начать
раздавать товар бесплатно.
| Учитывает деньги, которые… | Сравнивается с expectedAmount? |
|
|---|---|---|
pendingAmount |
видны в сети, но ещё не подтверждены и не прошли скрининг | нет |
paidAmount |
подтверждены и прошли скрининг | да |
На обе суммы влияют только платежи в той сети и в том токене, которые указаны в инвойсе. Депозиты в другом токене — никогда, что бы вы потом с ними ни решили делать. Статус выводится уже из этих двух величин:
paidAmount >= expectedAmount -> PAID
paidAmount > 0 -> PARTIALLY_PAID
pendingAmount > 0 -> PAYMENT_DETECTED
иначе -> WAITING
pendingAmount — это не деньги, которые у вас есть
Это деньги, которые вы видите. Вашими они становятся, только когда переходят в paidAmount.
Инвойс никогда не достигнет PAID за счёт средств, не прошедших скрининг: помеченные деньги
вычитаются из pendingAmount и никогда не добавляются в paidAmount.
Недоплата и переплата¶
Неверную сумму инвойс не отклоняет — он записывает ровно то, что пришло.
Недоплата. Инвойс остаётся в PARTIALLY_PAID. Покупатель может доплатить до expiresAt,
несколько депозитов складываются. Если срок вышел, а суммы так и не хватило, инвойс закрывается с
уже записанным paidAmount: деньги не потеряны, но инвойс закрыт, и что делать дальше — решать вам.
Переплата. Инвойс переходит в PAID с paidAmount > expectedAmount. Верхней границы нет,
статуса OVERPAID нет, автоматического возврата тоже. Хотите вернуть разницу — сравнивайте эти две
суммы сами.
Проверяйте сумму, а не только статус
PAID значит «пришло не меньше ожидаемого». Если от точной суммы зависит выдача заказа, читайте
paidAmount.
Истечение срока¶
Инвойс истекает по наступлении expiresAt — но не тогда, когда деньги ещё в пути. Срок не сработает,
если у инвойса есть хотя бы один депозит, ждущий подтверждения, или подтверждённый депозит в
альтернативном токене, ждущий вашего решения. Покупатель, заплативший за тридцать секунд до
дедлайна, не потеряет платёж из-за того, что подтверждение пришло через тридцать секунд после.
А вот инвойс в статусе PARTIALLY_PAID истечь может. Частичная оплата останавливает часы только на
то время, пока что-то ещё подтверждается.
Альтернативные токены¶
Поставьте acceptsAnyToken: true — и депозит в другом токене совместимой сети будет записан как
платёж ALTERNATIVE, а не признан ошибкой. В paidAmount он всё равно не попадёт: expectedAmount
выражен в собственном токене инвойса, так что сколько такой депозит стоит и оплачен ли заказ —
решаете вы.
Оставите false (значение по умолчанию) — тот же депозит станет платежом UNEXPECTED. Деньги
записываются и там, и там; флаг меняет только их классификацию и то, спросят ли у вас решение по ним.
См. Платежи по инвойсу.