The router quotes conversions between two assets across live L2 order books from ~60 exchanges. You say what you hold and what you want; it picks the market, the direction, the venues, and — when no direct market exists — the bridge.
Base URL for the beta:
https://docs.ccxt.com/router/api
One call. Substitute your key.
curl -s "https://docs.ccxt.com/router/api/route?from=USDT&to=BTC&amountIn=50000" \
-H "x-api-key: or_live_YOUR_KEY_HERE"
That reads as: I hold 50,000 USDT, I want BTC, how much do I end up with? The response, abbreviated to the fields that matter:
{
"requestId": "9f2c1e04-8b3a-4d61-9c77-2a5f0e1b7d43",
"from": "USDT",
"to": "BTC",
"amountIn": 50000, // what the route actually spends
"amountOut": 0.47677, // what it actually produces
"requestedAmount": 50000, // what you asked for
"exactSide": "in", // which side you pinned
"effectiveRate": 9.5354e-6, // BTC per USDT, after all fees and hops
"referenceRate": 9.5438e-6, // the same rate if size were free
"impactBps": 8.79, // how much your size cost you, end to end
"fullyFillable": true,
"fillRatio": 1,
"unroutableReason": null,
"warnings": [],
"hops": [
{
"pair": "BTC/USDT", "side": "buy", "base": "BTC", "quote": "USDT",
"amountIn": 50000, "amountOut": 0.47677,
"legs": [
{ "exchangeId": "binance", "amount": 0.47677, "averagePrice": 104766.2,
"takerFeeRate": 0.001, "feeCost": 49.95, "effectivePrice": 104870.97 }
],
"feeCost": 49.95, "feeCurrency": "USDT",
"referencePrice": 104780.1, "impactBps": 8.67,
"venueCount": 47, "freshVenueCount": 41,
"quotes": [ /* one entry per venue considered — the "why these?" diagnostic */ ]
}
]
}
Read fillRatio before you read effectiveRate. That is the single most common way to misread this API, and it is covered in Reading the response correctly.
Every endpoint except GET /health requires an API key. Two header forms are accepted and they are equivalent:
x-api-key: or_live_YOUR_KEY_HERE
Authorization: Bearer or_live_YOUR_KEY_HERE
If both are present, x-api-key wins. The Bearer form is matched case-insensitively on the scheme.
A rejected request looks like this, with no other information in it:
HTTP/1.1 401 Unauthorized
content-type: application/json
{ "error": "unauthorized" }
Missing, unknown and revoked keys are deliberately indistinguishable. All three produce the identical body and status. The response is not an oracle for whether a key was ever real, or whether it used to be. If you are debugging an integration, that means a 401 tells you nothing beyond "this request was not authorised" — check the key you sent, not the response.
For the same reason, unknown paths return 401 rather than 404 when you have no valid key. Without a key you cannot enumerate which routes exist. With a valid key a genuine typo does return a truthful 404.
Failed authentication consumes rate-limit budget. Wrong-key requests are counted and throttled like any other traffic; see Rate limits.
Keys are stored as digests, never in plaintext. A key is shown exactly once, when it is issued, and cannot be recovered afterwards. Revocation takes effect within about 10 seconds, and it reaches open WebSocket streams too — a revoked key's live /stream/route sockets are closed with code 1008 rather than being allowed to run until the client hangs up.
GET /routeReturns an ordered list of hops that converts from into to. A single-market conversion is hops.length === 1; a bridged one (e.g. SOL → USDT → BTC) is longer.
There is no side parameter. You name the asset you are spending and the asset you want, and the router works out both the market and the direction:
from=USDT&to=BTC is a buy of BTC/USDT.from=BTC&to=USDT is a sell of the very same market.This is deliberate. Mapping an intent onto a symbol plus a side is the single most error-prone step in an order-routing API — get it backwards and you receive a confident, well-formed quote for the opposite trade. Deriving it server-side removes the class of bug entirely. The derived side is reported back per hop as hops[].side.
Asset codes are trimmed and upper-cased, so from=usdt and from=USDT are the same request. from must differ from to.
amountIn / amountOutamountIn — how much of from you will spend.amountOut — how much of to you want to end up with.Supplying both, or neither, is a 400:
{ "error": "exactly one of amountIn or amountOut must be supplied" }
Silently preferring one would turn a caller's typo into a confidently wrong route, so the request is refused instead. The pinned side is echoed as exactSide ("in" or "out") and the value you sent as requestedAmount.
The two are not a unit conversion of each other — they are different traversals of the book. amountIn on a buy is a notional walk: consume levels until the money runs out. amountOut is a quantity walk: consume levels until the size is reached. Which one you pin changes what the router optimises, not just how the answer is expressed.
amountIn / amountOut in the response body report what the route achieves, not what you asked for. On an unroutable request both are 0. The request is never echoed back as if it were an outcome — a fill that will not happen is never presented as one.
| Parameter | Type | Default | Notes |
|---|---|---|---|
from | string | — (required) | The asset you are spending, e.g. USDT. Case-insensitive. |
to | string | — (required) | The asset you want to acquire. Must differ from from. |
amountIn | number > 0 | — | Exact amount of from to spend. Supply this or amountOut, never both. |
amountOut | number > 0 | — | Exact amount of to to acquire. Supply this or amountIn, never both. |
strategy | best_single | split_optimal | split_capped | best_single | See Strategies. Anything else is a 400. |
maxVenues | integer ≥ 1 | 3 | Per-hop venue cap. Only meaningful with split_capped. |
bridges | comma-separated | USDT,USDC,BTC,ETH | Intermediary assets to consider. An explicitly empty value (bridges=) disables bridging entirely — direct markets only. |
hopPenaltyBps | number 0–10000 | 5 | How much better a bridged route must be, per extra hop, before it beats a direct market. 0 compares purely on rate. |
exchanges | comma-separated | unrestricted | Venue allowlist, e.g. binance,kraken,bybit. An explicitly empty value (exchanges=) means no venues, not all venues — and yields an empty route. |
certified | true | false | false | Consider only exchanges CCXT marks as certified (22 of 80 pro classes at time of writing). Composes with exchanges as an intersection. |
includeFees | true | false | true | Set false to rank on raw price only — useful with negotiated tiers or maker rebates. Then feeCost is 0 and effectivePrice equals averagePrice. |
includeQuotes | true | false | true here, false on /stream/route | Return the per-venue quotes[] diagnostic. Suppressing it never changes the routing decision; only savingVsBestSingleBps may become null, since the single-venue baseline comes from the same pass. |
minLegNotional | number ≥ 0 | 0 | Suppress legs below this quote-currency notional and reallocate the freed size, so a split does not produce legs a venue would reject on minimum-order rules. If every leg falls below it the route is kept rather than emptied. |
requireFullFill | true | false | false | Refuse partial fills. When cached depth cannot cover the whole size the route comes back empty with unroutableReason: "insufficient_depth" rather than a partial whose effectiveRate prices only part of the order. |
x-request-id (header) | string | minted | Caller-supplied audit id, echoed in the body as requestId and in the x-request-id response header. Accepted only if it matches ^[\w.\-]{1,200}$; otherwise a UUID is minted silently. |
Booleans are compared against literal strings. includeFees and includeQuotes are disabled only by the exact value false — anything else, including 0 and no, leaves them on. certified and requireFullFill are enabled only by the exact value true. Send true / false and nothing else.
Repeated parameters are rejected. ?from=A&from=B is a 400 ("from must not be repeated") rather than a silent pick of one. Taking either value would let a duplicated requireFullFill or certified drop a safety flag without the caller ever knowing.
There is deliberately no best field and no top-level side. A single-venue answer is a hop with one leg; a single-market answer is a route with one hop. Two fields carrying the same answer would be two sources of truth, and callers would eventually read the wrong one and under-fill.
| Field | Type | Meaning |
|---|---|---|
requestId | string | Audit id for this recommendation. Logged server-side alongside the full decision. |
calculatedAt | integer | Epoch ms at which the route was computed. Subtract from your receive time to measure network delay. |
calculatedAtIso | string | The same instant, ISO 8601. |
from / to | string | The assets, normalised to upper case. |
amountIn | number | Amount of from the route actually spends. 0 when unroutable. |
amountOut | number | Amount of to the route actually produces. 0 when unroutable. |
requestedAmount | number | What was asked for, in the units of exactSide. |
exactSide | in | out | Which side the caller pinned. The other side is the computed result. |
effectiveRate | number | null | amountOut / amountIn end to end, after all fees and all hops — units of to per unit of from. On a partial fill this prices only the filled portion. |
referenceRate | number | null | The end-to-end frictionless rate: each hop's referencePrice chained together. What you would receive if size were free. Null when any hop could not be benchmarked. |
impactBps | number | null | How far effectiveRate falls short of referenceRate, in basis points — the end-to-end cost of size. Positive is worse. |
hops | array | The route, in execution order. Length 1 for a direct market, longer when bridged. Empty when nothing sufficiently fresh could fill; on a mid-route failure the hops solved so far are still returned, for diagnosis. |
fullyFillable | boolean | True only when every hop filled completely. |
fillRatio | number | achieved / requested on the pinned side. Check this before using effectiveRate. |
unfilledAmount | number | The remainder of the pinned side that could not be routed. |
unroutableReason | enum | null | Why hops is empty; null when routing succeeded. See the table below. |
unroutableHopIndex | integer | null | Which hop failed, for a bridged route. Null when the whole request was unroutable, or when routing succeeded. |
warnings | string[] | Human-readable cautions, prefixed partial_fill: or multi_hop:. Empty on a clean single-hop full fill. |
savingVsBestSingleBps | number | null | How much a split beats the best single venue, in bps. Positive means better, for both directions. Null on multi-hop routes (no single-venue baseline exists), and null when the baseline was not computed. |
pathsConsidered | array | Every candidate market path evaluated, winner flagged. Empty when only one path existed — there was nothing to choose between. |
strategy | enum | Echoed. |
includeFees | boolean | Echoed. |
exchangesFilter | string[] | null | The venue allowlist actually applied, sorted. Null when unrestricted. |
certifiedOnly | boolean | Echoed. |
staleBookMs | number | The freshness cutoff applied, echoed so bookAgeMs on each quote is interpretable without knowing server config. |
staleBookMs | number | The freshness cutoff the router applied. Books older than this were excluded. Not a request parameter — see below. |
stalenessPenaltyBps | number | The staleness markdown applied, in bps per √second of book age. Not a request parameter. |
hopPenaltyBps | number | Echoed. |
requireFullFill | boolean | Echoed, so you can confirm the safety flag you sent was applied. Without the echo, a request that lost the flag in transit is indistinguishable from one that never set it. |
hops[]One hop is one market conversion. amountIn is denominated in that hop's input asset and amountOut in its output asset — for a buy that is quote-in / base-out, for a sell base-in / quote-out.
| Field | Type | Meaning |
|---|---|---|
pair | string | The market this hop trades, e.g. BTC/USDT. |
side | buy | sell | Derived from the asset direction, not supplied by the caller. |
base / quote | string | The market's two assets. |
amountIn / amountOut | number | Consumed and produced by this hop, in this hop's own input/output assets. |
legs | array | The venues to trade on for this hop, largest leg first. Length 1 for best_single. |
feeCost | number | Total taker fee for this hop, denominated in feeCurrency. |
feeCurrency | string | The hop's quote currency. Reported per hop precisely because a route's hops can charge in different currencies. |
fullyFillable | boolean | False means cached depth could not cover this hop's requested size. |
referencePrice | number | null | The best fee- and staleness-adjusted price available on any fresh permitted venue for an infinitesimally small order — the frictionless benchmark. Null when nothing fresh could be priced. |
impactBps | number | null | How much worse this hop's size executes than referencePrice, in bps. Positive is always worse, on both sides. |
venueCount | integer | Venues considered for this hop after the exchanges / certified filters, fresh or not, and regardless of whether quotes[] was returned. |
freshVenueCount | integer | How many of those were within the freshness cutoff. venueCount vs freshVenueCount distinguishes "the filters excluded everything" from "every book was stale" without needing the diagnostic. |
quotes | array | Every venue considered for this hop, including stale and unfillable ones. Present only when includeQuotes is on. |
hops[].legs[]| Field | Type | Meaning |
|---|---|---|
exchangeId | string | Venue to trade on. |
amount | number | Size to fill on this venue. |
averagePrice | number | Raw VWAP for this leg, before fees. |
takerFeeRate | number | e.g. 0.001. |
feeCost | number | What the fee costs on this leg, in quote currency. |
effectivePrice | number | Fee-adjusted price for this leg. |
hops[].quotes[]The "why these venues?" diagnostic — every venue considered, whether or not it was used, whether or not it was fresh.
| Field | Type | Meaning |
|---|---|---|
exchangeId | string | The venue. |
side | buy | sell | The derived side for this hop. |
requestedAmount | number | The target this venue was probed against, in the units of the hop's pinned side — base units for a quantity walk, quote units for a notional walk. |
filledAmount | number | How much this venue's cached book could fill, always in base units. |
averagePrice | number | null | Raw VWAP across the levels walked, before fees and before any staleness penalty. Null if nothing could fill. Do not rank on it. |
effectivePriceWithFee | number | null | averagePrice adjusted for the venue's taker fee, and for the staleness penalty when one is set. Rank on this. |
takerFeeRate | number | The venue's taker fee. |
fullyFillable | boolean | False means the requested size exceeded this venue's cached depth. |
bookAgeMs | integer | Age of the underlying book. Compare against the top-level staleBookMs. |
pathsConsidered[]What quotes[] is to "why this venue?", pathsConsidered is to "why this market?". When more than one market path exists the router solves all of them — the direct market plus one two-hop route per asset in bridges — and takes the best. It does not stop at the first bridge that happens to work.
| Field | Type | Meaning |
|---|---|---|
pairs | string[] | The markets this path would trade, in order. |
bridge | string | null | The intermediary asset, or null for a direct market. |
amountOut | number | Raw output this path would produce. |
fullyFillable | boolean | Whether this path could cover the size. |
score | number | amountOut after the per-extra-hop penalty. This, not amountOut, decides the winner — so a path can show the highest amountOut and still lose. That is intended. |
chosen | boolean | The winner. |
A longer path must beat a shorter one by more than hopPenaltyBps per extra hop. A second order is a second chance for the price to move between fills, and that risk is not in any order book — so a bridge winning by a hair is not actually the better trade.
unroutableReason| Value | Status | Meaning |
|---|---|---|
no_market | 404 | No direct market and no bridge path exists between the two assets at all. |
exact_out_multi_hop_unsupported | 501 | The assets are reachable, but only over a bridge, and exact-out across hops needs a backwards solve the router does not implement. Re-ask with amountIn. |
no_venues_matched_filter | 200 | The exchanges / certified filters excluded everything. |
all_books_stale | 200 | Venues exist but every book exceeded the freshness cutoff. |
no_liquidity | 200 | Fresh books exist but none had depth to fill any of the size. |
insufficient_depth | 200 | requireFullFill was set and depth could not cover the size. |
null | 200 | Routing succeeded. |
Every hop carries referencePrice and impactBps; the route carries the end-to-end pair referenceRate and impactBps. Positive always means worse, on both sides — no branching on side to interpret it. Fees and the staleness penalty appear in both halves of the comparison and cancel, so what is left is purely the cost of consuming depth. That is the number that answers "should I split this order, or shrink it?" without walking the book yourself.
The benchmark is deliberately the best price anywhere, not the chosen venue's own top of book. Using the latter would report zero impact for an order that demonstrably paid more than the best available price, simply because the cheapest venue was too thin to use.
The per-hop and route-level impactBps use different denominators. A hop measures against its benchmark price — the trading convention, (executed − benchmark) / benchmark. The route measures against the benchmark rate. For a buy the two are reciprocals, so the numbers agree to first order and diverge only as impact grows large. Use the hop figure to reason about a single fill and the route figure to compare whole routes.
strategy changes the number of legs per hop and nothing else about the response shape. A single-venue answer is just a hop with one leg.
| Strategy | Legs per hop | What it is for |
|---|---|---|
best_single (default) |
1 | One venue per hop. You have one funded account, or you want one order and one fill to reconcile. Simplest execution, most price impact. |
split_optimal |
unlimited | Minimum cost across the consolidated fee-adjusted book. The best achievable price for the size, at the cost of an unbounded number of simultaneous orders across venues you must already be funded on. |
split_capped |
at most maxVenues |
Most of the split's benefit with bounded execution risk and a bounded number of funded accounts. The usual production choice. |
# "I want 1 BTC — spend as little USDT as possible, across at most 3 venues"
curl -s "https://docs.ccxt.com/router/api/route?from=USDT&to=BTC&amountOut=1\
&strategy=split_capped&maxVenues=3" -H "x-api-key: $KEY"
split_capped is a greedy approximation, not a proven optimum. It solves unconstrained, keeps the highest-volume venues, re-solves, and falls back to the deepest venues if that set cannot fill. It will usually be very close to the constrained optimum; it is not guaranteed to be it.
Compare a split against the single-venue baseline with savingVsBestSingleBps, which is computed in the same pass. It is null on multi-hop routes, where no single-venue baseline exists, and may be null if includeQuotes=false suppressed the pass that produces it.
Splitting is worth reaching for when a hop's impactBps is large — that is the depth cost the split is there to reduce. When impact is a fraction of a basis point, best_single and a split will quote nearly the same rate and the split just buys you more orders to manage.
minLegNotional pairs with both split strategies: it suppresses legs below a quote-currency notional and reallocates the freed size, so a split does not hand you legs a venue would reject on minimum-order rules.
Three responses are well-formed, return 200, and will mislead you if read at a glance. Handle all three before trading on a quote.
fillRatio before you trust effectiveRateOn a partial fill, effectiveRate prices only the filled portion. It is not the rate for the size you asked for, and the unfilled remainder would cost more — that is precisely why it did not fill.
GET /route?from=USDT&to=BTC&amountOut=25
{
"requestedAmount": 25,
"exactSide": "out",
"amountIn": 242618.9,
"amountOut": 2.31, // not 25
"fillRatio": 0.0924, // 9.24% of what you asked for
"unfilledAmount": 22.69,
"fullyFillable": false,
"effectiveRate": 9.5211e-6, // the rate for 2.31 BTC, NOT for 25 BTC
"warnings": [
"partial_fill: only 9.24% of the requested BTC could be routed. effectiveRate prices
the FILLED portion only; the remainder would cost more."
]
}
Reading that effectiveRate as the price of 25 BTC misprices the order by an order of magnitude. The guard is one line:
if (route.fillRatio < 1) { /* effectiveRate covers route.amountOut only */ }
If you would rather never see a partial, send requireFullFill=true. You then get an empty route with unroutableReason: "insufficient_depth" instead of a partial you have to remember to check. The flag is echoed back in the response so you can confirm it was actually applied.
A bridged route is trimmed so it never strands capital: if an early hop could consume everything you offered but a later hop cannot absorb what it produces, the earlier hop is cut back to what the next one actually takes. That is why a bridged route can report a low fillRatio even though hop 1 on its own would have filled completely.
hops with an unroutableReason is a refusal to quote, not an errorIt arrives as a 200 with a complete, valid body. The market exists; the router simply has no price it is willing to stand behind.
GET /route?from=USDT&to=SOMETHIN&amountIn=1000
HTTP/1.1 200 OK
{
"from": "USDT", "to": "SOMETHIN",
"amountIn": 0, "amountOut": 0, // achieved, not requested
"requestedAmount": 1000,
"exactSide": "in",
"effectiveRate": null,
"hops": [],
"fillRatio": 0,
"unroutableReason": "all_books_stale",
"unroutableHopIndex": null,
"staleBookMs": 5000
}
all_books_stale in particular is common on the illiquid tail at full-discovery scale. It means "no reliable price", not "no market" — the venues are there, their books are just older than the freshness cutoff. Freshness is not something you configure. Deciding whether a book is still worth pricing is the router's judgment, made against update rates a caller cannot see — and a millisecond threshold is the wrong shape for the question. Both the cutoff and the age markdown are always applied and always echoed, so you can see what was decided.
Distinguish the empty-route reasons before retrying. no_venues_matched_filter means your own exchanges / certified filters excluded everything — retrying will not help until you widen them. no_liquidity means fresh books exist but none had any depth. Compare hops[].venueCount against hops[].freshVenueCount to tell filtering from staleness without requesting the quotes[] diagnostic.
Because both amounts are 0 on an unroutable request, a client that reads amountOut without checking unroutableReason gets a zero rather than a phantom fill. Do not read requestedAmount as an outcome — it is the input.
hops.length > 1 means two orders, not oneWhen no direct market exists — or when a bridged path simply beats the direct one — the route is bridged. That is two separate orders, executed in sequence, each with its own execution risk. The price can move between them, and that risk is not in any order book.
GET /route?from=SOL&to=BTC&amountIn=100
{
"from": "SOL", "to": "BTC",
"amountIn": 100, "amountOut": 0.16195,
"hops": [
{ "pair": "SOL/USDT", "side": "sell", "base": "SOL", "quote": "USDT",
"amountIn": 100, "amountOut": 16988.0,
"feeCost": 17.01, "feeCurrency": "USDT",
"referencePrice": 169.94, "impactBps": 3.5, "legs": [ /* ... */ ] },
{ "pair": "BTC/USDT", "side": "buy", "base": "BTC", "quote": "USDT",
"amountIn": 16988.0, "amountOut": 0.16195,
"feeCost": 16.97, "feeCurrency": "USDT",
"referencePrice": 104780.1, "impactBps": 10.9, "legs": [ /* ... */ ] }
],
"warnings": [
"multi_hop: routed via SOL/USDT -> BTC/USDT. Hops are solved sequentially, not jointly,
so this is a good route rather than a provably optimal one. Each hop carries its own
execution risk and fees (see feeCurrency per hop)."
],
"savingVsBestSingleBps": null,
"pathsConsidered": [
{ "pairs": ["SOL/USDT","BTC/USDT"], "bridge": "USDT", "amountOut": 0.16195,
"score": 0.161869, "chosen": true }, // penalised: 1 extra hop x 5 bps
{ "pairs": ["SOL/BTC"], "bridge": null, "amountOut": 0.16170,
"score": 0.16170, "chosen": false } // no extra hops, so no penalty
]
}
Three consequences:
feeCurrency is the hop's quote currency, and a route's hops need not share one — a route bridged through BTC reports one hop's fee in BTC and the next's in USDC. There is no cross-hop fee total on purpose: summing USDT fees and BTC fees would produce a meaningless number.savingVsBestSingleBps is null. There is no single-venue baseline for a multi-hop route to be compared against.If you cannot execute two orders, set bridges= (explicitly empty) to restrict routing to direct markets. You will get fewer routable pairs and sometimes a worse rate, but never a two-order plan you cannot act on.
Note that a bridged route can win even when a direct market exists — pathsConsidered above shows exactly that, and shows the losing candidate's numbers so the choice is auditable. If you want the direct market preferred more strongly, raise hopPenaltyBps; if you want the comparison made purely on rate, set it to 0.
| Status | Body | When |
|---|---|---|
200 |
RouteResult | A route was computed. Includes the case where hops is empty with an unroutableReason — that is a routing outcome, not a request error. |
400 |
{ "error": "…" } |
Invalid parameters: missing from/to, from equal to to, neither or both of amountIn/amountOut, a non-positive or non-finite amount, an unknown strategy, an out-of-range numeric, or a repeated query parameter. The message names the specific problem. |
401 |
{ "error": "unauthorized" } |
Missing, unknown or revoked key — all three identical. Also returned for unknown paths when the key is not valid. |
404 |
RouteResult with unroutableReason: "no_market" |
No market and no bridge path exists between from and to at all. |
429 |
{ "error": "…" } |
Rate limit exceeded. See Rate limits. |
501 |
RouteResult with unroutableReason: "exact_out_multi_hop_unsupported" |
amountOut was requested for a conversion that only routes over a bridge. Re-ask with amountIn. |
hops 200 are different thingsThey answer two different questions, and conflating them would make one of them unactionable.
hops 200 is a problem with the market, right now. The path exists and the request is well-formed; the books were stale, or thin, or your filters excluded every venue. The identical request may well succeed on the next call, or with a smaller size.Retry logic should branch on exactly that. Retrying a 404 is a wasted call forever; retrying a stale-book 200 is often the correct move.
501 sits in between deliberately: the request is syntactically fine (so not a 400) and the assets are reachable (so not a 404) — the router just cannot solve the exact-out shape across hops. There is a concrete next action, and the body names it.
A 404 also comes from GET /orderbook/:exchange/:symbol when no book is cached for that pair; the body there is { "error": "no cached order book for binance:BTC/USDT" }, not a RouteResult.
Every response carries the current budget:
| Header | Meaning |
|---|---|
x-ratelimit-limit | Requests permitted in the window. |
x-ratelimit-remaining | Requests left in the current window. |
x-ratelimit-reset | Seconds until the window resets. |
retry-after | Seconds to wait. Sent on a 429. |
The default budget is 600 requests per 60-second window. Individual keys can be issued with their own limit, so read x-ratelimit-limit rather than assuming the default.
Buckets are keyed by API key only once the key is valid. Requests with a wrong or absent key bucket by client IP instead. That means one legitimate client cannot consume another's budget, and an attacker rotating the key header cannot mint a fresh bucket per request.
Failed authentication consumes budget. 401s are counted and throttled like any other request. A client looping on a bad key will start receiving 429s, from the shared IP bucket, and can throttle its own legitimate traffic from the same address in the process.
GET /health is exempt — a throttled liveness probe reads as an outage to an orchestrator, under exactly the load where that is worst.
Rate limiting bounds how fast connections open, not how many stay open. Concurrent /stream/route sockets are capped separately, at 50 per key by default and overridable per key.
GET /stream/routeA WebSocket carrying the same route, recomputed and pushed whenever any market it depends on moves. Identical query parameters, identical response body — switching from polling to streaming changes the URL and nothing else. Both endpoints share one query parser, so they cannot disagree about what is valid.
websocat "wss://docs.ccxt.com/router/api/stream/route?from=USDT&to=BTC&amountOut=1" \
-H "x-api-key: $KEY"
Bridged routes watch every leg of every candidate path, so a bridged quote does not miss half the price changes that alter its answer. The watch set is re-derived rather than captured at connect: a market listed after you connected can become the winning route, and a frozen subscription would quote a market it was not subscribed to.
Both differ for one reason — volume. Unbounded, a single socket measured 658 frames/sec at 9.3KB per frame, i.e. 6.3 MB/s from one connection, against a per-key limit of 50 sockets.
| Behaviour | GET /route | GET /stream/route |
|---|---|---|
includeQuotes |
true |
false |
| Push interval | n/a | floored at one per 100 ms |
The quotes[] array is roughly 90% of a response's bytes, and it is a "why these venues?" explanation rather than an input to any decision. A once-per-request explanation is cheap; the same explanation ten times a second is not. Pass includeQuotes=true to get it anyway — an explicit value always wins over the default.
The 100 ms floor is leading-edge and trailing-edge: the first move after a quiet period is pushed immediately, and the newest state always lands rather than being dropped for arriving too soon. Note that within a tick, updates are coalesced into a single computed result — a fast-moving symbol emits hundreds of book updates per second, and you get the latest state, not a queue of them.
Each frame is its own recommendation and carries a fresh requestId.
| Code | Reason | Meaning |
|---|---|---|
1008 | invalid request | Invalid parameters. An error frame with the same message the REST endpoint would have returned as a 400 is sent first, then the socket closes. |
1008 | no_market | No market or bridge path exists between the two assets. Nothing could ever push, so the socket is closed rather than held open as a silent hang. |
1008 | exact_out_multi_hop_unsupported | Exact-out over a bridge, refused for the same reason REST answers 501. Re-ask with amountIn. |
1008 | key revoked | A stream authenticates once, at upgrade. Revoking the key closes its live sockets rather than letting the feed run on. |
1013 | connection limit reached | Too many concurrent streams for this key. The error frame names the limit. |
Every one of these sends a JSON error frame before closing, so the reason is readable from the payload as well as the close code.
The server pings periodically and terminates a socket that misses two heartbeats — a half-open TCP connection or a suspended client would otherwise hold its listeners and its slot against the connection cap forever.
Frames are dropped, not queued, when the client is not draining fast enough. The ws transport buffers rather than blocking, so an undrained socket would otherwise grow the send buffer without limit. A slow consumer therefore sees gaps, not a growing backlog of stale quotes — which is the correct behaviour for a price feed, but means you must not treat the stream as a complete sequence of every state the route passed through.
GET /orderbook/{exchange}/{symbol}The cached L2 book for one venue — what the router itself walked. The symbol must be URL-encoded, since unified symbols contain a slash.
curl -s "https://docs.ccxt.com/router/api/orderbook/binance/BTC%2FUSDT" \
-H "x-api-key: $KEY"
{
"exchangeId": "binance",
"symbol": "BTC/USDT",
"bids": [ { "price": 104779.9, "amount": 1.842 }, … ],
"asks": [ { "price": 104780.1, "amount": 0.973 }, … ],
"exchangeTimestamp": 1755950412115,
"receivedAt": 1755950412184,
"sequence": 88213
}
Use receivedAt as the freshness measure, not exchangeTimestamp. The venue-supplied timestamp is absent on some exchanges and subject to clock drift; receivedAt is local arrival time and is what the staleness cutoff is applied against.
404 with { "error": "no cached order book for …" } when that exchange/symbol pair is not cached.
GET /symbolsThe symbols currently cached — i.e. what is routable right now.
{ "symbols": ["BTC/USDT", "ETH/USDT", …] }
GET /exchanges/statusPer-venue WebSocket connection health.
{
"exchanges": [
{ "exchangeId": "binance", "connected": true, "lastUpdateAt": 1755950412184,
"updateCount": 918233, "reconnectCount": 2, "lastError": null },
…
]
}
| Field | Type | Meaning |
|---|---|---|
exchangeId | string | The venue. |
connected | boolean | Socket state. |
lastUpdateAt | integer | null | Epoch ms of the last book update received. |
updateCount | integer | Updates received since start. |
reconnectCount | integer | Reconnections since start. |
lastError | string | null | Most recent connector error. |
connected: true is not proof of a live feed. A venue can hold an open socket while its subscription is silently dead, so connected stays true while the data rots. Judge a venue on lastUpdateAt — how long ago it last delivered — not on connection state.
This endpoint requires a key like every other non-health route: the venue list and traffic volume are exactly the reconnaissance an attacker wants.
GET /healthThe only unauthenticated route, so orchestrator probes work before credentials are injectable. It exposes nothing but status and uptime, and is exempt from rate limiting.
curl -s https://docs.ccxt.com/router/api/health
{ "status": "ok", "uptimeSec": 1183.4 }