Invoice payments¶
An invoice aggregates; a payment is one deposit. GET /api/v1/invoices/{invoiceId}/payments lists
them individually — transaction, sender, amount, token, screening verdict and classification.
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" }
}
Filters: id, kind, resolution, plus offset, limit, order and sortBy. This list defaults
to order=ASC — oldest first — unlike every other list in the API.
Three fields that mean different things¶
A payment carries three orthogonal statuses. Reading one for another is the most common source of confusion here.
| Field | Answers | Values |
|---|---|---|
status |
Did the transaction stick? | DETECTED, CONFIRMED, FAILED |
amlStatus |
Did screening clear it? | null, PASSED, FAILED, SKIPPED |
kind |
Is it the money the invoice asked for? | EXPECTED, ALTERNATIVE, UNEXPECTED |
A payment must be CONFIRMED, cleared (PASSED or SKIPPED) and EXPECTED before it counts
towards paidAmount. Any one of the three failing keeps the money out of the invoice total.
confirmations is a snapshot, not a live counter
It records the depth at the moment the record was written and is not incremented afterwards. Use
status to decide whether a payment has settled. If you want to display depth, read
latestBlockNumber and confirmationBlocks from GET /api/v1/chains.
How a payment is classified¶
kind is decided once, when the deposit is first seen, from the state of the invoice at that moment.
flowchart TD
A["Deposit lands on the invoice's deposit address"] --> B{"Invoice still settling?<br/>WAITING · PAYMENT_DETECTED · PARTIALLY_PAID"}
B -- no --> U["UNEXPECTED"]
B -- yes --> C{"Same chain and same token<br/>as the invoice?"}
C -- yes --> E["EXPECTED<br/>counts towards paidAmount"]
C -- no --> D{"acceptsAnyToken"}
D -- "true" --> F["ALTERNATIVE<br/>recorded, does not count"]
D -- "false" --> U
When a payment is UNEXPECTED, unexpectedReason says why. A token or chain mismatch always wins
over a timing reason:
unexpectedReason |
Meaning |
|---|---|
WRONG_NETWORK_AND_TOKEN |
Neither the chain nor the token matches |
WRONG_NETWORK |
Right token, wrong chain |
WRONG_TOKEN |
Right chain, wrong token |
AFTER_EXPIRATION |
Correct chain and token, but the invoice had already expired |
AFTER_COMPLETION |
Correct chain and token, but the invoice had already moved past settling — PAID, SWAPPING, COMPLETED or FAILED |
What counts towards the invoice¶
kind |
status |
Screening | pendingAmount |
paidAmount |
|---|---|---|---|---|
EXPECTED |
DETECTED |
not yet run | + | — |
EXPECTED |
CONFIRMED |
PASSED / SKIPPED |
− | + |
EXPECTED |
CONFIRMED |
FAILED |
− | — |
EXPECTED |
FAILED |
any | − | − |
ALTERNATIVE |
any | any | — | — |
UNEXPECTED |
any | any | — | — |
Two consequences worth stating plainly:
- Flagged money can never pay an invoice. It is subtracted from
pendingAmountand never added topaidAmount, whatever you later resolve it as. - Resolving a payment does not change any amount.
ACCEPTEDandREFUNDEDrecord your decision for the audit trail; neither movespaidAmount.
Unexpected payments¶
resolution tracks your decision about a payment you were not asked for.
resolution |
Meaning |
|---|---|
PENDING |
Waiting for you to decide |
ACCEPTED |
You are keeping it |
REFUNDED |
You are sending it back |
UNEXPECTED payments start PENDING. ALTERNATIVE payments start ACCEPTED — you opted in to them
with acceptsAnyToken, so there is nothing to decide, and they cannot be resolved.
Resolve a payment¶
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" }'
| Field | Required | Notes |
|---|---|---|
resolution |
yes | ACCEPTED or REFUNDED |
refundAddress |
conditional | Required when REFUNDED, rejected otherwise |
A payment can be resolved when it is PENDING and either its kind is not EXPECTED, or its
amlStatus is FAILED. Clean expected money settles itself and is not yours to decide about;
flagged expected money is the exception, because it never counted towards the invoice and somebody
has to say where it goes.
| Status | message |
Cause |
|---|---|---|
404 |
InvoicePaymentWithIdNotFoundError |
No such payment, or it belongs to a different invoice |
422 |
ExpectedInvoicePaymentCannotBeResolvedError |
EXPECTED payment that passed screening |
422 |
InvoicePaymentAlreadyResolvedError |
Already ACCEPTED or REFUNDED |
422 |
RefundAddressRequiredError |
REFUNDED without a refundAddress |
422 |
RefundAddressNotAllowedError |
refundAddress sent with a non-REFUNDED resolution |
Record a refund¶
NullSwap does not send refunds
Resolving a payment as REFUNDED records a decision, not a transfer. You send the money back
from your own wallet, then report the transaction so it lands on the record.
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" }'
| Status | message |
Cause |
|---|---|---|
422 |
InvoicePaymentNotRefundedError |
The payment was not resolved as REFUNDED first |
422 |
InvoicePaymentRefundAlreadyRecordedError |
A refundTxHash is already recorded |
The full sequence for a payment you want to send back is therefore: