REST API
Base URL: https://api.zerodust.xyz
- JSON in and out. Amounts are strings in wei, chain IDs are numbers.
- No key is needed. An API key raises your rate limits.
- Browsers may only call the API from the ZeroDust site (CORS). Call it from a server, a script or an agent.
- Send only signatures. No endpoint takes a private key.
The SDK wraps the chain, balance, quote and sweep endpoints. Chains without EIP-7702 use separate planning endpoints, documented on Direct chains.
Rate limits
Limits are per client IP and per minute unless noted. With an API key, the read, quote and sweep routes the SDK uses count against the key instead.
| Group | Limit | Routes |
|---|---|---|
| Reads | 200 | /chains, /chains/:chainId, /bridges/timing, /destinations, /balances/*, /prices, /prices/:symbol, GET /sweep/:sweepId, /sweeps/:address, /direct/chains, /direct/balances/:address, /direct/status |
| Quotes | 60 | /quote, POST /quote/:quoteId/relay-route, POST /authorization, POST /authorization/batch, /direct/route, /direct/prepare, /direct/exit |
| Sweep submission | 60, and 60 per wallet address | POST /sweep |
| Key registration | 5 per hour | POST /agent/register |
| Everything else | 100 |
Responses carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset. Over the limit, the API returns 429:
{ "error": "Too Many Requests", "message": "You are requesting quotes too quickly. Maximum 60 quotes per minute.", "statusCode": 429 }
A wallet address over its sweep limit gets 429 with code RATE_LIMITED.
Errors
Errors are JSON with an error message and, in most cases, a machine-readable code:
{ "error": "Source chain not supported", "code": "INVALID_FROM_CHAIN" }
- 400 with code
FST_ERR_VALIDATION: a parameter failed validation. - 404 "Quote not found", "Sweep not found" and "Chain not found" have no code.
- 500 responses are generic and carry no detail.
Sweep flow
GET /quote: price the sweep and get aquoteId.POST /authorization: get the EIP-712SweepIntentfor that quote.- Sign the intent, an EIP-7702 delegation to
0x3732398281d0606aCB7EC1D490dFB0591BE4c4f2, and a revoke authorization (delegation to address 0, nonce + 1). POST /sweep: submit the signatures.GET /sweep/:sweepId: poll untilcompletedorfailed.
Check the quote before signing it. The SDK's ZeroDustAgent and verifySweepQuote() do this; see Agent.
With MetaMask
A wallet that supports ERC-7715 Advanced Permissions (MetaMask 13.23 or later) can sweep without exposing its key, through the ZeroDust permission router 0x369A97dd256F7eb37fF7116C4EcBd50318eBb286 (same address on every chain it is deployed on). Several chains take one permission request and one signature:
wallet_requestExecutionPermissions(in the wallet): per chain, anative-token-allowanceof the balance,isAdjustmentAllowed: false, with the rulesexpiry(the site uses 10 minutes),redeemer= router andpayee= router, andto= router. Keep each chain's returnedcontext. The first grant on a chain also upgrades the account to MetaMask's smart account, which spends a little of that chain's balance, so quote after granting.GET /quote?signer=permissionper chain.POST /authorization/batchwith the quote IDs: the EIP-712SweepBatchfor all of them.- Sign it once (
eth_signTypedData_v4). The domain has no chain id, so no network switch is needed. - For a quote routed through Relay, fetch the deposit from Relay yourself and bind it with
POST /quote/:quoteId/relay-route: Relay keeps the recipient on its servers, so only a deposit you requested pays the address you set. Then checkintent.callDatais exactly that deposit. POST /sweepper quote, each with the samesignature, that chain'spermissionContext, andbatchQuoteIdsin the order of step 3.GET /sweep/:sweepIduntilcompletedorfailed.
The SDK does not support this flow yet. Differences from the key flow:
- No EIP-7702 authorizations from you and no revoke (
autoRevoke: false,revokeGasUnits: "0"): the account stays MetaMask's smart account. - Cross-chain only through Relay, Across, Stargate or Hyperlane, which take the refund address and recipient explicitly. A pair only Gas.zip serves answers
PERMISSION_ROUTE_UNAVAILABLE; the key flow can still sweep it. - A chain's own bridge (
bridge=native) is not offered with a permission; sweep with the key. - Quotes live 55 seconds. If they expire while the wallet is signing, quote again and sign a new batch.
Chains
GET /chains
Chains ZeroDust sweeps with EIP-7702.
Query: testnet (boolean, default false). There are no testnet chains at the moment.
Response:
{
"chains": [
{
"chainId": 8453,
"name": "Base",
"nativeToken": "ETH",
"nativeTokenDecimals": 18,
"minBalance": "100000000000000",
"contractAddress": "0x3732398281d0606aCB7EC1D490dFB0591BE4c4f2",
"explorerUrl": "https://basescan.org",
"enabled": true,
"crossChain": { "available": true, "bridges": ["gaszip", "relay", "across"], "checkedAt": "2026-10-01T16:01:01.302Z" },
"simulation": { "reliable": true, "mode": "authorizationList", "checkedAt": "2026-10-01T16:00:51.180Z" },
"metamask": true
}
]
}
| Field | Notes |
|---|---|
minBalance |
Informational. Quotes do not enforce a fixed minimum; the fee reserve decides. |
crossChain |
Whether any bridge currently accepts this chain as a cross-chain source, and which. Checked every 10 minutes; null values until the first check. Advisory: /quote always asks the bridges live. |
simulation |
Whether this chain's RPC can simulate an EIP-7702 sweep (checked hourly). mode is authorizationList or stateOverride. Quotes from a chain with reliable: false are refused. |
metamask |
Whether a MetaMask permission sweep works on this chain (the permission router is deployed there) |
closing |
Only for a chain that announced its shutdown: stage (closing or cutoff), closesAt, cutoffAt (3 days earlier: sponsored sweeps stop), source (the announcement) and note. Never offered as a destination. In the list only; /chains/:chainId leaves it out. |
GET /chains/:chainId
One chain, same fields as above. 404 { "error": "Chain not found" } for an unknown ID.
GET /bridges/timing
How long each bridge usually takes to deliver, from real ZeroDust deliveries in the last 14 days. A bridge or pair with fewer than 3 deliveries is left out, so the lists can be empty. Cached for 5 minutes.
{
"routes": {
"relay": { "typicalSeconds": 18, "p90Seconds": 41, "samples": 23, "slowLately": false }
},
"pairs": {
"relay:42161": { "typicalSeconds": 16, "samples": 9 }
}
}
| Field | Notes |
|---|---|
routes |
Per bridge (for example relay, across, gaszip, stargate, hyperlane): median and 90th percentile seconds from the source transaction to delivery, and the number of deliveries |
slowLately |
The last 24 hours' median is above twice the usual median and at least 60 seconds more |
pairs |
Per bridge and source chain, keyed bridge:chainId |
GET /destinations
Chains a cross-chain sweep from fromChainId can deliver native gas to. Any EVM chain a routing bridge serves qualifies, not only ZeroDust chains. This is what bridges advertise; /quote confirms a route live.
Query: fromChainId (required, must be a ZeroDust chain).
{
"fromChainId": 8453,
"destinations": [
{ "chainId": 16661, "name": "0G", "nativeSymbol": "0G", "nativeDecimals": 18, "bridges": ["gaszip"], "zerodustChain": false }
]
}
zerodustChain is true when the destination is also a ZeroDust source chain. Errors: 400 INVALID_FROM_CHAIN.
Balances
GET /balances/:address
Native balance on every enabled chain.
Query: testnet (boolean, default false).
{
"address": "0x...",
"chains": [
{ "chainId": 8453, "name": "Base", "nativeToken": "ETH", "balance": "1200000000000000", "balanceFormatted": "0.0012", "canSweep": true, "minBalance": "100000000000000" }
]
}
canSweep is true for any balance above 0. Whether it covers the fees is decided by the quote.
GET /balances/:address/:chainId
One chain, same fields as an entry above. 404 { "error": "Chain not supported" } for an unknown chain.
Prices
GET /prices
USD prices of native tokens, cached for 10 minutes. For display.
{ "prices": { "ETH": 2681.24, "BNB": 765.75 }, "cachedAt": "2026-10-01T16:00:41.686Z", "ttlSeconds": 115 }
GET /prices/:symbol
{ "symbol": "ETH", "priceUsd": 2681.24 }. 404 when no price is available.
Quotes
GET /quote
Prices a sweep and stores the quote. Nothing is signed or sent.
| Query | Notes |
|---|---|
fromChainId |
Required. A ZeroDust chain. |
toChainId |
Required. Equal to fromChainId for a same-chain sweep, or any chain from /destinations. |
userAddress |
Required. The wallet to empty. |
destination |
Required. Where the funds go. |
signer |
key (default): EIP-7702, signed with the wallet's key. permission: through MetaMask and the permission router (With MetaMask). |
bridge |
native: through the source chain's own bridge to Ethereum, for chains that have one (Lisk, Arbitrum Nova; toChainId must be 1). Never chosen without it. See A chain's own bridge. |
ZeroDust always chooses the route. For a cross-chain sweep it asks every bridge that serves the pair (Gas.zip, Relay, Across, Stargate; Hyperlane, Metalayer or Endurance for the token deliveries) and keeps the highest expected output. Every route delivers the destination's native gas (see What arrives for the token exceptions): Across is asked only when both chains' gas is ETH, and only a plain deposit to a recipient with no contract code (or an EIP-7702 delegation) on the destination is accepted from it. Passing callTarget or callData is refused with CUSTOM_ROUTE_UNSUPPORTED.
Response:
| Field | Notes |
|---|---|
quoteId |
UUID, used by /authorization and /sweep |
version |
3 |
userBalance |
Balance at quote time |
estimatedReceive |
The least that arrives. Same-chain: exactly userBalance - fees.maxTotalFeeWei (the actual fee can only be lower). Cross-chain: the bridge's expected output less 3%, because bridges settle at their own price on delivery. |
mode |
0 transfer (same-chain), 1 bridge call (cross-chain) |
fees |
See below |
autoRevoke |
true with signer=key; false with signer=permission (no revoke) |
signer |
key or permission, as requested |
permission |
signer=permission only: router (the permission's delegate, redeemer and payee, and the typed data's verifyingContract), delegationManager (MetaMask's) and domainVersion (permission-2) |
receiveToken |
Token deliveries only (Mitosis and Endurance to BNB Chain, Intuition to Base): { symbol, address, decimals } of the ERC-20 token that arrives instead of gas. estimatedReceive and intent.minReceive are in this token. |
bridge |
Cross-chain only: name, displayName, inputAmount (wei sent to the bridge), expectedOutput (wei the bridge expects to deliver). With bridge=native, name is native and the sweep is completed once its source transaction succeeds: the funds wait in the bridge until the recipient claims them on Ethereum. |
intent |
Fields that go into the signed SweepIntent: mode, destination, destinationChainId (string), callTarget (zero address for same-chain), routeHash, minReceive, and callData for cross-chain. keccak256(callData) == routeHash, so the route can be decoded and checked before signing. |
deadline |
Unix seconds. The contract accepts at most 60 seconds ahead. |
nonce |
The wallet's ZeroDust intent nonce |
authNonce |
The wallet's transaction count, which the EIP-7702 delegation must use (signer=key only) |
validForSeconds |
55 |
intent.minReceive is the least the contract will accept: equal to estimatedReceive for a same-chain sweep; for a cross-chain sweep, the bridge's expected output less 0.5%, then 5%.
Fees in a quote
| Field | Notes |
|---|---|
maxTotalFeeWei |
The fee reserve. The whole reserve leaves the wallet: the contract reimburses the sponsor's gas from it and sends the unused part to the sponsor. |
extraFeeWei |
Fixed part of the reserve: the service fee, the L1 data fee on rollups, and per-chain surcharges |
overheadGasUnits |
Gas the contract cannot measure itself (intrinsic cost, authorization, signature checks) |
protocolFeeGasUnits |
Always 0 (deprecated) |
reimbGasPriceCapWei |
Highest gas price the sponsor can be reimbursed at: 1.2 times the gas price at quote time |
revokeGasUnits |
Gas reserved for the revoke transaction |
The service fee in extraFeeWei is 5% of the balance's USD value under $1, and 1% from $1 with a minimum of $0.05 and a maximum of $0.50.
Errors (400 unless noted):
| Code | Meaning |
|---|---|
INVALID_FROM_CHAIN |
Source chain not supported |
INVALID_TO_CHAIN |
No bridge delivers from the source to this chain |
BALANCE_TOO_LOW |
The balance is 0 |
INSUFFICIENT_FOR_FEES |
The balance does not cover the fee reserve |
SOURCE_CHAIN_DISABLED, DEST_CHAIN_DISABLED, BRIDGE_UNAVAILABLE |
No cross-chain route right now |
SIMULATION_UNAVAILABLE |
The chain's RPC cannot simulate the sweep right now |
CHAIN_PAUSED |
Sweeps from this chain are paused for now |
SPONSOR_UNAVAILABLE |
The sponsor cannot pay for this sweep on this chain right now |
L1_FEE_UNAVAILABLE |
The rollup's L1 data fee could not be priced; try again shortly |
CHAIN_CLOCK_LAG |
The chain's latest block is too far behind to leave time to sign |
CHAIN_CLOSING |
The source chain is past its cut-off or closed, or the destination is a closing chain; the message names the date and the chain's own bridge |
ADDRESS_TEMPORARILY_BLOCKED |
Two or more sweeps from this address failed for wallet-side reasons; try again later |
CUSTOM_ROUTE_UNSUPPORTED |
callTarget or callData was passed |
AMOUNT_TOO_LOW |
Below every bridge's minimum; minimumBalanceWei is the balance to top up to (an estimate) |
OWN_WALLET_ONLY |
Endurance: its bridge pays only the sweeping wallet, so destination must be userAddress |
PERMISSION_UNAVAILABLE |
signer=permission on a chain without the router, or one MetaMask does not offer permissions on |
PERMISSION_ROUTE_UNAVAILABLE |
signer=permission, and only a bridge unsafe for the router (Gas.zip) serves this pair |
INTERNAL_ERROR (500) |
The quote could not be stored |
POST /quote/:quoteId/relay-route
Binds a Relay deposit you requested from Relay yourself into a signer=permission quote routed through Relay. Relay keeps a request's recipient on its own servers and the deposit only names an id, so a route someone else requested cannot be checked; one you requested can.
Request the deposit with POST https://api.relay.link/quote: user and refundTo = the wallet, recipient = the destination, originChainId / destinationChainId, both currencies 0x0000000000000000000000000000000000000000, amount = the quote's bridge.inputAmount, tradeType EXACT_INPUT. Check Relay's answer (one transaction on the source chain for exactly that amount, details.recipient, native currencyOut on the destination, at least estimatedReceive), then send its transaction here.
Body: { "callTarget": "<tx.to>", "callData": "<tx.data>", "requestId": "<Relay's request id>" }
Returns { quoteId, intent }, the quote's intent now carrying that deposit (callTarget, callData, routeHash). Sign only if intent.callData is the deposit Relay gave you.
Errors (400): NOT_RELAY_ROUTE, PERMISSION_ONLY (key quotes keep their route), ROUTE_ALREADY_BOUND (one bind per quote), QUOTE_EXPIRED, QUOTE_USED (already swept), INVALID_RELAY_ROUTE (not a Relay contract on the source chain, a depository deposit crediting another wallet, a multicall refunding to a third party, or a deposit that would fail from the wallet), ROUTE_GAS_CHANGED (its gas no longer fits the quote's fee reserve: quote again). 404 when the quote does not exist.
POST /authorization
Returns the EIP-712 typed data for a quote.
Body: { "quoteId": "<uuid>" }
| Field | Notes |
|---|---|
sweepType |
same-chain or cross-chain |
typedData |
EIP-712 SweepIntent. Domain: name ZeroDust, version 3, chainId = source chain, verifyingContract = the wallet's own address. |
contractAddress |
The ZeroDust contract to delegate to |
version |
3 |
Errors: 404 "Quote not found", 400 QUOTE_EXPIRED, 400 CONTRACT_NOT_DEPLOYED.
For a signer=permission quote, typedData is a SweepBatch of that one chain and contractAddress is the router. Use POST /authorization/batch to cover several chains with one signature.
The SweepIntent type is mode uint8, user address, destination address, destinationChainId uint256, callTarget address, routeHash bytes32, minReceive uint256, maxTotalFeeWei uint256, overheadGasUnits uint256, protocolFeeGasUnits uint256, extraFeeWei uint256, reimbGasPriceCapWei uint256, deadline uint256, nonce uint256. Build it from the verified quote and compare it with this response rather than signing the response as received.
POST /authorization/batch
Returns one EIP-712 SweepBatch for several signer=permission quotes, one per chain.
Body: { "quoteIds": ["<uuid>", ...] }, 1 to 64 quotes, in the order you want signed.
| Field | Notes |
|---|---|
typedData |
EIP-712 SweepBatch(ChainSweep[] sweeps), ChainSweep(uint256 chainId, SweepIntent intent). Domain: name ZeroDust, version permission-2, verifyingContract = the router, no chainId. |
router |
The permission router |
entries |
{ quoteId, chainId, index } per quote: its place in the batch |
Errors (400): INVALID_BATCH (a quote missing or listed twice, not a signer=permission quote, another wallet, or the same chain twice), QUOTE_EXPIRED (quote again).
Rebuild each SweepIntent from its verified quote and compare before signing, as for a single quote.
Sweeps
POST /sweep
Submits a signed sweep. The relayer simulates it, sends it, then sends the revoke.
Body:
| Field | Notes |
|---|---|
quoteId |
Required |
signature |
Required. EIP-712 signature of the SweepIntent (64 or 65 bytes, hex). |
eip7702Authorization |
Required with signer=key. { chainId, contractAddress, nonce, yParity, r, s }, delegating to the ZeroDust contract on the source chain. |
revokeAuthorization |
Required with signer=key. Same shape, delegating to 0x0000000000000000000000000000000000000000 on the same chain with nonce = delegation nonce + 1, so the delegation is removed right after the sweep (its gas is in the quoted fee). A sweep without it is refused with REVOKE_AUTHORIZATION_REQUIRED. |
| permissionContext | Required with signer=permission, and only then: the context the wallet returned for this chain's permission. It must hold one delegation, from this wallet, to the router. |
| batchQuoteIds | signer=permission only: every quote the signature covers, in the order of POST /authorization/batch. Defaults to this quote alone. |
{ "sweepId": "6f1c...", "status": "pending", "sweepType": "cross-chain", "isExisting": false, "version": 3 }
Idempotent per quote: submitting a quoteId that already has a sweep returns that sweep with isExisting: true.
Errors (400 unless noted):
| Code | Meaning |
|---|---|
QUOTE_EXPIRED |
Past the quote's deadline |
INVALID_SIGNATURE |
The SweepIntent (or, with signer=permission, the SweepBatch) signature does not verify for this wallet |
CHAIN_ID_MISMATCH |
The delegation is for another chain |
CONTRACT_ADDRESS_MISMATCH |
The delegation targets another contract |
EIP7702_INVALID_SIGNATURE |
The delegation was not signed by the wallet |
INVALID_REVOKE_TARGET |
The revoke does not delegate to address 0 |
REVOKE_CHAIN_ID_MISMATCH |
The revoke is for another chain |
REVOKE_NONCE_MISMATCH |
The revoke nonce is not delegation nonce + 1 |
REVOKE_INVALID_SIGNATURE |
The revoke was not signed by the wallet |
EIP7702_AUTHORIZATION_REQUIRED, REVOKE_AUTHORIZATION_REQUIRED |
signer=key without the delegation or the revoke |
PERMISSION_CONTEXT_REQUIRED |
signer=permission without permissionContext |
INVALID_PERMISSION_CONTEXT |
The context is too large, not a MetaMask delegation chain, holds more than one delegation, or is not from this wallet to the router |
PERMISSION_CONTEXT_UNEXPECTED |
permissionContext on a signer=key quote |
INVALID_BATCH |
batchQuoteIds does not include this quote, fails the batch rules above, or was sent with a signer=key quote |
ADDRESS_TEMPORARILY_BLOCKED, CHAIN_PAUSED |
As for /quote |
RATE_LIMITED (429) |
More than 60 sweeps a minute from this address |
| 404 | Quote not found |
GET /sweep/:sweepId
| Field | Notes |
|---|---|
sweepId, status, sweepType, mode |
status is pending, simulating, executing, broadcasted, bridging, completed or failed |
txHash |
Sweep transaction on the source chain |
destinationTxHash |
Delivery transaction (cross-chain) |
amountSent |
Wei |
destination, fromChainId, toChainId |
|
errorMessage |
When failed |
bridgeTrackingUrl |
Cross-chain, once there is a transaction |
revokeStatus |
not_requested, pending, executing, completed or failed |
revokeTxHash, revokeError |
|
createdAt, updatedAt, version |
completed and failed are final. Confirm the result on-chain: the source balance is 0, the wallet has no code once the revoke lands, and the funds arrived.
GET /sweeps/:address
Sweeps for a wallet, newest first.
Query: limit (1 to 100, default 20), offset (default 0), status (one of the statuses above).
{ "sweeps": [{ "sweepId": "...", "status": "completed", "sweepType": "same-chain", "mode": 0, "fromChainId": 8453, "toChainId": 8453, "amountSent": "...", "txHash": "0x...", "version": 3, "createdAt": "..." }], "total": 1 }
API keys
A key gives you your own rate limit, separate from your IP's. Send it as X-API-Key: <key> (what the SDK and MCP server send) or Authorization: Bearer <key>. Treat a key as a secret; it cannot move funds.
- Where it applies: the reads
/chains,/chains/:chainId,/destinations,/balances/*,GET /sweep/:sweepIdand/sweeps/:address, plus/quote,POST /authorizationandPOST /sweep. Each route has its own per-minute bucket for the key, at the key's limit (300 for agent keys) and never below the anonymous limit. The 60 sweeps per minute per wallet address still apply. - Where it does not:
/pricesand the/direct/*routes keep the per-IP limits. - One IP, at most two keys an hour. Further keys used from the same IP get that IP's anonymous limits.
- An invalid, revoked or expired key is treated as no key: the request gets the anonymous limits, not an error. Check a key with
GET /agent/me. - A revoked key can keep its higher limits for up to a minute.
POST /agent/register
Issues a key. Limited to 5 per IP per hour.
| Body | Notes |
|---|---|
name |
Required, 3 to 100 characters |
agentId |
Optional, up to 255 characters |
contactEmail |
Optional |
webhookUrl |
Optional URL, stored with the key |
metadata |
Optional object, up to 20 properties |
Response (201): apiKey (shown once, not retrievable later), keyPrefix, keyId, keyType (agent), rateLimits (perMinute 300, daily 1000) and message.
GET /agent/me
Requires a key. Returns keyId, keyType, agentId, rateLimits (perMinute, daily, dailyUsed, dailyRemaining) and stats for the last 7 days (totalRequests, successfulRequests, failedRequests, avgResponseTimeMs).
DELETE /agent/key
Requires a key. Revokes it permanently.
Requests to these routes count against the key's own per-minute limit (300 for agent keys) as well as the IP limit. Missing or invalid key: 401.
MCP and discovery
| Route | Notes |
|---|---|
POST /mcp |
Hosted MCP server, JSON-RPC 2.0. See MCP server. |
GET /mcp |
Server info and tool list |
GET /mcp/tools |
Tool definitions as plain JSON |
GET /.well-known/agent-card.json |
A2A agent card |
GET /erc8004/agent.json |
ERC-8004 registration metadata |
Health
GET /health:{ "status": "ok", "timestamp": "..." }.GET /ready: also checks the database. 200{ "status": "ok", "database": "connected", "timestamp": "..." }, or 503 withdatabase: "disconnected".