CCXT/Router beta

Quickstart

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.

Authentication

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 /route

Returns 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.

Direction is derived, not supplied

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.

Exactly one of amountIn / amountOut

  • amountIn — 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.

Parameters

ParameterTypeDefaultNotes
fromstring— (required)The asset you are spending, e.g. USDT. Case-insensitive.
tostring— (required)The asset you want to acquire. Must differ from from.
amountInnumber > 0Exact amount of from to spend. Supply this or amountOut, never both.
amountOutnumber > 0Exact amount of to to acquire. Supply this or amountIn, never both.
strategybest_single | split_optimal | split_cappedbest_singleSee Strategies. Anything else is a 400.
maxVenuesinteger ≥ 13Per-hop venue cap. Only meaningful with split_capped.
bridgescomma-separatedUSDT,USDC,BTC,ETHIntermediary assets to consider. An explicitly empty value (bridges=) disables bridging entirely — direct markets only.
hopPenaltyBpsnumber 0–100005How much better a bridged route must be, per extra hop, before it beats a direct market. 0 compares purely on rate.
exchangescomma-separatedunrestrictedVenue allowlist, e.g. binance,kraken,bybit. An explicitly empty value (exchanges=) means no venues, not all venues — and yields an empty route.
certifiedtrue | falsefalseConsider only exchanges CCXT marks as certified (22 of 80 pro classes at time of writing). Composes with exchanges as an intersection.
includeFeestrue | falsetrueSet false to rank on raw price only — useful with negotiated tiers or maker rebates. Then feeCost is 0 and effectivePrice equals averagePrice.
includeQuotestrue | falsetrue here, false on /stream/routeReturn 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.
minLegNotionalnumber ≥ 00Suppress 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.
requireFullFilltrue | falsefalseRefuse 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)stringmintedCaller-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.

Response — top level

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.

FieldTypeMeaning
requestIdstringAudit id for this recommendation. Logged server-side alongside the full decision.
calculatedAtintegerEpoch ms at which the route was computed. Subtract from your receive time to measure network delay.
calculatedAtIsostringThe same instant, ISO 8601.
from / tostringThe assets, normalised to upper case.
amountInnumberAmount of from the route actually spends. 0 when unroutable.
amountOutnumberAmount of to the route actually produces. 0 when unroutable.
requestedAmountnumberWhat was asked for, in the units of exactSide.
exactSidein | outWhich side the caller pinned. The other side is the computed result.
effectiveRatenumber | nullamountOut / 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.
referenceRatenumber | nullThe 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.
impactBpsnumber | nullHow far effectiveRate falls short of referenceRate, in basis points — the end-to-end cost of size. Positive is worse.
hopsarrayThe 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.
fullyFillablebooleanTrue only when every hop filled completely.
fillRationumberachieved / requested on the pinned side. Check this before using effectiveRate.
unfilledAmountnumberThe remainder of the pinned side that could not be routed.
unroutableReasonenum | nullWhy hops is empty; null when routing succeeded. See the table below.
unroutableHopIndexinteger | nullWhich hop failed, for a bridged route. Null when the whole request was unroutable, or when routing succeeded.
warningsstring[]Human-readable cautions, prefixed partial_fill: or multi_hop:. Empty on a clean single-hop full fill.
savingVsBestSingleBpsnumber | nullHow 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.
pathsConsideredarrayEvery candidate market path evaluated, winner flagged. Empty when only one path existed — there was nothing to choose between.
strategyenumEchoed.
includeFeesbooleanEchoed.
exchangesFilterstring[] | nullThe venue allowlist actually applied, sorted. Null when unrestricted.
certifiedOnlybooleanEchoed.
staleBookMsnumberThe freshness cutoff applied, echoed so bookAgeMs on each quote is interpretable without knowing server config.
staleBookMsnumberThe freshness cutoff the router applied. Books older than this were excluded. Not a request parameter — see below.
stalenessPenaltyBpsnumberThe staleness markdown applied, in bps per √second of book age. Not a request parameter.
hopPenaltyBpsnumberEchoed.
requireFullFillbooleanEchoed, 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.

Response — 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.

FieldTypeMeaning
pairstringThe market this hop trades, e.g. BTC/USDT.
sidebuy | sellDerived from the asset direction, not supplied by the caller.
base / quotestringThe market's two assets.
amountIn / amountOutnumberConsumed and produced by this hop, in this hop's own input/output assets.
legsarrayThe venues to trade on for this hop, largest leg first. Length 1 for best_single.
feeCostnumberTotal taker fee for this hop, denominated in feeCurrency.
feeCurrencystringThe hop's quote currency. Reported per hop precisely because a route's hops can charge in different currencies.
fullyFillablebooleanFalse means cached depth could not cover this hop's requested size.
referencePricenumber | nullThe 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.
impactBpsnumber | nullHow much worse this hop's size executes than referencePrice, in bps. Positive is always worse, on both sides.
venueCountintegerVenues considered for this hop after the exchanges / certified filters, fresh or not, and regardless of whether quotes[] was returned.
freshVenueCountintegerHow 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.
quotesarrayEvery venue considered for this hop, including stale and unfillable ones. Present only when includeQuotes is on.

hops[].legs[]

FieldTypeMeaning
exchangeIdstringVenue to trade on.
amountnumberSize to fill on this venue.
averagePricenumberRaw VWAP for this leg, before fees.
takerFeeRatenumbere.g. 0.001.
feeCostnumberWhat the fee costs on this leg, in quote currency.
effectivePricenumberFee-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.

FieldTypeMeaning
exchangeIdstringThe venue.
sidebuy | sellThe derived side for this hop.
requestedAmountnumberThe 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.
filledAmountnumberHow much this venue's cached book could fill, always in base units.
averagePricenumber | nullRaw VWAP across the levels walked, before fees and before any staleness penalty. Null if nothing could fill. Do not rank on it.
effectivePriceWithFeenumber | nullaveragePrice adjusted for the venue's taker fee, and for the staleness penalty when one is set. Rank on this.
takerFeeRatenumberThe venue's taker fee.
fullyFillablebooleanFalse means the requested size exceeded this venue's cached depth.
bookAgeMsintegerAge 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.

FieldTypeMeaning
pairsstring[]The markets this path would trade, in order.
bridgestring | nullThe intermediary asset, or null for a direct market.
amountOutnumberRaw output this path would produce.
fullyFillablebooleanWhether this path could cover the size.
scorenumberamountOut 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.
chosenbooleanThe 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

ValueStatusMeaning
no_market404No direct market and no bridge path exists between the two assets at all.
exact_out_multi_hop_unsupported501The 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_filter200The exchanges / certified filters excluded everything.
all_books_stale200Venues exist but every book exceeded the freshness cutoff.
no_liquidity200Fresh books exist but none had depth to fill any of the size.
insufficient_depth200requireFullFill was set and depth could not cover the size.
null200Routing succeeded.

Price impact

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.

Strategies

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.

StrategyLegs per hopWhat 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.

Reading the response correctly

Three responses are well-formed, return 200, and will mislead you if read at a glance. Handle all three before trading on a quote.

1. Check fillRatio before you trust effectiveRate

On 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.

2. An empty hops with an unroutableReason is a refusal to quote, not an error

It 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.

3. hops.length > 1 means two orders, not one

When 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:

  • Fees are per hop, in that hop's own currency. 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.
  • Hops are solved sequentially, not jointly. Hop 1's split is chosen without knowing what hop 2 would prefer. The result is a good route, not a provably optimal one. Joint optimisation is a min-cost flow over the asset graph and is deliberately out of scope.

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 codes

StatusBodyWhen
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.

Why 404 and an empty-hops 200 are different things

They answer two different questions, and conflating them would make one of them unactionable.

  • 404 is a problem with your request. The assets are not connected by any market or any bridge — a wrong ticker, an unlisted asset, a symbol from a venue this router does not cover. Retrying the identical request will never succeed. Fix the input.
  • An empty-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.

Rate limits

Every response carries the current budget:

HeaderMeaning
x-ratelimit-limitRequests permitted in the window.
x-ratelimit-remainingRequests left in the current window.
x-ratelimit-resetSeconds until the window resets.
retry-afterSeconds 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/route

A 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.

Two defaults differ from REST

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.

BehaviourGET /routeGET /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.

Close codes

CodeReasonMeaning
1008invalid requestInvalid parameters. An error frame with the same message the REST endpoint would have returned as a 400 is sent first, then the socket closes.
1008no_marketNo 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.
1008exact_out_multi_hop_unsupportedExact-out over a bridge, refused for the same reason REST answers 501. Re-ask with amountIn.
1008key revokedA stream authenticates once, at upgrade. Revoking the key closes its live sockets rather than letting the feed run on.
1013connection limit reachedToo 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.

Liveness and backpressure

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.

Other endpoints

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 /symbols

The symbols currently cached — i.e. what is routable right now.

{ "symbols": ["BTC/USDT", "ETH/USDT", …] }

GET /exchanges/status

Per-venue WebSocket connection health.

{
  "exchanges": [
    { "exchangeId": "binance", "connected": true, "lastUpdateAt": 1755950412184,
      "updateCount": 918233, "reconnectCount": 2, "lastError": null },
    …
  ]
}
FieldTypeMeaning
exchangeIdstringThe venue.
connectedbooleanSocket state.
lastUpdateAtinteger | nullEpoch ms of the last book update received.
updateCountintegerUpdates received since start.
reconnectCountintegerReconnections since start.
lastErrorstring | nullMost 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 /health

The 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 }