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}",
"standard": "ERC20"
}
]
}
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, DASH, DOGECOIN, RIPPLE, 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 |
standard |
Name of the token standard on this chain, such as ERC20, TRC20 or BEP20 — a short label for telling one network of a token from another. Up to 32 characters. null where the chain has none; label it with name then |
The response is { "data": [...] } with no pagination object — the list is short and complete,
and offset/limit are refused with 400 INVALID_QUERY_PARAMETER if you send them.
latestBlockNumber is the only field here that moves minute to minute; everything else is stable
enough to cache for hours. Pairing it with the blockNumber on a payment tells you how deep that
payment is right now:
You have to compute the depth yourself: confirmations on a payment is a snapshot from when the
record was last written, not a live counter.
On RIPPLE, the XRP Ledger, a block is a ledger: latestBlockNumber and a payment's blockNumber
are ledger indexes, and only ledgers the network has validated are read. The coin is XRP with
decimals: 6, so "1000000" is 1 XRP. A deposit there is named by an address and a destination
tag — see invoices.
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}",
"standard": "ERC20"
},
"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": "100000000000",
"decimals": 6,
"price": "1000200000000000000",
"status": "ENABLED",
"srcStatus": "ENABLED",
"dstStatus": "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 the currency's name and symbol. It does not search contract addresses — use address for that |
id, chainId, currencyId, address, symbol |
Exact, case-insensitive, repeatable |
status |
ENABLED, DISABLED. Matches srcStatus |
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 — the jetton master for a JETTON. A NATIVE token has no contract, and its address is not one: identify the chain's coin by type, not by address |
type |
NATIVE (the chain's own coin), ERC20 (EVM and TRON contracts), SPL (Solana), JETTON (TON) |
category |
NATIVE, HEDGE (the chain's reference stablecoin, at most one per chain), ASSET (everything else) |
maxAmountOut |
A fixed nominal ceiling of 100,000 whole tokens, in base units. It does not follow liquidity: for what can actually be paid out, read maxAmountOut on a rate |
price |
USD per whole token, scaled by 10^18 |
srcStatus |
Whether the token can be paid in. ENABLED accepts new invoices, DISABLED does not |
dstStatus |
Whether we currently pay the token out as the result of a swap. Nothing you do through this API depends on it, withdrawals included |
status |
Deprecated. Always equal to srcStatus. Read srcStatus instead |
monitoringCode |
Internal label with no merchant-facing meaning. Can be null |
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 (it matches srcStatus) when you are building a picker;
leave the filter off when you are resolving an id you already hold.
Creating an invoice in a token whose srcStatus is DISABLED is rejected with
422 TOKEN_IS_DISABLED. Disabling stops new invoices only: a payment that arrives in the token
afterwards — on an invoice raised earlier, or on a static wallet — is still recorded and swept.
A token has two statuses, and status is only one of them
status predates the split and now mirrors srcStatus, so code that reads it keeps working for
payments. Do not read it as "the token is fully live": a token can be open for payment and closed
for payout, or the other way round. Build anything new on srcStatus.
Rates¶
curl "https://api.pay.nullswap.com/api/v1/tokens/rate?srcTokenId=3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908&dstTokenId=8c1f2e3d-4a5b-4c6d-8e70-9f0a1b2c3d4e&amountIn=250000000" \
-H "x-api-key: sk_live_..."
| Parameter | Type | Required | Notes |
|---|---|---|---|
srcTokenId |
uuid | yes | The token being sold |
dstTokenId |
uuid | yes | The token being bought |
amountIn |
string | no | Quote exactly this much of srcTokenId. Base units, a positive integer. Not together with amountOut |
amountOut |
string | no | Quote what pays out exactly this much of dstTokenId. Base units, a positive integer. Not together with amountIn |
{
"srcToken": { "id": "3b7e1a48-2c6d-4e9f-8a01-5d4c3b2a1908", "…": "full token object" },
"dstToken": { "id": "8c1f2e3d-4a5b-4c6d-8e70-9f0a1b2c3d4e", "…": "full token object" },
"rate": "396560000000000",
"minAmountIn": "10000000",
"maxAmountIn": "4180000000",
"maxAmountOut": "1608910000000000000",
"amountIn": "250000000",
"amountOut": "99140000000000000",
"hedgeAmountIn": "248747000"
}
Pass an amount and you get a quote for exactly that amount: amountIn and amountOut are both
filled, and amountOut is what that amount pays out with every fee already deducted — the platform's
and, for a pair that converts through a liquidity pool, the pool's fee and the price impact of your
amount on it. You do not subtract anything yourself. With amountOut instead, the amountIn that
comes back is rounded up, so it pays out at least the amountOut you asked for.
| Field | Notes |
|---|---|
rate |
Destination units per source unit, scaled by 10^18. With a quote, the rate your amount actually gets. Without an amount, the marginal rate: the best rate any amount gets, with no price impact |
minAmountIn |
Smallest input we quote, in base units of srcTokenId: a fixed USD minimum at the token's current price |
maxAmountIn |
Largest input we quote: what maxAmountOut costs at the pair's rate |
maxAmountOut |
Largest payout we quote. The lowest of three limits: a fixed USD maximum per swap; for a pair that converts through a liquidity pool, what the pool pays out within 3% price impact (the pool's own fee not counted); and how much of dstTokenId we can pay out on its chain right now. "0" when none of it can be paid out |
amountIn, amountOut |
The quote. Both null when you sent no amount, and when your amount is outside the limits |
hedgeAmountIn |
Base units of the destination chain's HEDGE token that buying exactly amountOut costs. null without a quote, and when dstTokenId is itself a HEDGE token or on a chain without one. You do not need it to use the quote |
rate is amountOut / amountIn with the decimals aligned, so
reproduces a quote up to rounding. Use amountOut itself when you have it.
Without an amount, rate is the best case
The marginal rate carries no price impact, and every real amount gets less — a large amount on a
thin pool noticeably less. Multiplying an amount by it overstates what that amount receives. Send
the amount you are going to show, and display the amountOut that comes back.
An amount outside the limits is still a 200
We do not quote an amount below minAmountIn or above maxAmountIn (for amountOut, one that
would need such an input). The response is a normal 200 with amountIn and amountOut set to
null; rate falls back to the marginal rate and the limits tell you which bound you crossed.
Check amountOut for null before you show it.
maxAmountIn and maxAmountOut follow the liquidity available for the pair and what we can pay out
in dstTokenId at the moment, so they move with the market and can be much lower than the USD
maximum for a thinly traded token. Read them from the same response you quote from rather than from
an earlier one.
Zero limits mean nothing can be paid out right now, not an unsupported pair
When we cannot pay out dstTokenId on its chain at the moment, maxAmountIn and maxAmountOut
are both "0", minAmountIn is above maxAmountIn, and every amount comes back unquoted —
still as a 200, not a 404. This usually clears up; ask again later rather than removing the
pair from your picker.
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.
| Status | message |
Cause |
|---|---|---|
400 |
INVALID_QUERY_PARAMETER |
srcTokenId or dstTokenId is missing or not a UUID, an amount is not a positive integer of base units, or both amountIn and amountOut were sent |
401 |
INVALID_API_KEY |
Missing, malformed, unknown or revoked x-api-key |
403 |
CALLER_IP_NOT_ALLOWED |
The key is valid but the source address is not on its whitelist |
403 |
MERCHANT_BLOCKED |
The merchant is blocked |
404 |
TOKENS_RATE_NOT_FOUND |
The pair cannot be priced right now, see below |
TOKENS_RATE_NOT_FOUND means the pair cannot be priced: one of the tokens does not exist or has no
price yet, the pair is not supported, or the liquidity data for it is temporarily unavailable. The
last case clears up on its own, so a pair that worked a minute ago is worth retrying. See
Errors for the envelope.
The rate does not look at srcStatus or dstStatus: a pair with a DISABLED token is still
quoted. Check the statuses on the token before you offer it.
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 |
Not at all. A quote is for one amount at one moment; ask again for each amount you show |
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 TOKEN_NOT_FOUND comes back on an invoice you expected to work, refresh the list before
treating it as an error.