Skip to content

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

curl "https://api.pay.nullswap.com/api/v1/chains?type=EVM" \
  -H "x-api-key: sk_live_..."
{
  "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:

depth = chain.latestBlockNumber - payment.blockNumber + 1

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.

amountOut ≈ amountIn × rate / 10^18

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:

{ "statusCode": 404, "message": "Rate not found", "error": "Not Found" }

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.