Chains and tokens¶
Every invoice, payment, balance and withdrawal names a chainId and a tokenId. Both are NullSwap
UUIDs, not chain-native identifiers — there is no route anywhere that takes "ethereum" or 1 or a
contract address in place of them. These two endpoints are how you turn what you know into the ids
you need.
Chains¶
{
"data": [
{
"id": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"name": "Ethereum",
"networkChainId": 1,
"type": "EVM",
"logoURI": "https://cdn.pay.nullswap.com/chains/ethereum.svg",
"priority": 10,
"confirmationBlocks": 12,
"latestBlockNumber": 21345678,
"explorerTxUrlTemplate": "https://etherscan.io/tx/{value}",
"explorerAddressUrlTemplate": "https://etherscan.io/address/{value}"
}
]
}
Filters: id, type, name. All repeatable, all exact matches — name is compared
case-insensitively but is not a substring search, so name=Ethereum works and name=eth returns
nothing.
| Field | Notes |
|---|---|
networkChainId |
The EIP-155 id. null on non-EVM chains, which do not have one |
type |
The chain family: EVM, TRON, SOLANA, BITCOIN, LITECOIN, TON, MONERO |
priority |
Display order hint. Lower sorts first |
confirmationBlocks |
Depth a payment must reach before it counts as CONFIRMED |
latestBlockNumber |
Head of the chain as last seen by our indexer |
explorerTxUrlTemplate |
Substitute a transaction hash for the literal {value}. Empty string if the chain has no explorer |
explorerAddressUrlTemplate |
Same, with an address |
The response is { "data": [...] } with no pagination object — the list is short and complete,
and offset/limit are not accepted.
latestBlockNumber is the only field here that moves minute to minute; everything else is stable
enough to cache for a day. Pairing it with the blockNumber on a payment tells you how deep that
payment is right now:
which is worth knowing because the confirmations on a payment is a snapshot from when the record
was last written, not a live counter.
Tokens¶
curl "https://api.pay.nullswap.com/api/v1/tokens?symbol=USDT&status=ENABLED" \
-H "x-api-key: sk_live_..."
{
"data": [
{
"id": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908",
"chain": {
"id": "1c9a7f36-5f2b-4a83-9d51-0e6b7c8d9a10",
"name": "Ethereum",
"networkChainId": 1,
"type": "EVM",
"logoURI": "https://cdn.pay.nullswap.com/chains/ethereum.svg",
"priority": 10,
"confirmationBlocks": 12,
"latestBlockNumber": 21345678,
"explorerTxUrlTemplate": "https://etherscan.io/tx/{value}",
"explorerAddressUrlTemplate": "https://etherscan.io/address/{value}"
},
"currency": {
"id": "7d8e9f0a-1b2c-4d3e-8f40-5a6b7c8d9e0f",
"name": "Tether USD",
"symbol": "USDT",
"logoURI": "https://cdn.pay.nullswap.com/tokens/usdt.svg",
"decimals": 6,
"priority": 20
},
"address": "0xdac17f958d2ee523a2206206994597c13d831ec7",
"type": "ERC20",
"category": "HEDGE",
"maxAmountOut": "500000000000",
"decimals": 6,
"price": "1000200000000000000",
"status": "ENABLED",
"monitoringCode": "usdt_eth"
}
]
}
Each token embeds its whole chain, so one call gives you everything you need to render a picker without joining anything yourself. Also unpaginated.
| Filter | Matching |
|---|---|
query |
Substring, case-insensitive, across name, symbol and contract address |
id, chainId, currencyId, address, symbol |
Exact, case-insensitive, repeatable |
status |
ENABLED, DISABLED |
category |
NATIVE, HEDGE, ASSET |
Different filters are ANDed; repeated values of one filter are ORed. So
?chainId=A&chainId=B&status=ENABLED means (chain A or chain B) and enabled.
Currency versus token¶
A currency is the asset as a human names it — "Tether USD", USDT, 6 decimals. A token is
that currency deployed on one chain at one address. USDT on Ethereum and USDT on Tron are two tokens
sharing one currency.id.
Invoices, balances and withdrawals all address the token. currency.id is only useful for
grouping in your own UI.
decimals lives in two places and they are not interchangeable
token.decimals is what you use to render amount, expectedAmount, paidAmount, and every
balance and withdrawal figure. token.currency.decimals describes the currency in the abstract.
They agree for essentially every listed token, but the amount you were handed was scaled by
token.decimals, so read that one.
The other fields¶
| Field | Notes |
|---|---|
address |
Contract address. Empty for a NATIVE token, which has no contract |
type |
NATIVE (the chain's own coin), ERC20 (EVM and TRON contracts), SPL (Solana) |
category |
NATIVE, HEDGE (a stablecoin), ASSET (everything else) |
maxAmountOut |
Largest amount, in base units, we will pay out in this token in one go |
price |
USD per whole token, scaled by 10^18 |
status |
ENABLED accepts new invoices, DISABLED does not |
monitoringCode |
Internal label. Ignore it |
price is a reference rate for display and conversion. It is not what an invoice is settled at —
raise an invoice and NullSwap fixes the amount at creation time.
An unfiltered listing includes DISABLED tokens
That is deliberate. An invoice raised last month against a token that has since been delisted
still refers to it, and you still need to be able to read the symbol and decimals back to render
that invoice. Pass status=ENABLED when you are building a picker; leave the filter off when you
are resolving an id you already hold.
Creating an invoice against a DISABLED token is rejected — but with
404 TokenWithIdNotFoundError, not a distinct "disabled" error.
Rates¶
curl "https://api.pay.nullswap.com/api/v1/tokens/rate?srcTokenId=3b7e1a48-...&dstTokenId=8c1f2e3d-..." \
-H "x-api-key: sk_live_..."
{
"srcToken": { "id": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908", "…": "full token object" },
"dstToken": { "id": "8c1f2e3d-4a5b-4c6d-8e70-9f0a1b2c3d4e", "…": "full token object" },
"rate": "999400000000000000",
"minAmountIn": "10000000",
"maxAmountIn": "1000000000000",
"maxAmountOut": "999000000000"
}
Both query parameters are required. rate is destination units per one source unit, scaled by
10^18, with the platform fee already deducted — you do not subtract anything yourself.
adjusting for the decimals gap if the two tokens differ.
minAmountIn and maxAmountIn bound what the route will accept, and maxAmountOut bounds what it
will deliver; a pair can fail on the output bound even when the input is inside its range.
A rate is a quote, not a reservation
Nothing is held for you. Rates move between the call and the invoice, so use this to show a
figure, and treat the expectedAmount on the invoice you actually create as the number that
counts.
This endpoint is the one place in the API that answers a 404 in a different shape:
A prose message, an extra error key, no timestamp. It means no route exists between the two
tokens — usually an unsupported pair, occasionally a temporary liquidity gap. Everywhere else in the
API message is an error class name; see Errors.
Caching¶
| Endpoint | Cache for |
|---|---|
/api/v1/chains |
Hours. Only latestBlockNumber changes |
/api/v1/tokens |
Hours for the list, minutes if you display price |
/api/v1/tokens/rate |
Seconds, if at all |
Fetch both lists at start-up and refresh them on a timer rather than calling them on the path of
every checkout. The one thing not to cache indefinitely is a token id you have never seen before —
if TokenWithIdNotFoundError comes back on an invoice you expected to work, refresh the list before
concluding anything.