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.
- Rate limits
- Per IP, per endpoint group. See below.
- Machine-readable spec
/api/v1/openapi.json(OpenAPI 3.1)- 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
keyfromGET /chains(sol,eth,base,btc…). 70+ chains route both ways; each chain'scanPayFromandcanReceiveOnsay which way it goes. - Tokens are
{ "chain": "sol", "token": "<address>" }: a contract address (EVM) or mint (Solana), or the wordnativefor 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 intoGET /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, andamountInon payment routes) are raw integer strings of their token. The*Formattedfields 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,xmrsplitandtwoleg.
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.
| Group | Limit | Applies to |
|---|---|---|
read | 120 / minute | GET /api/v1, /chains, /tokens, /status, /health, /openapi.json; POST /api/v1/paylink |
quote | 60 / minute | POST /api/v1/quote, POST /api/v1/x402/fund |
swap | 30 / minute | POST /api/v1/swap (asks the protocol for a one-time deposit address) |
mcp | 120 / minute | every 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.
{
"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"
}
]
}
}{
"error": {
"code": "unknown_chain",
"message": "Unknown chain \"zzz\". List supported keys with GET /api/v1/chains."
}
}| code | HTTP | Meaning |
|---|---|---|
validation_error | 400 | The request is malformed. error.issues[] lists each bad field as { path, message }. |
unknown_chain | 400 | The chain key is not in GET /chains. |
token_not_found | 404 / 400 | The token is not listed on that chain. 404 on /tokens lookups, 400 when it is inside a quote or swap body. |
invalid_json | 400 | The body is empty or not valid JSON. |
unsupported_media_type | 415 | Send POST bodies as application/json. |
payload_too_large | 413 | The body is over 16 KB. |
not_found | 404 | No such endpoint, or the provider does not know that swap id. |
method_not_allowed | 405 | Wrong verb for this path. The Allow header lists the right ones. |
rate_limited | 429 | Over the limit for this endpoint. Retry-After says how many seconds to wait. |
no_route | 422 | The provider cannot route this pair or amount right now. |
below_minimum | 422 | The amount is under the provider's minimum. Quotes carry suggestedMinInput when they can. |
refund_needed | 400 | The 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_moved | 409 | The 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_provider | 400 | That provider cannot issue a deposit address for this request, or has no status to follow. Request a quote to see which providers can. |
upstream_error | 502 | A route provider failed or is unreachable. |
timeout | 504 | No 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_configured | 501 | This deployment lacks a credential that feature needs (for example status of confidential NEAR Intents swaps). |
internal_error | 500 | Unexpected 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.
curl https://zerofee.si/api/v1{
"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[].keychains[].namechains[].vmevm, svm, bvm (Bitcoin-like) or other.chains[].nativeSymbol / decimalschains[].icon / explorerchains[].canPayFrom / canReceiveOncurl https://zerofee.si/api/v1/chains{
"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/chains.qtokens[].addressnull means the chain's native coin. Pass "native" for that in other calls.tokens[].symbol / name / decimalstokens[].usdtokens[].verifiedtrue 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[].unverifiedtokens[].riskshintcurl "https://zerofee.si/api/v1/tokens?chain=sol&q=BONK"{
"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.
fromtoamountfrom, e.g. "1.5". Up to 40 digits before and after the point.amountRawamountOutto, 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.refundTorecipientslippageBpshopRefundsroutes[].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.privacystandard (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[]routes[].provider / providerNameroutes[].amountOut, amountOutFormatted, minAmountOutroutes[].amountIn, amountInFormattedamountOut: 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"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[].costBpsroutes[].etaSecondsroutes[].feeNote, breakdownroutes[].depositSupported / depositReadyrefundTo and recipient. A route you can create has both true.routes[].quoteExpiresAtroutes[].appUrlbest / fastesterrors[]routes[].refundsNeeded{ chain, chainName, recipientFits }. Send them in hopRefunds. Absent means none.suggestedMinInput, noteamount 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 -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"
}'{
"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)from, to, amount, amountRaw or amountOut, slippageBps, privacy, and the addresses below.providerroutes[].provider.mode"deposit": the protocol issues a one-time address to pay from anywhere. It is the only mode.refundTo + recipientminAmountOutminAmountOut from the quote you confirmed (for a route without one, its amountOut). If the fresh re-quote pays less: 409 price_moved, nothing reserved.maxAmountInamountOut: 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, chainmemoexpiresAtterms{ raw, formatted, symbol, chain }: amountIn, amountOut and minAmountOut.trackingId, statusUrl, trackingLinks[]statusUrl (a path on this API) and show the links to the provider's own explorer.recipient, refundTo, refundNote, instructions, warningquotecurl -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"
}'{
"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.
providerprovider from the swap response.idtrackingId 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.txstatusHint saying so.statuspending, success, refunded or failed, with an optional detail and provider links[].curl "https://zerofee.si/api/v1/status?provider=near&id=FVF5uMHL15ha8XTzS2QXcDYy3zPC3VwdZtfxcofW5E4A%3Apublic"{
"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.
Pay link
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/chains ("solana", "ethereum" work too). It must be one that can be paid to (canReceiveOn).token"native", or a ticker such as "USDC" when exactly one listed token has it.amount"25", with no more decimals than the token has.tolabel, noteIt returns:
url/pay link to share or show as a QR code.directUrianyCoinfalse when NEAR Intents doesn't list the coin, so only the same coin can pay; warnings then says so.requestamount and amountRaw, to, label and note.quoteBodynote, warnings[]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 -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" }'{
"url": "https://zerofee.si/pay?to=<address>&chain=sol&token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&amount=25&label=Coffee%20shop¬e=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.
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{ 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).walletfromrefundToslippageBpsIt returns:
requirementaccepts entry it used, resolved: chain, chainName, symbol, decimals, amount (human) and amountRaw, plus network, asset and payTo as the 402 gave them.skipped[]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).fundingNeededfalse when you already pay with that asset on that network: pay the 402 directly.quoteBody, quote/swap (an amountOut request to your wallet) and its quote; quote.routes[0].amountIn is what you send.nextWhen 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.
# 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.{
"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.
curl https://zerofee.si/api/v1/health{
"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.
curl "https://zerofee.si/api/v1/clock?chains=btc,base,xmr"{
"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.
- 1Resolve the inputs. Get chain keys from
/chainsand token addresses from/tokens. Use exactly what they return. - 2Quote with the addresses. Add
refundTo(on the from-chain) andrecipient(on the to-chain). Routes withdepositReady: trueare ready. Show the user the output, the costs and the ETA. - 3Confirm with the user. Route, recipient and refund address. Then call
/swapwithmode: "deposit"and the chosenprovider. - 4The user sends. Display
amountFormatted,symbol,chain,depositAddressandmemoif present. Funds move only when the user pays; the address does nothing until then. - 5Poll the status every 15 seconds until
success,refundedorfailed.
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.
- 1Quote the exact output.
POST /quotewithamountOut(the merchant's amount),recipient(the merchant's address) and the payer'srefundTo. Each route'samountInis what the payer sends. Only Relay and NEAR Intents quote exact output;routes[0]is the one that asks the payer for less. - 2Show the payer
amountInFormattedand get their confirmation. - 3Create the payment.
POST /swapwith the same body, the route'sprovider(relayornear),mode: "deposit"andmaxAmountInset to the route'smaxAmountIn(the confirmedamountInplus your slippage, so a tiny move between quote and create doesn't refuse it). A409 price_movedmeans the price rose past that: show the fresh route and ask again. - 4The payer sends
amountFormattedtodepositAddressfrom 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. - 5Track with
GET /status?provider=<provider>&id=<trackingId>(the same provider) untilsuccess,refundedorfailed, and confirm the merchant's receipt on-chain.
# 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).
Deep links into the app
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:
https://zerofee.si/swap?from=<chainKey>:<tokenAddress|native>&to=<chainKey>:<tokenAddress|native>&amount=<decimal>&recipient=<address>from, tonative. Example: base:native. A token the app cannot resolve on that chain is ignored, not guessed.amount0.5.recipientFor 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./swapre-quotes fresh, and answersprice_movedrather than silently giving the user a worse price. - Show the user exactly what to send. Display
amountFormatted,symbol,chain,depositAddressandmemoas 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
xmris 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.