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

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

Инвойс показывает итог, платёж — отдельный депозит. 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": "ACCEPTED",
    "status": "CONFIRMED",
    "confirmations": 19,
    "txHash": "4f1e0c5d8b2a7e9f3c6d1b8a5e2f9c4d7a0b3e6f1c8d5a2b9e4f7c0d3a6b1e8f",
    "refundAddress": null,
    "refundTag": 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 (CREATED_AT, ID). В отличие от всех остальных списков в API, здесь по умолчанию order=ASC — сначала самые старые.

Несуществующий id инвойса или id инвойса другого мерчанта здесь не ошибка: список просто приходит пустым.

txHash — хэш ончейн-транзакции, которой пришёл депозит. Его можно показать клиенту или подставить вместо {value} в explorerTxUrlTemplate сети из GET /api/v1/chains. У старых платежей он может быть null, поэтому проверяйте его, прежде чем строить ссылку. Не путайте его с refundTxHash — транзакцией, которой вы вернули деньги.

Три независимых статуса

У платежа три статусных поля, и каждое отвечает на свой вопрос.

Поле Отвечает на вопрос Значения
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, платежи EXPECTED и ALTERNATIVE — сразу с ACCEPTED: первые вы сами запросили, а принимать вторые согласились флагом acceptsAnyToken, так что решать нечего. Скрининг это меняет: платёж любого kind, у которого amlStatus оказался FAILED, возвращается в PENDING, потому что решение по помеченным деньгам — за вами.

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

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, в остальных случаях отклоняется. На RIPPLE принимается и X-адрес — он читается как адрес и тег, которые в нём упакованы
refundTag нет Только RIPPLE: тег назначения, с которым отправляется возврат, — целое число 0–4294967295 или null. Только вместе с refundAddress. Отклоняется на любой другой сети, а также если расходится с тегом X-адреса в refundAddress

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

Статус message Причина
400 INVALID_PATH_PARAMETER invoiceId или paymentId — не UUID
400 INVALID_REQUEST_BODY resolution не из списка значений, refundAddress — не строка, refundTag — не целое число 0–4294967295, refundTag на сети, отличной от RIPPLE, X-адрес другой сети либо X-адрес, чей тег расходится с refundTag
403 PERMISSION_DENIED Инвойс принадлежит другому мерчанту
404 INVOICE_NOT_FOUND Такого инвойса нет
404 INVOICE_PAYMENT_NOT_FOUND Такого платежа нет, либо он принадлежит другому инвойсу
422 EXPECTED_INVOICE_PAYMENT_CANNOT_BE_RESOLVED Платёж EXPECTED, прошедший скрининг
422 INVOICE_PAYMENT_ALREADY_RESOLVED Уже ACCEPTED или REFUNDED
422 REFUND_ADDRESS_REQUIRED REFUNDED без refundAddress
422 REFUND_ADDRESS_NOT_ALLOWED refundAddress или refundTag отправлен с разрешением, отличным от REFUNDED

После этого у платежа записаны названные вами refundAddress и refundTag — X-адрес сохраняется как классический адрес и его тег. Возврат на биржевой счёт на RIPPLE почти всегда требует тега покупателя на этой бирже: без него деньги дойдут до биржи, но ни на чей баланс там не попадут.

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

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 Причина
400 INVALID_PATH_PARAMETER invoiceId или paymentId — не UUID
403 PERMISSION_DENIED Инвойс принадлежит другому мерчанту
404 INVOICE_NOT_FOUND Такого инвойса нет
404 INVOICE_PAYMENT_NOT_FOUND Такого платежа нет, либо он принадлежит другому инвойсу
422 INVOICE_PAYMENT_NOT_REFUNDED Платёж не был предварительно разрешён как REFUNDED
422 INVOICE_PAYMENT_REFUND_ALREADY_RECORDED refundTxHash уже записан

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

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