Invoices¶
An invoice is a request for payment tied to one order. Creating one reserves a deposit address on the chain you chose; anything sent to that address is attributed to that invoice and to no other.
Create an invoice¶
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"
}'
| Field | Type | Required | Notes |
|---|---|---|---|
chainId |
UUID | yes | The chain the customer will pay on. From GET /api/v1/chains |
tokenId |
UUID | yes | Must belong to chainId, or the call is 404 TOKEN_NOT_FOUND, and accept payments (srcStatus: ENABLED), or it is 422 TOKEN_IS_DISABLED |
expectedAmount |
decimal string | yes | Strictly positive, in the token's base units |
expiresInSeconds |
integer | yes | 60–604800 (1 minute – 7 days) |
externalId |
string | null | no | Your own order reference. Unique per merchant |
acceptsAnyToken |
boolean | no | Default false. See Alternative tokens |
autoSweep |
boolean | no | Default true. See Moving funds to your balance |
There is no merchantId field — your API key already names the merchant.
The response is the invoice:
{
"id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"merchantId": "9f1d2c3b-4a5e-4f60-8b71-2c3d4e5f6a7b",
"depositWalletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
"depositAddress": "TQ5s...9Xk2",
"depositTag": null,
"depositXAddress": null,
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"expectedAmount": "25500000",
"acceptsAnyToken": false,
"autoSweep": true,
"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"
}
Show depositAddress and the exact expectedAmount to your customer, and stop showing them once
expiresAt has passed. On RIPPLE show depositTag with them, marked as mandatory: there the
address alone does not name the invoice. See Destination tags on RIPPLE.
depositTag and depositXAddress are null on every other chain.
Always send externalId
It is what stops one order from opening two deposit addresses when a create call times out and
your client retries. A repeat answers 409 INVOICE_EXTERNAL_ID_ALREADY_EXISTS rather than
creating a second invoice — recover by looking the original up with
GET /api/v1/invoices?externalId=order-10241&limit=1. It is also how you find the invoice again
later without storing our id.
Errors¶
Validation runs in this order, so the first failing rule is the error you get:
| Status | message |
Cause |
|---|---|---|
403 |
MERCHANT_BLOCKED |
The merchant is blocked. Checked with your key, before the body is read |
400 |
INVALID_REQUEST_BODY |
A field is missing or of the wrong JSON type, e.g. autoSweep that is not a boolean |
422 |
INVALID_INVOICE_EXPIRATION |
expiresInSeconds outside 60–604800 |
422 |
INVALID_INVOICE_AMOUNT |
expectedAmount is not a positive base-unit integer string |
409 |
INVOICE_EXTERNAL_ID_ALREADY_EXISTS |
You have already used that externalId |
404 |
CHAIN_NOT_FOUND |
Unknown chainId |
404 |
TOKEN_NOT_FOUND |
Unknown tokenId, or the token is not on that chain |
422 |
TOKEN_IS_DISABLED |
The token does not accept payments right now: its srcStatus is DISABLED. See tokens |
502 |
INTERNAL_ERROR |
A deposit address could not be reserved. Safe to retry |
Destination tags on RIPPLE¶
On RIPPLE, the XRP Ledger, a deposit address is not yours alone. Every RIPPLE invoice is paid to
the same depositAddress, and a payment is matched to its invoice by the destination tag it
carries, and by nothing else. The invoice names that tag in two more fields:
| Field | Type | Notes |
|---|---|---|
depositTag |
integer | null | The destination tag the payment must carry, 0–4294967295. Tags are not sequential: show the one returned, never derive one |
depositXAddress |
string | null | depositAddress and depositTag packed into one X-address (X…), for wallets that take an address and no tag. Paying to it is paying to the pair |
{
"id": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"depositWalletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
"depositAddress": "rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3",
"depositTag": 959725,
"depositXAddress": "XVMFQQBMhdouRqhPMuawgBMN1AVFToG5NjwwoxoAYuiUyDF",
"expectedAmount": "25000000",
"status": "WAITING"
}
Show your customer the address, the tag — labelled as required, not as an optional memo — and the
exact amount. You may also show depositXAddress as an alternative to the pair, and it is what a QR
code should encode: a wallet that scans it gets the address and the tag together. Every invoice.*
webhook carries both fields in data as well.
Without the right tag the payment does not reach the invoice
A payment to depositAddress with no destination tag is refused by the network, and the
customer's wallet reports it as failed. A payment with a tag other than depositTag is not
credited to this invoice: a tag we never issued is held and is not credited to any invoice
automatically — contact support with the transaction hash. Exchange withdrawal forms often label
the tag field "optional"; tell your customer it is not.
The amount credited is what the payment delivered to the address, not the amount it named: an XRP Ledger payment can be sent as a partial payment, and only what arrived counts. Under- and overpayment then work as on any other chain — see Under- and overpayment.
Reading invoices¶
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/invoices/{invoiceId} |
One invoice |
GET |
/api/v1/invoices |
List, with filters and pagination |
GET |
/api/v1/invoices/{invoiceId}/payments |
The individual on-chain deposits — see Invoice payments |
GET /api/v1/invoices filters on id, chainId, tokenId, status and externalId, all
repeatable, plus offset, limit, order and sortBy (CREATED_AT, EXPIRES_AT, ID).
Polling is the fallback, not the integration
Subscribe to webhooks and treat a poll as the reconciliation job you run when your endpoint has been down. There is no long-polling and no streaming endpoint.
Status lifecycle¶
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 --> WAITING
PAID --> PAYMENT_DETECTED
PAID --> PARTIALLY_PAID
WAITING --> SWAPPING
PAYMENT_DETECTED --> SWAPPING
PARTIALLY_PAID --> SWAPPING
PAID --> SWAPPING
SWAPPING --> COMPLETED
SWAPPING --> FAILED
FAILED --> SWAPPING
FAILED --> COMPLETED
COMPLETED --> [*]
EXPIRED --> [*]
| Status | What it means |
|---|---|
WAITING |
Created, nothing seen on chain yet |
PAYMENT_DETECTED |
A transaction is visible but not yet confirmed or screened |
PARTIALLY_PAID |
Cleared money arrived, but paidAmount is still under expectedAmount |
PAID |
paidAmount >= expectedAmount — this is the one to fulfil on |
SWAPPING |
A sweep of the deposit address has started and its money is on its way to your balance |
COMPLETED |
No sweep of the deposit address is still running or failed: the money is on your balance |
FAILED |
A sweep of the deposit address gave up. The money is still on the address, and a later sweep picks it up |
EXPIRED |
expiresAt passed without sufficient payment |
Four details in the diagram are easy to miss:
- Statuses can go backwards,
PAIDincluded. A transaction that is reorged away takes its amount with it: the invoice drops fromPARTIALLY_PAIDback toWAITING, and aPAIDinvoice drops back toWAITING,PAYMENT_DETECTEDorPARTIALLY_PAID, whichever its remaining amounts give. Your handler must treat every webhook payload as a snapshot, not as a diff. - Going forward,
PAIDleads only toSWAPPING. While an invoice is paid it cannot expire, and money that arrives meanwhile is recorded as an unexpected payment and does not changepaidAmount. Only a reverted payment takes it out ofPAID; once a sweep has started, a reverted payment no longer moves the status backwards. SWAPPING,COMPLETEDandFAILEDfollow sweeps, not payment. An automatic sweep moves aPAIDinvoice toSWAPPINGonce the sweep is accepted; a partially paid invoice keeps waiting for the rest. Calling the sweep endpoint yourself moves the invoice toSWAPPINGas soon as the call puts money in flight — fromWAITING,PAYMENT_DETECTEDandPARTIALLY_PAIDas well as fromPAID. Either way the invoice becomesCOMPLETEDonce no sweep of the address is still running or failed; dust left on it is not waited for.FAILEDis not final. When a sweep of aSWAPPINGinvoice gives up after its retries, the invoice becomesFAILEDand you receiveinvoice.failed. The money is still on the deposit address: the next sweep — automatic, or one you request — moves the invoice back toSWAPPING, or straight toCOMPLETEDwhen it settles.
invoice.paid can be taken back
A chain reorganisation can revert a payment after invoice.paid was sent. The invoice then leaves
PAID, you receive invoice.payment_failed (and invoice.partially_paid if it lands in
PARTIALLY_PAID), and invoice.paid fires again if the invoice is paid again. If what you
deliver on invoice.paid cannot be undone, check the invoice is still PAID before you release
it.
COMPLETED does not mean paid
A sweep you request moves an invoice to SWAPPING whatever it was paid, so a COMPLETED invoice
can have a paidAmount under expectedAmount, or "0" when only a deposit in another token was
swept. Once you sweep, the status stops telling you whether the order was paid: decide that from
paidAmount before you call the sweep.
COMPLETED and EXPIRED are terminal. Amounts can still be corrected afterwards, so a
late transaction on an expired invoice is recorded rather than lost — but the status will not move
again.
The two amounts¶
pendingAmount and paidAmount move independently and mean different things.
| Counts money that is… | Compared against expectedAmount? |
|
|---|---|---|
pendingAmount |
seen on chain, not yet confirmed and not yet screened | no |
paidAmount |
confirmed and cleared screening | yes |
Only payments in the invoice's own chain and token contribute to either figure. Deposits in another token never do, whatever you decide to do with them. The status is then derived from the two:
paidAmount >= expectedAmount -> PAID
paidAmount > 0 -> PARTIALLY_PAID
pendingAmount > 0 -> PAYMENT_DETECTED
otherwise -> WAITING
pendingAmount is not money you have
It has only been seen on chain. It becomes yours when it moves into paidAmount. An invoice can
never reach PAID on funds that failed screening — flagged money is removed from
pendingAmount and is never added to paidAmount.
Under- and overpayment¶
The invoice does not reject a wrong amount; it records what actually arrived.
Underpayment. The invoice sits at PARTIALLY_PAID. The customer can top it up before expiresAt
and several deposits sum together. If the deadline passes while it is still short, it expires with
paidAmount recorded — the money is not lost, but the invoice is closed and you decide what to do.
Overpayment. The invoice becomes PAID with paidAmount > expectedAmount. There is no cap, no
OVERPAID status and no automatic refund. Compare the two yourself if you refund the difference.
Check the amount, not just the status
PAID means "at least the expected amount cleared". If your fulfilment depends on the exact
figure, read paidAmount.
Expiry¶
An invoice expires when expiresAt passes — but not while money is still in flight. Expiry is
skipped for an invoice that has any deposit still awaiting confirmation, or any confirmed deposit in
an alternative token. A customer who pays just before the deadline therefore does not lose the payment
because its confirmation arrived just after.
A confirmed alternative-token deposit stops expiry for good
The rule does not look at whether you have resolved the payment. An invoice that has received a
confirmed ALTERNATIVE payment stays in its status past expiresAt and never sends
invoice.expired.
A PARTIALLY_PAID invoice does expire. Partial payment stops the clock only for as long as
something is still confirming.
Alternative tokens¶
Set acceptsAnyToken: true and a deposit of a different token on a compatible chain is recorded as an
ALTERNATIVE payment instead of being treated as a mistake. It still does not count towards
paidAmount, because expectedAmount is denominated in the invoice's own token — you decide what it
is worth and whether the order is settled.
Leave it false (the default) and such a deposit becomes an UNEXPECTED payment instead. Either way
the money is recorded; the flag only changes how it is classified and whether you are asked to decide
about it. See Invoice payments.
Moving funds to your balance¶
Money paid into an invoice arrives on its deposit address. It reaches your balance, and becomes something you can withdraw, only when it is swept off that address. A sweep moves one token's whole clean amount on the address in one go, so several payments in the same token travel together.
autoSweep on the invoice decides who starts it:
autoSweep |
What happens to a confirmed, clean payment |
|---|---|
true (default) |
It is swept to your balance automatically, as soon as it has confirmed and cleared screening |
false |
It stays on the deposit address until you sweep it yourself |
Every payment that has confirmed and cleared screening is swept, whatever its kind — EXPECTED,
ALTERNATIVE and UNEXPECTED alike, and whether or not you have resolved it. Money that failed
screening is never swept and never reaches your balance; it stays on the deposit address.
Sweeps are retried without anything on your side. One that could not be started is started again until it is accepted, and one that failed is raised again a limited number of times, so a temporary outage delays the money rather than stranding it. If a sweep has still not gone through, call the sweep endpoint yourself.
An automatic sweep moves a PAID invoice to SWAPPING once it is accepted, and to COMPLETED once
the money is on your balance. An invoice that is not yet
fully paid keeps its status while its first payments are swept, and moves on only after it becomes
PAID. A sweep that gives up turns a SWAPPING invoice FAILED until a later sweep moves the
money; see the status lifecycle.
Set autoSweep: false when you want to choose the moment yourself — for example, to keep a payment
you may resolve as REFUNDED off your balance until you have decided. The sweep is the only way money
leaves a deposit address: NullSwap does not send refunds, so
with false the funds stay exactly where they are until you sweep them.
Dust stays on the address
An amount worth no more than the network fee of moving it is not swept: sweeping it would cost more than it is worth. It stays on the deposit address and goes along with the next sweep of the same token there. On an invoice address a further payment rarely comes, so a dust remainder there usually stays.
Moving money costs a network fee, and you pay it either way. On a chain's native coin the sweep pays its own network fee out of what it moves, so slightly less than was paid arrives. For a token on an account-based chain we pay the network fee in the native coin and withhold its value, in the token at current prices, from the credit — together with the cost of any approvals a converting sweep needs and of the native coin sent to the address to pay for the move. On UTXO chains and on Monero nothing is withheld beyond what the sweep itself pays. Of what arrives, the merchant fee and that network fee are withheld, and the rest is credited to your balance.
The network fee is not a field of the sweep
The sweep reports amount and fee, but not the network fee withheld next to fee, so
amount − fee overstates the credit on a token sweep. What actually reached your balance is
credited in the list of fees withheld.
Sweep a deposit address¶
curl -X POST https://api.pay.nullswap.com/api/v1/wallets/7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a/sweep \
-H "x-api-key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908"
}'
walletId in the path is the invoice's depositWalletId, or the id of a
static wallet. The body is optional:
| Field | Type | Required | Notes |
|---|---|---|---|
chainId |
UUID | no | Sweep only tokens on this chain |
tokenId |
UUID | no | Sweep only this token |
Without a body, every token with a clean amount on the address is swept. The answer is 202 with
one sweep per token:
{
"data": [
{
"id": "0b1c2d3e-4f50-4617-8a29-3b4c5d6e7f80",
"walletId": "7e2a9c14-8b3d-4c56-a789-0f1e2d3c4b5a",
"invoiceId": "b2f4a6c8-0d1e-4f23-9a45-6b7c8d9e0f11",
"staticWalletId": null,
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"trigger": "MANUAL",
"status": "SWEEPING",
"amount": "25500000",
"feeEstimate": null,
"transferRequestId": "4d5e6f70-8192-4a3b-9c4d-5e6f708192a3",
"error": null,
"createdAt": "2026-01-01T12:10:00.000Z",
"updatedAt": "2026-01-01T12:10:01.000Z",
"feePercentage": "150",
"fee": null,
"feeUsd": null,
"settledAt": null,
"creditedTokenId": null,
"dstTokenId": null,
"amountIn": null,
"amountOut": null,
"conversionSkipReason": null
}
]
}
| Field | Meaning |
|---|---|
invoiceId, staticWalletId |
Which kind of address was swept. Exactly one is set |
trigger |
AUTO — started automatically; MANUAL — started by this endpoint |
amount |
Base units of tokenId being moved, then what arrived once SWEPT — in creditedTokenId. null until accepted |
feeEstimate |
Network fee in base units of the chain's native coin. Set only for dust |
transferRequestId |
Our reference for the underlying transfer. Quote it to support |
error |
Why the sweep failed, when it did, as a code: SWEEP_SOURCE_NOT_SUPPORTED (this address cannot be swept), SWEEP_FUNDS_UNAVAILABLE (the money is no longer there to sweep) or SWEEP_FAILED (anything else). Treat an unknown code as SWEEP_FAILED |
feePercentage |
The merchant fee rate this sweep is charged, in basis points, fixed when it was opened |
fee |
The merchant fee withheld out of amount, in base units of creditedTokenId. null until SWEPT |
feeUsd |
fee in USD, multiplied by 1018. null until it has been priced |
settledAt |
When the sweep reached SWEPT. null before that |
creditedTokenId |
The token amount and fee are in once SWEPT: tokenId, or dstTokenId when the sweep was converted. null before that |
dstTokenId |
The target of your auto-conversion rule, copied when the sweep was opened. null without a rule |
amountIn |
On a converted sweep, base units of tokenId sold |
amountOut |
On a converted sweep, base units of dstTokenId delivered |
conversionSkipReason |
Why a sweep covered by a rule went through unconverted. null when it was converted or no rule covered it |
Auto-conversion rules are set in the cabinet, under Convert → Auto-convert. A sweep covered by
one is converted into the rule's target token on its way to your balance, when it can be. When it
cannot, it is credited in its own token and conversionSkipReason says why:
conversionSkipReason |
Why the sweep was not converted |
|---|---|
NO_VENUE |
There is nowhere to swap the token |
NO_QUOTE |
The token has no usable price right now |
BELOW_MINIMUM |
The amount is too small to convert |
ABOVE_LIQUIDITY_LIMIT |
The amount is more than the market takes within its price-impact limit |
GAS_TOO_HIGH |
The swap's network fee would cost too large a share of the amount |
SLIPPAGE |
The live quote was below the floor your slippage allows |
PRICE_DEVIATION |
The live quote was too far from the market price |
SWAP_FAILED |
The swap failed on chain, and the sweep was retried without converting |
TARGET_NOT_HEDGE |
The rule's target token can no longer be converted into |
MERCHANT_BLOCKED |
The merchant was blocked when the sweep was opened |
status |
Meaning |
|---|---|
PENDING |
Recorded, not yet accepted for transfer. It is retried automatically |
SWEEPING |
Accepted; the money is on its way to your balance |
SWEPT |
Credited to your balance: amount − fee, less the network fee on a token sweep. Terminal |
FAILED |
The transfer failed or was refused; read error. Retried automatically a limited number of times, unless the address itself is the problem |
DUST |
Too little to move. Never returned by this endpoint — that case answers 422 instead |
SUPERSEDED |
A FAILED or DUST sweep whose money a later sweep of the same address and token moved. That later sweep credited it; nothing was credited under this one, so never count its amount. Terminal. Not returned by this endpoint, which answers only with sweeps it started or found still active |
A sweep can already be FAILED in the 202 itself, when it was refused the moment it was started.
The 202 means the call was taken, not that every sweep in it is moving: read each status.
There is at most one active (PENDING or SWEEPING) sweep per address and token. If one is already
running — an automatic one, or an earlier call of yours — it is returned as it is, and a new sweep is
started only for the tokens that have none. That is not an error: trigger: "AUTO" in the answer to
your call means the money was already on its way. Repeating the call is therefore safe.
There is no route that reads a sweep in flight
No endpoint returns a sweep by id, and no webhook reports one unless an auto-conversion rule
covers it — then conversion.completed or conversion.skipped carries its sweepId. Once the
answer says SWEEPING,
watch your balance. Calling this endpoint again returns
the sweep while it is still active; once it has finished, a further call finds nothing to move.
A sweep that has settled appears, under the same id, in
the list of fees withheld; a failed one
appears nowhere.
When the address holds nothing clean above dust, the call answers 422 NOTHING_TO_SWEEP and adds
a details object with the estimate, so you can see how far short the amount is:
{
"statusCode": 422,
"message": "NOTHING_TO_SWEEP",
"timestamp": "2026-01-01T12:10:00.000Z",
"details": {
"amount": "150000",
"feeEstimate": "2800000",
"dust": [
{
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"amount": "150000",
"feeEstimate": "2800000"
}
]
}
}
amount is in base units of the token, feeEstimate in base units of the chain's native coin; the
top-level pair repeats the first entry of dust. With no clean amount at all, dust is empty,
amount is "0" and feeEstimate is null.
| Status | message |
Cause |
|---|---|---|
400 |
INVALID_PATH_PARAMETER |
walletId is not a UUID |
400 |
INVALID_REQUEST_BODY |
chainId or tokenId is present but not a UUID |
403 |
MERCHANT_BLOCKED |
The merchant is blocked. Automatic sweeps of money already received carry on; only this call is refused |
404 |
WALLET_NOT_FOUND |
No such address, or it belongs to another merchant |
409 |
WALLET_HAS_NO_OWNER |
The address is not attributed to any merchant, so there is nowhere to credit it. Contact support; retrying will not help |
422 |
NOTHING_TO_SWEEP |
Nothing clean above dust on the address. details carries the estimate |
422 |
SWEEP_AMOUNT_TOO_SMALL |
Not expected here: dust is answered with NOTHING_TO_SWEEP. If you do see it, treat it the same way |
502 |
INTERNAL_ERROR |
What is on the address could not be read. Nothing was started; safe to retry |
502 |
INTERNAL_ERROR |
The tokens on the address could not be looked up. Nothing was started; safe to retry |
Unlike the rest of the API, an address of another merchant is a 404, not a 403: the answer does
not confirm that the id exists.