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 TokenWithIdNotFoundError |
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 |
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",
"chainId": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"tokenId": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"expectedAmount": "25500000",
"acceptsAnyToken": false,
"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.
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 InvoiceWithExternalIdAlreadyExistsError 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 |
|---|---|---|
400 |
InvalidRequestBodyError |
A field is missing or of the wrong JSON type |
403 |
MerchantIsBlockedError |
The merchant is blocked |
422 |
InvalidInvoiceExpirationError |
expiresInSeconds outside 60–604800 |
422 |
InvalidInvoiceAmountError |
expectedAmount is not a positive base-unit integer string |
409 |
InvoiceWithExternalIdAlreadyExistsError |
You have already used that externalId |
404 |
ChainWithIdNotFoundError |
Unknown chainId |
404 |
TokenWithIdNotFoundError |
Unknown tokenId, or the token is not on that chain |
502 |
WalletServiceError |
A deposit address could not be reserved. Safe to retry |
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/stats |
Aggregate counts and volumes |
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).
GET /api/v1/invoices/stats takes chainId, tokenId, createdFrom and createdTo, and returns
counts bucketed three ways:
{
"total": 137,
"byStatus": [{ "status": "PAID", "count": 96 }],
"byToken": [{
"chainId": "1c9a7f36-...",
"tokenId": "3b7e1a48-...",
"count": 96,
"expectedAmount": "2448000000",
"paidAmount": "2451300000"
}],
"byDay": [{ "date": "2026-01-01", "count": 12, "paidCount": 9 }]
}
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 --> SWAPPING
SWAPPING --> COMPLETED
SWAPPING --> FAILED
COMPLETED --> [*]
FAILED --> [*]
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 |
The funds are being swapped for settlement |
COMPLETED |
Settled to your balance |
FAILED |
Settlement failed |
EXPIRED |
expiresAt passed without sufficient payment |
Three things the diagram is saying that are easy to miss:
- Statuses can go backwards, but only before
PAID. A transaction that is reorged away takes its amount with it, and the invoice drops fromPARTIALLY_PAIDback toWAITING. Your handler must treat every webhook payload as a snapshot, not as a diff. PAIDonly ever leads toSWAPPING. Once an invoice is paid it can no longer expire and no longer partially unpays. Money that arrives afterwards is recorded as an unexpected payment and does not changepaidAmount.SWAPPING,COMPLETEDandFAILEDare driven by settlement, not by you. There is no merchant-facing route that triggers them; you observe them through webhooks or by reading the invoice.
COMPLETED, FAILED 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 confusing them is the difference between
shipping goods and giving them away.
| 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 is money you can see. It becomes yours only 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 a confirmed deposit in an
alternative token awaiting your decision. A customer who pays thirty seconds before the deadline does
not lose their payment to a confirmation that lands thirty seconds after it.
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.