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": "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" }
}
Filters: id, kind, resolution, plus offset, limit, order and sortBy (CREATED_AT,
ID). This list defaults to order=ASC — oldest first — unlike every other list in the API.
An invoice id that does not exist, or belongs to another merchant, is not an error here: the list comes back empty.
txHash is the hash of the on-chain transaction that carried the deposit — what you show your
customer, or substitute for {value} in the chain's explorerTxUrlTemplate from
GET /api/v1/chains. It can be null on older payments, so check it
before you render a link. Do not confuse it with refundTxHash, the transaction you sent back.
Three independent statuses¶
A payment carries three status fields, and each answers a different question.
| 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 follow:
- 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. EXPECTED and ALTERNATIVE payments start ACCEPTED — you
asked for the first and opted in to the second with acceptsAnyToken, so there is nothing to decide.
Screening changes that: a payment of any kind whose amlStatus comes back FAILED returns to
PENDING, because flagged money is yours to decide about.
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. On RIPPLE an X-address is accepted and read as the address and tag it packs |
refundTag |
no | RIPPLE only: the destination tag to send the refund with, an integer 0–4294967295 or null. Only with refundAddress. Refused on any other chain, and when it differs from the tag of an X-address in refundAddress |
A payment can be resolved when its resolution is PENDING and either its kind is not
EXPECTED, or its amlStatus is FAILED. Expected money that passed screening settles on its
own, so there is nothing to decide. Flagged expected money is the exception: it never counted towards
the invoice, so the decision about it is yours.
| Status | message |
Cause |
|---|---|---|
400 |
INVALID_PATH_PARAMETER |
invoiceId or paymentId is not a UUID |
400 |
INVALID_REQUEST_BODY |
resolution is not one of its values, refundAddress is not a string, refundTag is not a whole number 0–4294967295, a refundTag on a chain other than RIPPLE, an X-address for another network, or one whose tag differs from refundTag |
403 |
PERMISSION_DENIED |
The invoice belongs to another merchant |
404 |
INVOICE_NOT_FOUND |
No such invoice |
404 |
INVOICE_PAYMENT_NOT_FOUND |
No such payment, or it belongs to a different invoice |
422 |
EXPECTED_INVOICE_PAYMENT_CANNOT_BE_RESOLVED |
EXPECTED payment that passed screening |
422 |
INVOICE_PAYMENT_ALREADY_RESOLVED |
Already ACCEPTED or REFUNDED |
422 |
REFUND_ADDRESS_REQUIRED |
REFUNDED without a refundAddress |
422 |
REFUND_ADDRESS_NOT_ALLOWED |
refundAddress or refundTag sent with a non-REFUNDED resolution |
The payment then carries what you named as refundAddress and refundTag — an X-address stored as
its classic address and its tag. Refunding to an exchange account on RIPPLE almost always needs the
customer's tag there: without it the money reaches the exchange and nobody's balance on it.
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 |
|---|---|---|
400 |
INVALID_PATH_PARAMETER |
invoiceId or paymentId is not a UUID |
403 |
PERMISSION_DENIED |
The invoice belongs to another merchant |
404 |
INVOICE_NOT_FOUND |
No such invoice |
404 |
INVOICE_PAYMENT_NOT_FOUND |
No such payment, or it belongs to a different invoice |
422 |
INVOICE_PAYMENT_NOT_REFUNDED |
The payment was not resolved as REFUNDED first |
422 |
INVOICE_PAYMENT_REFUND_ALREADY_RECORDED |
A refundTxHash is already recorded |
The full sequence for a payment you want to send back is therefore: