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

Платежи по инвойсу

Инвойс показывает итог, платёж — отдельный депозит. GET /api/v1/invoices/{invoiceId}/payments перечисляет их по одному: транзакция, отправитель, сумма, токен, вердикт скрининга и классификация.

curl "https://api.pay.nullswap.com/api/v1/invoices/b2f4a6c8-.../payments?limit=50" \
  -H "x-api-key: sk_live_..."
{
  "data": [{
    "id": "d4e5f6a7-b8c9-4012-a345-6b7c8d9e0f12",
    "invoiceId": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
    "chainTransactionId": "aa11bb22-cc33-4d44-8e55-6f7788990011",
    "chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
    "tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
    "fromAddress": "TJ8k...2Ba7",
    "amount": "25500000",
    "kind": "EXPECTED",
    "unexpectedReason": null,
    "resolution": "PENDING",
    "status": "CONFIRMED",
    "confirmations": 19,
    "refundAddress": null,
    "refundTxHash": null,
    "amlResultId": "5c6d7e8f-9a0b-41c2-8d34-5e6f7a8b9c0d",
    "amlStatus": "PASSED",
    "createdAt": "2026-01-01T12:04:11.000Z",
    "updatedAt": "2026-01-01T12:07:52.000Z"
  }],
  "pagination": { "offset": 0, "limit": 50, "total": 1, "order": "ASC" }
}

Фильтры: id, kind, resolution, плюс offset, limit, order и sortBy. В отличие от всех остальных списков в API, здесь по умолчанию order=ASC — сначала самые старые.

Три поля, которые означают разное

У платежа три независимых статуса, и перепутать их между собой — самая частая ошибка на этой странице.

Поле Отвечает на вопрос Значения
status Закрепилась ли транзакция? DETECTED, CONFIRMED, FAILED
amlStatus Пропустил ли её скрининг? null, PASSED, FAILED, SKIPPED
kind Это те деньги, которые запрашивал инвойс? EXPECTED, ALTERNATIVE, UNEXPECTED

Чтобы деньги попали в paidAmount, платёж должен быть CONFIRMED, пройти скрининг (PASSED или SKIPPED) и иметь kind = EXPECTED. Не выполнено хотя бы одно условие из трёх — в итог инвойса деньги не попадут.

confirmations — это снимок, а не живой счётчик

Поле фиксирует глубину на момент записи и дальше не растёт. О завершённости платежа судите по status. А если нужно показывать глубину, берите latestBlockNumber и confirmationBlocks из GET /api/v1/chains.

Как классифицируется платёж

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

flowchart TD
    A["Депозит приходит на депозитный адрес инвойса"] --> B{"Инвойс ещё в процессе оплаты?<br/>WAITING · PAYMENT_DETECTED · PARTIALLY_PAID"}
    B -- нет --> U["UNEXPECTED"]
    B -- да --> C{"Та же сеть и тот же токен,<br/>что и у инвойса?"}
    C -- да --> E["EXPECTED<br/>засчитывается в paidAmount"]
    C -- нет --> D{"acceptsAnyToken"}
    D -- "true" --> F["ALTERNATIVE<br/>записывается, не засчитывается"]
    D -- "false" --> U

Если платёж оказался UNEXPECTED, причину объясняет unexpectedReason. Несовпадение сети или токена всегда перевешивает причину, связанную со временем:

unexpectedReason Значение
WRONG_NETWORK_AND_TOKEN Не совпадают ни сеть, ни токен
WRONG_NETWORK Токен верный, сеть неверная
WRONG_TOKEN Сеть верная, токен неверный
AFTER_EXPIRATION Сеть и токен верные, но срок инвойса уже истёк
AFTER_COMPLETION Сеть и токен верные, но инвойс уже вышел из стадии оплаты — PAID, SWAPPING, COMPLETED или FAILED

Что засчитывается в инвойс

kind status Скрининг pendingAmount paidAmount
EXPECTED DETECTED ещё не выполнен +
EXPECTED CONFIRMED PASSED / SKIPPED +
EXPECTED CONFIRMED FAILED
EXPECTED FAILED любой
ALTERNATIVE любой любой
UNEXPECTED любой любой

Отсюда два следствия, которые стоит проговорить вслух:

  • Помеченные деньги инвойс не оплатят никогда. Они вычитаются из pendingAmount и в paidAmount не попадают — чем бы вы потом ни закрыли такой платёж.
  • Разрешение платежа не двигает суммы. ACCEPTED и REFUNDED фиксируют ваше решение для журнала аудита, и ни то, ни другое на paidAmount не влияет.

Неожиданные платежи

resolution хранит ваше решение по деньгам, которых вы не просили.

resolution Значение
PENDING Ждёт вашего решения
ACCEPTED Вы оставляете его себе
REFUNDED Вы отправляете его обратно

Платёж UNEXPECTED появляется со статусом PENDING, платёж ALTERNATIVE — сразу с ACCEPTED: принимать такие токены вы уже согласились флагом acceptsAnyToken, так что решать нечего и разрешить его нельзя.

Разрешение платежа

curl -X POST https://api.pay.nullswap.com/api/v1/invoices/{invoiceId}/payments/{paymentId}/resolve \
  -H "x-api-key: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "resolution": "REFUNDED", "refundAddress": "TJ8k...2Ba7" }'
Поле Обяз. Примечания
resolution да ACCEPTED или REFUNDED
refundAddress условно Обязателен при REFUNDED, в остальных случаях отклоняется

Разрешить платёж можно, пока он PENDING, и при условии, что либо его kind отличается от EXPECTED, либо amlStatus равен FAILED. Ожидаемые деньги, прошедшие скрининг, расходятся сами — решать по ним нечего. Помеченные ожидаемые деньги — исключение: в инвойс они так и не попали, и кто-то должен сказать, куда их деть.

Статус message Причина
404 InvoicePaymentWithIdNotFoundError Такого платежа нет, либо он принадлежит другому инвойсу
422 ExpectedInvoicePaymentCannotBeResolvedError Платёж EXPECTED, прошедший скрининг
422 InvoicePaymentAlreadyResolvedError Уже ACCEPTED или REFUNDED
422 RefundAddressRequiredError REFUNDED без refundAddress
422 RefundAddressNotAllowedError refundAddress отправлен с разрешением, отличным от REFUNDED

Регистрация возврата

NullSwap не отправляет возвраты

Разрешение REFUNDED фиксирует ваше решение, а не перевод. Деньги вы отправляете обратно сами, со своего кошелька, а потом сообщаете нам хеш транзакции — чтобы он остался в истории платежа.

curl -X POST https://api.pay.nullswap.com/api/v1/invoices/{invoiceId}/payments/{paymentId}/refund \
  -H "x-api-key: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "refundTxHash": "0xabcdef...7890" }'
Статус message Причина
422 InvoicePaymentNotRefundedError Платёж не был предварительно разрешён как REFUNDED
422 InvoicePaymentRefundAlreadyRecordedError refundTxHash уже записан

Целиком последовательность для платежа, который вы решили вернуть, выглядит так:

POST .../resolve   { "resolution": "REFUNDED", "refundAddress": "..." }
  -> вы отправляете возврат со своего кошелька
POST .../refund    { "refundTxHash": "0x..." }