Docs menu

Developers

REST API

ZeroFee Swap and ZeroFee Pay over HTTPS: list chains, find a token, get ranked quotes from every route that can issue a deposit address, and reserve that address, which any wallet or exchange can pay. Deposit only, with no wallet to connect. No API key, no sign-up, and ZeroFee adds 0%.

Base URL
https://zerofee.si/api/v1
Authentication
None. There is no API key.
Format
JSON in, JSON out. POST bodies are application/json, at most 16 KB.
CORS
Open to every origin (Access-Control-Allow-Origin: *), preflight answered, so you can call it from a browser.
Version
3.2.1 — what changed, and when
Service level
Live provider checks are on the status page. No SLA yet.

What the API does

The API never creates, holds or touches a wallet or a key, and never signs or sends anything: a swap call only asks the protocol for a one-time deposit address, which the user pays from any wallet or exchange. ZeroFee adds a 0% platform fee. The providers behind each route charge their own fees, and every route carries its cost breakdown. Third parties hold funds while a swap is in flight (the protocol that issued the deposit address while it fills; Exolix and ChangeNOW for their legs of the Monero step, privacy xmr).

ZeroFee is Super intelligence audited; no third-party firm has reviewed its own code yet. See Security.

Conventions

  • Chains are named by the key from GET /chains (sol, eth, base, btc…). 70+ chains route both ways; each chain's canPayFrom and canReceiveOn say which way it goes.
  • Tokens are { "chain": "sol", "token": "<address>" }: a contract address (EVM) or mint (Solana), or the word native for the chain's own coin. A coin works once a route provider lists it, including fresh launchpad coins once a DEX or aggregator lists them: paste the address into GET /tokens.
  • Amounts in are strings, never JSON numbers. Send exactly one of amount (human decimal, "1.5"), amountRaw (integer in the token's smallest unit, lamports or wei) or, for a payment, amountOut (the exact amount of the to token to deliver, as a human decimal; see payments).
  • Amounts in responses (amountOut, minAmountOut, effectiveAmountOut, and amountIn on payment routes) are raw integer strings of their token. The *Formatted fields are the human versions. Never do money arithmetic in floating point.
  • One way to swap. Every swap is a deposit swap: the protocol issues a one-time address and you pay it from any wallet or exchange. Nothing is signed through this API.
  • Timestamps are ISO 8601 in UTC. Provider ids are relay, near, chainflip, exolix, fixedfloat, xmrsplit and twoleg.

Rate limits

Limits are per client IP, measured over a sliding 60-second window, with a separate counter for each group below. A refused request is not counted. The counters live in server memory and are best-effort, so treat the numbers as a floor for planning rather than an exact meter.

GroupLimitApplies to
read120 / minuteGET /api/v1, /chains, /tokens, /status, /health, /openapi.json; POST /api/v1/paylink
quote60 / minutePOST /api/v1/quote, POST /api/v1/x402/fund
swap30 / minutePOST /api/v1/swap (asks the protocol for a one-time deposit address)
mcp120 / minuteevery request to /api/mcp (tool calls also count against their own bucket above)

Successful responses carry X-RateLimit-Limit and X-RateLimit-Remaining. A refused request returns 429 with Retry-After (seconds) and the same two headers. If you need more headroom, batch your reads (one /chains response is cacheable for 5 minutes) and re-use quotes while they are valid.

Errors

Every failure has the same shape: HTTP status plus { "error": { "code", "message" } }. code is stable, so you can branch on it; message is for humans. Validation errors add issues. The API is stateless: requests and addresses are not stored or logged.

POST /quote with a number instead of a string, HTTP 400
{
  "error": {
    "code": "validation_error",
    "message": "Invalid request body — amount: Invalid input: expected string, received number",
    "issues": [
      {
        "path": "amount",
        "message": "Invalid input: expected string, received number"
      }
    ]
  }
}
GET /tokens?chain=zzz, HTTP 400
{
  "error": {
    "code": "unknown_chain",
    "message": "Unknown chain \"zzz\". List supported keys with GET /api/v1/chains."
  }
}
codeHTTPMeaning
validation_error400The request is malformed. error.issues[] lists each bad field as { path, message }.
unknown_chain400The chain key is not in GET /chains.
token_not_found404 / 400The token is not listed on that chain. 404 on /tokens lookups, 400 when it is inside a quote or swap body.
invalid_json400The body is empty or not valid JSON.
unsupported_media_type415Send POST bodies as application/json.
payload_too_large413The body is over 16 KB.
not_found404No such endpoint, or the provider does not know that swap id.
method_not_allowed405Wrong verb for this path. The Allow header lists the right ones.
rate_limited429Over the limit for this endpoint. Retry-After says how many seconds to wait.
no_route422The provider cannot route this pair or amount right now.
below_minimum422The amount is under the provider's minimum. Quotes carry suggestedMinInput when they can.
refund_needed400The route converts your money on a chain where it can't infer a refund address you control (a Monero route's middle leg, or the two-step route's middle chain). The quote names the chain in routes[].refundsNeeded; send hopRefunds and try again. Nothing was reserved.
price_moved409The fresh quote taken at swap time pays less than your minAmountOut (or your slippage allows), or, for a payment, needs more than your maxAmountIn. Nothing was reserved; the fresh route is in error.details.route. Quote again.
unsupported_provider400That provider cannot issue a deposit address for this request, or has no status to follow. Request a quote to see which providers can.
upstream_error502A route provider failed or is unreachable.
timeout504No route arrived within 15 s. The provider answers that did arrive are under partial. (When a route arrived in time, a quote returns it with incomplete: true instead.)
not_configured501This deployment lacks a credential that feature needs (for example status of confidential NEAR Intents swaps).
internal_error500Unexpected server error. The message never echoes your input.

Endpoints

Responses below were captured from a running server and trimmed (long arrays cut, marked with "…"). Routes, rates and fees change by the second; the shapes do not.

API index

GET/api/v1read

A small index, so the API is discoverable from its own root: name, version, the OpenAPI and MCP URLs, and the endpoint list.

bash
curl https://zerofee.si/api/v1
response (trimmed)
{
  "name": "ZeroFee Swap API",
  "version": "3.2.1",
  "changelog": "https://zerofee.si/docs/changelog",
  "keyless": true,
  "openapi": "https://zerofee.si/api/v1/openapi.json",
  "mcp": "https://zerofee.si/api/mcp",
  "endpoints": [
    {
      "method": "GET",
      "path": "/api/v1/chains"
    },
    {
      "method": "GET",
      "path": "/api/v1/tokens?chain=<key>&q=<symbol|name|address>"
    },
    "…"
  ]
}

Chains

GET/api/v1/chainsread

Every chain ZeroFee can route to or from. No parameters. The response is cacheable (Cache-Control: public, s-maxage=300).

chains[].key
string
Use this everywhere a chain is asked for.
chains[].name
string
Display name.
chains[].vm
string
Address type: evm, svm, bvm (Bitcoin-like) or other.
chains[].nativeSymbol / decimals
string / integer
The chain's own coin and its decimals.
chains[].icon / explorer
string or null
Logo and block-explorer URLs when known.
chains[].canPayFrom / canReceiveOn
boolean
Whether a swap can start on this chain, and whether it can end there.
bash
curl https://zerofee.si/api/v1/chains
response (trimmed)
{
  "chains": [
    {
      "key": "sol",
      "name": "Solana",
      "vm": "svm",
      "nativeSymbol": "SOL",
      "decimals": 9,
      "icon": "https://assets.relay.link/icons/792703809/light.png",
      "explorer": "https://solscan.io"
    },
    {
      "key": "base",
      "name": "Base",
      "vm": "evm",
      "nativeSymbol": "ETH",
      "decimals": 18,
      "icon": "https://assets.relay.link/icons/8453/light.png",
      "explorer": "https://basescan.org"
    },
    {
      "key": "btc",
      "name": "Bitcoin",
      "vm": "bvm",
      "nativeSymbol": "BTC",
      "decimals": 8,
      "icon": "https://assets.relay.link/icons/8253038/light.png",
      "explorer": "https://mempool.space"
    },
    "…more chains"
  ]
}

Tokens

GET/api/v1/tokens?chain=<key>&q=<text>read

The chain's default token list, or a search. Paste a contract address or Solana mint into q to find any coin a route provider lists, launchpad coins included.

chain
string · required
Chain key from /chains.
q
string, up to 128 chars · optional
A symbol, a name, or an address/mint. Omit it for the default list. A search returns at most 50 tokens.
tokens[].address
string or null
null means the chain's native coin. Pass "native" for that in other calls.
tokens[].symbol / name / decimals
string / string / integer
As listed by the providers.
tokens[].usd
number · sometimes
Indicative USD price when the source has one.
tokens[].verified
boolean
true when the token is on a curated list (the native coin, NEAR Intents' list, or verified by Relay or Jupiter). Anyone can deploy a coin called "USDC": a false entry with a familiar symbol is a lookalike unless the user gave you that exact address.
tokens[].unverified
boolean
true when the token is on no list at all and was found only by reading the chain (a pasted address): confirm it with the user.
tokens[].risks
string[] · sometimes
Plain-language red flags the source reports (issuer can freeze or mint, thin liquidity, concentrated holders).
hint
string · sometimes
Present with an empty list for an address nobody lists yet.
bash
curl "https://zerofee.si/api/v1/tokens?chain=sol&q=BONK"
response (trimmed)
{
  "tokens": [
    {
      "chain": "sol",
      "address": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
      "symbol": "BONK",
      "name": "Bonk",
      "decimals": 5,
      "icon": "https://coin-images.coingecko.com/coins/images/28600/large/bonk.jpg?1696527587",
      "usd": 0.0000032266547308011575,
      "verified": true,
      "unverified": false
    },
    "…more matches"
  ]
}

Quote

POST/api/v1/quotequote

Ranked quotes from every provider at once. Read-only: it reserves nothing and moves nothing. Providers get up to 15 seconds. If one is slower, you get the routes that arrived with incomplete: true and a note naming who may still answer; only when no route arrived is it a 504, whose partial field holds what did.

from
{ chain, token } · required
The token you pay with.
to
{ chain, token } · required
The token you want.
amount
decimal string · one of amount / amountRaw / amountOut
Human units of from, e.g. "1.5". Up to 40 digits before and after the point.
amountRaw
integer string · one of amount / amountRaw / amountOut
Smallest units of from (lamports, wei), up to 78 digits.
amountOut
decimal string · one of amount / amountRaw / amountOut
Payments: deliver exactly this much of to, in human units ("25" for 25 USDC). The routes then say what the payer sends in amountIn. Routed by exact-output providers (Relay, NEAR Intents); with privacy: "xmr" by the Monero step instead, which delivers at least the amount (see delivery below). See Recipe 2.
refundTo
address · optional
Where a failed swap is refunded, on the from-chain. Send it with recipient so routes come back depositReady; both are needed to create the swap.
recipient
address · optional
Who receives the output, on the to-chain.
slippageBps
integer, 1 to 5000 · default 50
Maximum slippage in basis points. 50 is 0.5%.
hopRefunds
{ entry?, exit?, exitToRecipient? } · when asked
Refund addresses for a route's middle steps, needed only when a route says so in routes[].refundsNeeded: the Monero route (xmr) and the two-step route (twoleg, standard privacy) convert your money on a chain other than the one you pay from, and every refund must reach an address you control. entry: your address on the chain the first exchange takes. exit: your address on the chain the last conversion runs on (never an exchange deposit address). exitToRecipient: true: the receive address is yours, so the last conversion may refund to it; only when that conversion runs on the destination chain (refundsNeeded.exit.recipientFits). Without them /swap answers 400 refund_needed and reserves nothing.
privacy
enum · default standard
standard (public), private (NEAR Intents confidential) or xmr (the Monero step: the coin becomes Monero at Exolix, which pays straight into ChangeNOW, which delivers the destination coin, with a NEAR Intents or Relay conversion added before or after when an exchange doesn't list a coin; a single exchange alone does not serve it, because it would see both ends). See Privacy protocols for what each one hides.

The body is strict: unknown fields are rejected, so typos fail loudly. What comes back:

routes[]
Route[]
Best first, ranked by effectiveAmountOut (what you actually receive after any fee paid on top of the input).
routes[].provider / providerName
string
The id to pass to /swap, and its display name.
routes[].amountOut, amountOutFormatted, minAmountOut
string
Output in raw and human units, and the minimum after slippage.
routes[].amountIn, amountInFormatted
string · payments
With amountOut: what the payer sends, in raw and human units of from. On a NEAR Intents route it includes a small exact-output margin (about 0.5%) that is refunded if unused; a Relay route is exact, or refunded.
routes[].delivery, cushionBps
string, number · payments
"exact" (Relay, NEAR Intents) or "at_least" (privacy: "xmr", the Monero step: floating-rate legs, so amountIn carries cushionBps — 300 = 3% — over the need and the recipient receives everything that arrives; amountOut is then the expected delivery). /swap prices an at-least route at exactly your maxAmountIn. The exchanges' minimums are about 50 USD.
routes[].costBps
number · sometimes
All-in cost against the market price in basis points, when both sides have a price from one source.
routes[].etaSeconds
number · sometimes
Provider's estimated duration.
routes[].feeNote, breakdown
string, object
Plain-language fee note, plus breakdown.fees[] (label, payee, usd, included in the output or paid on top), breakdown.steps[] (the path the money takes) and the network fee.
routes[].depositSupported / depositReady
boolean
Whether a deposit swap is possible for this route, and whether this very request already carries a valid refundTo and recipient. A route you can create has both true.
routes[].quoteExpiresAt
ISO time
Re-quote after this (about 25 seconds from issue).
routes[].appUrl
string
Opens this same swap in the app. See deep links.
best / fastest
string or null
Provider ids of the best and fastest route; null when there is no route.
errors[]
{ provider, error }[]
Providers that had no route, with a plain reason. This is normal; check it before telling a user there is no route.
routes[].refundsNeeded
{ entry?, exit? } · sometimes
The refund addresses this route still needs before it can be created, each as { chain, chainName, recipientFits }. Send them in hopRefunds. Absent means none.
suggestedMinInput, note
object, string · sometimes
When routes failed only on a minimum: amount is the smallest the providers named, recommended is 5% above it (prices move between the quote and your deposit, and a deposit under the minimum is refunded, not swapped). Send at least recommended.
curl
curl -X POST https://zerofee.si/api/v1/quote \
  -H 'content-type: application/json' \
  -d '{
    "from": { "chain": "sol", "token": "native" },
    "to":   { "chain": "base", "token": "native" },
    "amount": "0.5",
    "refundTo":  "AKeAJBqmt8nrbEcYtJ8ACAyPeYY81PGW6Zjs4rWwrPbA",
    "recipient": "0x1111111111111111111111111111111111111111"
  }'
response: 0.5 SOL to ETH on Base (the one route that answered)
{
  "request": {
    "from": {
      "chain": "sol",
      "token": "native",
      "symbol": "SOL",
      "decimals": 9
    },
    "to": {
      "chain": "base",
      "token": "native",
      "symbol": "ETH",
      "decimals": 18
    },
    "amount": "0.5",
    "amountRaw": "500000000",
    "slippageBps": 50,
    "privacy": "standard",
    "mode": "deposit",
    "refundTo": "AKeAJBqmt8nrbEcYtJ8ACAyPeYY81PGW6Zjs4rWwrPbA",
    "recipient": "0x1111111111111111111111111111111111111111",
    "depositReady": true
  },
  "routes": [
    {
      "provider": "near",
      "providerName": "NEAR Intents",
      "amountOut": "21345236355634922",
      "amountOutFormatted": "0.021345236355634922",
      "minAmountOut": "21238510173856747",
      "effectiveAmountOut": "21345236355634922",
      "etaSeconds": 22,
      "costBps": 37.1,
      "feeNote": "1Click API fee 0.20% (included)",
      "breakdown": {
        "fees": [
          {
            "label": "1Click API fee (0.20%)",
            "to": "NEAR Intents / 1Click",
            "usd": 0.10744000000000001,
            "included": true
          },
          {
            "label": "Withdrawal gas (destination)",
            "to": "Base validators",
            "usd": 0.0014044463999999988,
            "included": true
          }
        ],
        "steps": [
          "You send SOL to a one-time NEAR Intents deposit address on Solana",
          "Market makers (solvers) compete; the best quote is settled on NEAR by the intents.near contract (0.0001% protocol fee)",
          "ETH is withdrawn to the recipient on Base"
        ],
        "networkFeeUsd": null,
        "networkFeeNote": "whatever your sending wallet charges"
      },
      "route": "solvers compete on price",
      "confidential": false,
      "depositSupported": true,
      "depositReady": true,
      "executable": true,
      "quoteExpiresAt": "2026-10-14T12:09:29.488Z",
      "appUrl": "https://zerofee.si/swap?from=sol:native&to=base:native&amount=0.5&recipient=0x1111111111111111111111111111111111111111"
    }
  ],
  "best": "near",
  "fastest": "near",
  "errors": [
    {
      "provider": "relay",
      "error": "not available for this pair"
    }
  ]
}

Swap: a deposit address

POST/api/v1/swapswap

Turns one route into a one-time deposit address, issued by the protocol, that the user pays from any wallet or exchange. The body is the quote body plus two required fields. The server re-quotes the chosen provider fresh and refuses with 409 price_moved if the new price is worse than your slippage allows. It never signs or sends anything, and never creates, holds or touches a wallet.

(all quote fields)
As above: from, to, amount, amountRaw or amountOut, slippageBps, privacy, and the addresses below.
provider
provider id · required
From a quote's routes[].provider.
mode
"deposit" · required
Always "deposit": the protocol issues a one-time address to pay from anywhere. It is the only mode.
refundTo + recipient
address · required
Both are validated against their chains. refundTo is on the from-chain, recipient on the to-chain.
minAmountOut
integer string · recommended
The route's minAmountOut from the quote you confirmed (for a route without one, its amountOut). If the fresh re-quote pays less: 409 price_moved, nothing reserved.
maxAmountIn
integer string · payments
With amountOut: the quoted route's maxAmountIn (its amountIn plus your slippage; the mirror of minAmountOut), raw units of from. If the fresh re-quote needs more: 409 price_moved, nothing reserved.

It returns:

depositAddress, amountRaw, amountFormatted, symbol, chain
string
Send exactly this amount of this token on this chain to this address.
memo
string · sometimes
A memo or tag some chains need. Include it or the deposit may not be credited.
expiresAt
ISO time · sometimes
How long the address stays valid.
terms
object
The figures you are agreeing to, each as { raw, formatted, symbol, chain }: amountIn, amountOut and minAmountOut.
trackingId, statusUrl, trackingLinks[]
string, string, array
Poll statusUrl (a path on this API) and show the links to the provider's own explorer.
recipient, refundTo, refundNote, instructions, warning
string
Echoes, who refunds and where, and a ready-to-show instruction sentence.
quote
Route
The fresh route this address was reserved for.
curl: 0.5 SOL to ETH on Base
curl -X POST https://zerofee.si/api/v1/swap \
  -H 'content-type: application/json' \
  -d '{
    "from": { "chain": "sol",  "token": "native" },
    "to":   { "chain": "base", "token": "native" },
    "amount": "0.5",
    "refundTo":  "AKeAJBqmt8nrbEcYtJ8ACAyPeYY81PGW6Zjs4rWwrPbA",
    "recipient": "0x1111111111111111111111111111111111111111",
    "provider": "near",
    "mode": "deposit"
  }'
response (trimmed)
{
  "mode": "deposit",
  "provider": "near",
  "terms": {
    "amountIn": {
      "raw": "500000000",
      "formatted": "0.5",
      "symbol": "SOL",
      "chain": "sol"
    },
    "amountOut": {
      "raw": "21349111447525069",
      "formatted": "0.021349111447525069",
      "symbol": "ETH",
      "chain": "base"
    },
    "minAmountOut": {
      "raw": "21242365890287443",
      "formatted": "0.021242365890287443",
      "symbol": "ETH",
      "chain": "base"
    }
  },
  "depositAddress": "FVF5uMHL15ha8XTzS2QXcDYy3zPC3VwdZtfxcofW5E4A",
  "amountRaw": "500000000",
  "amountFormatted": "0.5",
  "symbol": "SOL",
  "chain": "sol",
  "expiresAt": "2026-10-17T12:39:15.028Z",
  "trackingId": "FVF5uMHL15ha8XTzS2QXcDYy3zPC3VwdZtfxcofW5E4A:public",
  "statusUrl": "/api/v1/status?provider=near&id=FVF5uMHL15ha8XTzS2QXcDYy3zPC3VwdZtfxcofW5E4A%3Apublic",
  "trackingLinks": [
    {
      "label": "NEAR Intents",
      "url": "https://explorer.near-intents.org/?search=FVF5uMHL15ha8XTzS2QXcDYy3zPC3VwdZtfxcofW5E4A"
    }
  ],
  "recipient": "0x1111111111111111111111111111111111111111",
  "refundTo": "AKeAJBqmt8nrbEcYtJ8ACAyPeYY81PGW6Zjs4rWwrPbA",
  "refundNote": "If the swap cannot complete it is refunded to AKeAJBqmt8nrbEcYtJ8ACAyPeYY81PGW6Zjs4rWwrPbA.",
  "instructions": "Send exactly 0.5 SOL on Solana to the deposit address, from any wallet or exchange. ETH is paid to 0x1111111111111111111111111111111111111111 on Base. If the swap cannot complete it is refunded to AKeAJBqmt8nrbEcYtJ8ACAyPeYY81PGW6Zjs4rWwrPbA.",
  "quote": {
    "provider": "near",
    "providerName": "NEAR Intents",
    "amountOut": "21345236355634922",
    "…": "…"
  },
  "warning": "Quotes expire in about 25 seconds. … Every amount in this response is in raw on-chain units (see amountFormatted for human units). … Send only SOL on Solana, and only the exact amount: a different amount or token can be lost or refunded less fees."
}

Nothing moves until the user sends the funds. If the swap cannot complete, the protocol refunds to refundTo.

Swap status

GET/api/v1/status?provider=<id>&id=<trackingId>&tx=<hash>read

One quick status check, never a long poll. Call it about every 15 seconds until it says success, refunded or failed.

provider
provider id · required
The provider from the swap response.
id
string · required
The trackingId from the swap response, exactly as returned and URL-encoded (a Relay id is <requestId>@<depositAddress>, a two-step id s1=…|s2=…). Simplest: call its statusUrl.
tx
tx hash · optional
Origin-chain transaction hash. For NEAR Intents it is forwarded to speed up deposit detection; a swap response that needs it carries a statusHint saying so.
status
enum
pending, success, refunded or failed, with an optional detail and provider links[].
bash
curl "https://zerofee.si/api/v1/status?provider=near&id=FVF5uMHL15ha8XTzS2QXcDYy3zPC3VwdZtfxcofW5E4A%3Apublic"
response: a deposit address nobody has paid yet
{
  "provider": "near",
  "id": "FVF5uMHL15ha8XTzS2QXcDYy3zPC3VwdZtfxcofW5E4A:public",
  "status": "pending",
  "detail": "pending deposit",
  "links": [
    {
      "label": "NEAR Intents",
      "url": "https://explorer.near-intents.org/?search=FVF5uMHL15ha8XTzS2QXcDYy3zPC3VwdZtfxcofW5E4A"
    }
  ]
}

Status for confidential (private) NEAR Intents swaps needs a partner credential and answers 501 not_configured on a deployment without one.

POST/api/v1/paylinkread

Makes the same pay link the Pay links page makes: a URL anyone can open to pay exactly amount of token to to, from a coin on one of 70+ chains. Read-only: nothing is reserved, stored or charged; the link is the request. See Pay links & payments.

chain
chain key · required
The chain the merchant is paid on, from /chains ("solana", "ethereum" work too). It must be one that can be paid to (canReceiveOn).
token
string · default native
The coin the merchant receives: a mint or contract, "native", or a ticker such as "USDC" when exactly one listed token has it.
amount
decimal string · required
The exact amount the merchant receives, e.g. "25", with no more decimals than the token has.
to
address · required
The merchant's address on that chain, checked against the chain's rules.
label, note
string · optional
A name (up to 60 characters) and a note (up to 120) shown to the payer.

It returns:

url
string
The /pay link to share or show as a QR code.
directUri
string or null
A Solana Pay or EIP-681 transfer request for a payer who already holds the coin; null on chains without one.
anyCoin
boolean
false when NEAR Intents doesn't list the coin, so only the same coin can pay; warnings then says so.
request
object
The link's fields as read: chain and chain name, token (symbol, address, decimals), amount and amountRaw, to, label and note.
quoteBody
object
What a payer's quote looks like: fill in from with the payer's coin and POST it to /quote.
note, warnings[]
string, string[]
A plain-language summary, and anything the payer or merchant should know.

A 400 validation_error comes back for an address that isn't valid on the chain, an amount with more decimals than the token has, a chain that can't be paid to, or a coin NEAR Intents doesn't list on a chain with no transfer request to fall back on (nobody could pay it).

curl: exactly 25 USDC on Solana
curl -X POST https://zerofee.si/api/v1/paylink \
  -H 'content-type: application/json' \
  -d '{ "chain": "sol", "token": "USDC", "amount": "25", "to": "<your Solana address>", "label": "Coffee shop", "note": "Invoice #1042" }'
response (trimmed)
{
  "url": "https://zerofee.si/pay?to=<address>&chain=sol&token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&amount=25&label=Coffee%20shop&note=Invoice%20%231042",
  "directUri": "solana:<address>?amount=25&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&label=Coffee%20shop&message=Invoice%20%231042",
  "anyCoin": true,
  "request": { "chain": "sol", "chainName": "Solana", "token": { "symbol": "USDC", "decimals": 6, "address": "EPjF…Dt1v" }, "amount": "25", "amountRaw": "25000000", "to": "<address>", "label": "Coffee shop", "note": "Invoice #1042" },
  "quoteBody": { "from": { "chain": "<payer's chain>", "token": "<payer's coin>" }, "to": { "chain": "sol", "token": "EPjF…Dt1v" }, "amountOut": "25", "recipient": "<address>" },
  "note": "Nothing was reserved or stored: the link is the payment request. …",
  "warnings": []
}

Read a payment code

POST/api/v1/payreqread

What a scanned or pasted payment code asks for: a ZeroFee pay link, BIP21 (bitcoin:, litecoin:, dogecoin:, bitcoincash:, zcash:, dash:), EIP-681 (ethereum:, native or ERC-20), Solana Pay (solana:), TON (ton://transfer) or a plain address. The answer has the chain, the token matched by its contract or mint, the amount, the payLink that lets the payer pay it with the coin of their choice, and a quoteBody for POST /quote (add your from and refundTo). An EVM address fits every EVM chain: chainChoices lists them. Anything it can't read is a 400 with the reason. Read-only.

bash
curl -X POST https://zerofee.si/api/v1/payreq -H 'content-type: application/json' \
  -d '{"text":"ethereum:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913@8453/transfer?address=0x26C4f6247197e7033CEb5eDeeBE02f742f45fA82&uint256=25000000"}'

Fund an x402 payment

POST/api/v1/x402/fundquote

For an agent or app that hits an HTTP 402 Payment Required (x402) asking for an asset it doesn't hold, such as 0.10 USDC on Base. It reads the 402's requirement and prices an exact-output swap from the coin you hold into exactly that asset, delivered by deposit to your own wallet on that network. Read-only: it is a quote. You then create the swap with /swap, send the deposit, and once it lands you pay the 402 from that wallet with your own x402 client. An x402 payment is a signature by the paying wallet, which ZeroFee never makes. Testnets are refused.

paymentRequired
object or string · required
What the 402 returned: its JSON body ({ x402Version, accepts: [...] }), one accepts entry, or the base64 PAYMENT-REQUIRED header. x402 v1 (network names, maxAmountRequired) and v2 (CAIP-2 networks, amount) are both read. At most 10 accepts entries; more is a 400 (pass the entry you want to pay with).
wallet
address · required
Your own wallet on the network the 402 wants paid on. The asset is delivered here.
from
{ chain, token } · required
The coin you hold and pay with, on a chain you can pay from.
refundTo
address · recommended
Where a failed swap is refunded, on the from-chain. Needed to create the swap.
slippageBps
integer, 1 to 5000 · default 50
Maximum slippage in basis points.

It returns:

requirement
object
The accepts entry it used, resolved: chain, chainName, symbol, decimals, amount (human) and amountRaw, plus network, asset and payTo as the 402 gave them.
skipped[]
array
Other accepts entries it could not use, each as { network, asset, reason } (a testnet, a network ZeroFee doesn't route to, an asset it can't find).
fundingNeeded
boolean
false when you already pay with that asset on that network: pay the 402 directly.
quoteBody, quote
object
The body to send to /swap (an amountOut request to your wallet) and its quote; quote.routes[0].amountIn is what you send.
next
string
The exact follow-up call, in one sentence.

When the asked amount is below a route's minimum delivery, it funds that minimum plus 3% instead: a 402 for 0.10 USDC on Base, paid from SOL, funded 0.1083 USDC on 14 October 2026. The 402 takes only what it asks, and the rest stays in your wallet.

curl: a 402 for 0.10 USDC on Base, paid from SOL
# 1. Price the funding (read-only)
curl -X POST https://zerofee.si/api/v1/x402/fund \
  -H 'content-type: application/json' \
  -d '{
    "paymentRequired": { "x402Version": 2, "accepts": [{ "scheme": "exact", "network": "eip155:8453",
      "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "amount": "100000", "payTo": "<the API's address>" }] },
    "wallet":   "<your Base wallet>",
    "from":     { "chain": "sol", "token": "native" },
    "refundTo": "<your Solana address>"
  }'
# → quoteBody, quote.routes[0].provider ("near") and .amountIn, next

# 2. Create it: /swap with quoteBody + provider + mode + maxAmountIn = the route's maxAmountIn
#    (too low a maxAmountIn → 409 price_moved, nothing reserved). Send the deposit, poll /status,
#    then pay the 402 from your Base wallet with your x402 client.
response (trimmed, 14 October 2026)
{
  "requirement": { "scheme": "exact", "network": "eip155:8453", "asset": "0x8335…2913", "amountRaw": "100000", "payTo": "0x…",
                   "chain": "base", "chainName": "Base", "symbol": "USDC", "decimals": 6, "amount": "0.1" },
  "skipped": [],
  "fundingNeeded": true,
  "quoteBody": { "from": { "chain": "sol", "token": "native" }, "to": { "chain": "base", "token": "0x8335…2913" },
                 "amountOut": "0.108385", "refundTo": "<your Solana address>", "recipient": "<your Base wallet>", "slippageBps": 50, "privacy": "standard" },
  "quote": { "best": "near", "routes": [{ "provider": "near", "amountIn": "1047976", "amountInFormatted": "0.001047976", "maxAmountIn": "1053216", "amountOutFormatted": "0.108385", "etaSeconds": 24, "…": "…" }] },
  "next": "Send 0.001047976 SOL (best: NEAR Intents) to get exactly 0.108385 USDC on Base into <your Base wallet>: POST /api/v1/swap … with this quoteBody, provider near and maxAmountIn 1053216 (the quoted amountIn plus your slippage). …"
}

Provider health

GET/api/v1/healthread

One cheap request to each upstream provider (4-second timeout each), cached on the server for 60 seconds so a status page cannot be used to hammer them. status is up, down or unconfigured (the host answers but this deployment has no key for it). The live status page reads this.

steps says whether each privacy step of the swap page can start right now: zk (the Solana ZK pool: its relayer answers), xmr (Monero: Exolix and ChangeNOW answer) and zec (Zcash: the chain runs a network version the in-browser Zcash wallet knows). A step that can't has ok: false and a why; the swap page shows it on that step and won't start a swap through it.

bash
curl https://zerofee.si/api/v1/health
response (trimmed)
{
  "checkedAt": "2026-10-14T13:38:30.919Z",
  "cacheAgeSeconds": 0,
  "providers": [
    {
      "id": "relay",
      "name": "Relay",
      "status": "up",
      "reachable": true,
      "latencyMs": 727
    },
    {
      "id": "near",
      "name": "NEAR Intents",
      "status": "up",
      "reachable": true,
      "latencyMs": 250
    },
    {
      "id": "chainflip",
      "name": "Chainflip",
      "status": "up",
      "reachable": true,
      "latencyMs": 724
    },
    "…more providers"
  ],
  "steps": {
    "zk": { "ok": true },
    "zec": { "ok": true },
    "xmr": { "ok": true }
  }
}

Chain clock (live ETAs)

GET/api/v1/clock?chains=btc,base,xmrread

What each chain is doing right now: its mean block interval in seconds, read from public chain data (recent block timestamps, Solana's performance samples, mempool.space for Bitcoin, public Monero nodes), with the nominal value as a fallback (source says which). lagging means Relay reports the chain's block production as lagging; congested means the latest EVM block was over 90% full. Up to 8 chains per call; cached per chain for 30 seconds. Providers' own ETAs are fixed tables, so the app times each route from these: deposit inclusion and confirmations, the fill, the payout, and for the Monero step the receiving exchange's Monero confirmations.

bash
curl "https://zerofee.si/api/v1/clock?chains=btc,base,xmr"
response
{
  "at": 1791988530694,
  "chains": {
    "btc": {
      "interval": 618.071,
      "nominal": 600,
      "source": "live",
      "at": 1791988532743
    },
    "base": {
      "interval": 2,
      "nominal": 2,
      "source": "live",
      "at": 1791988531928
    },
    "xmr": {
      "interval": 158.233,
      "nominal": 120,
      "source": "live",
      "at": 1791988531206
    }
  }
}

OpenAPI document

GET/api/v1/openapi.jsonread

The OpenAPI 3.1 document, generated from the same schemas that validate requests, so it cannot drift from the behaviour. Feed it to a code generator or an API client such as Postman. Cached for 5 minutes.

Recipe 1: swap by deposit

The one flow the API has, and the same one the app runs for a swap without privacy steps: the user pays a one-time address, issued by the protocol, from any wallet or exchange, so you never touch a key.

  1. 1
    Resolve the inputs. Get chain keys from /chains and token addresses from /tokens. Use exactly what they return.
  2. 2
    Quote with the addresses. Add refundTo (on the from-chain) and recipient (on the to-chain). Routes with depositReady: true are ready. Show the user the output, the costs and the ETA.
  3. 3
    Confirm with the user. Route, recipient and refund address. Then call /swap with mode: "deposit" and the chosen provider.
  4. 4
    The user sends. Display amountFormatted, symbol, chain, depositAddress and memo if present. Funds move only when the user pays; the address does nothing until then.
  5. 5
    Poll the status every 15 seconds until success, refunded or failed.
deposit-swap.mjs (Node 18+)
const API = "https://zerofee.si/api/v1";
const post = (path, body) =>
  fetch(API + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }).then(async (r) => {
    const j = await r.json();
    if (!r.ok) throw new Error(j.error.code + ": " + j.error.message);
    return j;
  });

const swap = {
  from: { chain: "sol", token: "native" },
  to: { chain: "base", token: "native" },
  amount: "0.5",
  refundTo: "<your Solana address>",
  recipient: "<your Base address>",
};

const q = await post("/quote", swap);
const route = q.routes.find((r) => r.depositReady);
if (!route) throw new Error("No deposit route: " + JSON.stringify(q.errors));
console.log("Best:", route.providerName, "pays", route.amountOutFormatted, "~" + route.etaSeconds + "s");

// ... ask the user to confirm the route and both addresses here ...

const dep = await post("/swap", { ...swap, provider: route.provider, mode: "deposit" });
console.log(dep.instructions, dep.memo ? "Memo: " + dep.memo : "");

for (;;) {
  await new Promise((r) => setTimeout(r, 15_000));
  const s = await (await fetch(API + dep.statusUrl.replace("/api/v1", ""))).json();
  console.log(s.status, s.detail ?? "");
  if (["success", "refunded", "failed"].includes(s.status)) break;
}

Recipe 2: accept a payment (exact output)

A checkout where the merchant names the coin and the exact amount, and the payer pays with a coin they hold. It is Recipe 1 with amountOut instead of amount. No key, and nothing stored on our side: no merchant account, no payment record. To hand out a link instead, POST /paylink makes one; to fund an HTTP 402, see POST /x402/fund. More in Pay links & payments.

  1. 1
    Quote the exact output. POST /quote with amountOut (the merchant's amount), recipient (the merchant's address) and the payer's refundTo. Each route's amountIn is what the payer sends. Only Relay and NEAR Intents quote exact output; routes[0] is the one that asks the payer for less.
  2. 2
    Show the payer amountInFormatted and get their confirmation.
  3. 3
    Create the payment. POST /swap with the same body, the route's provider (relay or near), mode: "deposit" and maxAmountIn set to the route's maxAmountIn (the confirmed amountIn plus your slippage, so a tiny move between quote and create doesn't refuse it). A 409 price_moved means the price rose past that: show the fresh route and ask again.
  4. 4
    The payer sends amountFormatted to depositAddress from any wallet or exchange. A NEAR Intents address stays open about 72 hours, but the price is held only briefly; a deposit outside the payer's slippage is refunded to them. How long a payment takes is in How fast is it.
  5. 5
    Track with GET /status?provider=<provider>&id=<trackingId> (the same provider) until success, refunded or failed, and confirm the merchant's receipt on-chain.
curl: exactly 25 USDC on Solana, paid with ETH on Base
# 1. Quote: routes[0].amountIn is what the payer sends
curl -X POST https://zerofee.si/api/v1/quote \
  -H 'content-type: application/json' \
  -d '{
    "from": { "chain": "base", "token": "native" },
    "to":   { "chain": "sol",  "token": "USDC" },
    "amountOut": "25",
    "refundTo":  "<payer's Base address>",
    "recipient": "<merchant's Solana address>"
  }'

# 2. Create the payment, capped at what the payer agreed to send
curl -X POST https://zerofee.si/api/v1/swap \
  -H 'content-type: application/json' \
  -d '{
    "provider": "<routes[0].provider: relay or near>", "mode": "deposit",
    "from": { "chain": "base", "token": "native" },
    "to":   { "chain": "sol",  "token": "USDC" },
    "amountOut": "25",
    "maxAmountIn": "<routes[0].maxAmountIn>",
    "refundTo":  "<payer's Base address>",
    "recipient": "<merchant's Solana address>"
  }'

Limits

privacy: "xmr" pays through Monero and delivers at least the amount (see delivery above). ZeroFee adds 0%; the payer pays the route's own fee (NEAR Intents' is 0.20%), the spread and fixed delivery costs, which weigh most on small payments (on 14 October 2026 a $25 payment on a NEAR route measured 0.85–1.92% all-in, depending on the coin paid with, and $250 measured 0.62–0.90%; Relay routes were cheaper in every pair checked, 0.10–1.18% at $25).

Any hand-off to a person can open in the app with the swap pre-filled. Every route already carries one in appUrl; you can also build it yourself:

text
https://zerofee.si/swap?from=<chainKey>:<tokenAddress|native>&to=<chainKey>:<tokenAddress|native>&amount=<decimal>&recipient=<address>
from, to
chainKey:token
A chain key, a colon, then a token address or the word native. Example: base:native. A token the app cannot resolve on that chain is ignored, not guessed.
amount
decimal
Human units, digits and an optional point, such as 0.5.
recipient
address
Pre-fills the receiving address.

For the Monero route the API appends privacy=xmr to appUrl, and the app switches its Monero step on (in the app it runs Exolix, then ChangeNOW). You can also add privacy=zk (the Solana ZK pool, app-only), or both in order (privacy=zk,xmr). The app ignores other values; a confidential crossing is automatic there.

Safety checklist

Before any real money moves

  • Quotes expire in about 25 seconds (see quoteExpiresAt). Do not cache a route for later. /swap re-quotes fresh, and answers price_moved rather than silently giving the user a worse price.
  • Show the user exactly what to send. Display amountFormatted, symbol, chain, depositAddress and memo as returned. A different amount, coin or network can be lost or refunded less fees.
  • Amounts are raw integer strings. Parse them as big integers (BigInt), never floats.
  • Never construct or guess addresses. Take chain keys, token addresses and deposit addresses from the API responses and from the user. A deposit address is valid for one swap, one token and one chain: sending a different amount or token can be lost or refunded minus fees.
  • Know what each privacy option hides. Privacy options break public links; they aren't a promise. Read what each one hides before you offer one.
  • Privacy xmr is custodial for a few minutes. Exolix, then ChangeNOW, each hold the coins for their leg (the whole Monero step takes about 15 minutes on a swap, about 15–30 minutes on a payment); neither sees both ends.
  • Third parties hold funds in flight and charge their own fees. ZeroFee adds 0%. ZeroFee is Super intelligence audited; no third-party firm has reviewed its code yet, and v1 has no SLA. Start with a small amount.

Building with an AI agent? The same tools are available over the MCP server. Live provider checks are on system status.