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}",
      "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:

depth = chain.latestBlockNumber - payment.blockNumber + 1

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

amountOut ≈ amountIn × rate × 10^(dstDecimals − srcDecimals) / 10^18

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.