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

Инвойсы

Инвойс — это запрос на оплату, привязанный к одному заказу. При его создании за инвойсом закрепляется депозитный адрес в выбранной вами сети, и всё, что придёт на этот адрес, засчитывается именно ему и никакому другому.

Создание инвойса

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 целое число да 60604800 (от 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 вне диапазона 60604800
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?status=PAID&status=PARTIALLY_PAID&limit=100&sortBy=EXPIRES_AT&order=ASC

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. Деньги записываются и там, и там; флаг меняет только их классификацию и то, спросят ли у вас решение по ним. См. Платежи по инвойсу.