Skip to main content
binXbase
Developers

API reference

Twelve routes, documented from the handlers that serve them. Six are public, four need a signed-in session cookie, two work either way, and none of them takes an API key — read Authentication before you plan around that.

On this page

Basics

This is the API the site itself runs on. The markets table, both terminals, the search palette and the account surfaces are all clients of the routes below — which is the reason to trust them and also the reason they exist. Nothing here was built as a product for third parties.

Use On this page to jump between groups, or open an endpoint below for its exact parameters, response shape, and runnable samples. The overview keeps the one-screen endpoint index for a faster first scan.

Base URL and shape

There is no separate API host and no version prefix. The routes are served by the same Next.js application as the rest of the site, under /api — whichever origin serves you the site serves the API too:

Base URL
https://your-deployment/api

The samples on this page follow that. curl commands write https://your-deployment — substitute the origin you are on — and the browser samples use relative URLs, which the CORS note below makes the only kind that can work there anyway.

Responses are JSON on eight of the twelve routes. The three streaming routes answer text/event-stream, and /api/ai/analyze answers text/plain because it streams prose rather than objects.

Every route documented here declares runtime = 'nodejs' and dynamic = 'force-dynamic', so nothing is cached by the framework and no response is ever a static prerender. Where caching exists it is an in-process memo inside the handler with a stated TTL, and it is documented on the endpoint that has one.

No CORS headers are set

Every response header this app adds is a cache directive or a Retry-After, and each is documented on the route that sets it — the only ones set outside a handler are the stream cache directives, which the app’s framework config applies. No Access-Control-Allow-Origin is emitted anywhere, so a browser on another origin cannot read any of these responses. Server-side callers, curl and anything running on the same origin are unaffected.

No version prefix, and no rate limit

Both absences are real rather than undocumented. There is no /v1 because there has never been a second shape to distinguish from the first, and no request counting, throttling or quota exists in any handler or in front of one. Plan for these routes to change without notice, and do not point anything at them that would suffer if they did.

Authentication

There are no API keys. Nothing in this codebase issues, stores, hashes or verifies one, and no handler reads an Authorization header. The account routes authenticate with the same session cookie the website uses — the one NextAuth sets when you sign in at /signin — and there is no token endpoint to trade credentials for one programmatically.

What that makes this: a browsable read API. Eight of the twelve routes will answer curl from anywhere. The other four are practical only from a browser tab that is already signed in, or from a request carrying a cookie you copied out of one. None of them writes anything.

ModeTypeNotes
Public6 routesNo credential of any kind. All three market-data routes, both market streams, and the AI analysis endpoint.
Session4 routesReads the session cookie and answers 401 without it. Balances, positions, activity and the account stream.
Optional2 routesReads the cookie if it is there and answers without it. /api/account/orders returns an empty list signed out; /api/terminal/context returns its market half with account: null.

There is no order placement endpoint

Orders are placed by Next.js server actions from the trading terminal, not over HTTP with a body you can construct. So this API cannot be used to trade, and no combination of the routes below adds up to a trading client. That is a description of the code as it stands, not a policy.

What would have to exist for keys to be real

Named concretely, because “coming soon” on an authentication surface is the least useful sentence a developer can be handed. Five things, and none of them is written:

Missing pieceTypeNotes
IssuanceUI + tableA place to create, name, scope and revoke a key, with the secret shown once and only its hash stored. No such table exists in the database schema.
A signing schemeprotocolA key in a query string is a key in every access log. Signed requests — HMAC over method, path, body, nonce and timestamp — plus a replay window on the server side.
ScopesauthzRead and trade must be different grants, or a leaked read key spends money. Nothing in the code distinguishes them today because nothing needs to.
Rate limitinginfraSession traffic is bounded by how fast a person can click. Key traffic is not, and there is no counter, bucket or quota anywhere in this repo.
Write endpointsroutesPlace, amend and cancel, with idempotency keys, so a retried request cannot double-fill. The engine enforces the rules; the HTTP surface for them was never built.

Conventions

Five conventions hold across the API, so they are stated once here rather than repeated on every endpoint.

RuleTypeNotes
symbolstringVenue-native and unpunctuated: BTCUSDT. Every route that names a market runs it through fromSlug, which strips non-alphanumerics and uppercases — so btc-usdt and btc/usdt work identically. The one exception is the symbol filter on /api/account/activity, which only trims and uppercases; give that one BTCUSDT.
kind'spot' | 'futures'Selects the market. Matched against a closed set and never cast: anything that is not exactly futures — including a typo, an empty string or an omitted parameter — is spot. The value reaches a Redis channel name, which is why it is validated rather than trusted.
atnumberMilliseconds since epoch. On market data it is the venue’s own clock, not this server’s.
candle.timenumberSeconds, not milliseconds — the one exception, and the convention charting libraries require. Multiply by 1000 before handing it to Date.
Numbersnumber | stringMarket data is JSON numbers: it is read, not owed, and float precision at that scale is invisible. Anything from the ledger — a quantity, a price, a fee, a balance — is a decimal string, because binary floats cannot hold a money value exactly. Do not parse one into a Number and then do arithmetic on it.

There is no pagination

No endpoint returns a cursor, a page token or a total. Where a response could grow without bound it takes a limit that is clamped rather than rejected, and the clamp is documented on the endpoint. That is the whole windowing story.

Errors and status codes

An error is a JSON object with an error key holding a sentence meant for a human. /api/ai/analyze is the one that differs: it sends error as a machine code and puts the sentence in message.

The usual error body
{ "error": "unsupported interval: 3h" }
StatusWhen
200Success — and, on two routes, a deliberate non-error empty. See below.
400A parameter is missing or outside its allowed set. The message names the offending value.
401No session cookie on a route that needs one. { "error": "Sign in required" } — except /api/stream/account, which answers the same sentence as plain text because an SSE route has no JSON envelope to put it in.
404The symbol does not exist on that venue, or has no live data.
502The upstream venue failed. Only /api/klines can return this.
503A dependency is down: ingest (/api/health), the exchange database (every account route, with Retry-After: 5), or a missing Anthropic key.

Empty is not unknown

The rule that shapes every one of those choices. A response that says “nothing here” and a response that says “I could not find out” are different claims, and collapsing them is how a UI ends up telling a margin trader they are flat during a database outage.

So the account routes answer 503 rather than an empty 200 when the database is unreachable, and their clients keep the last good snapshot instead of overwriting a funded account with zeros. The same reasoning runs the other way twice, and both are worth knowing before you write a client:

RouteTypeNotes
/api/markets/index200Never errors. On upstream failure it serves the last memo, or an empty list — the search palette degrades quietly rather than throwing in the user’s face.
/api/account/orders200Signed out returns an empty list, not a 401. Its only caller polls opportunistically and a signed-out tab is a normal state, not an error worth logging.

Market data

Public, uncredentialed, and real. Two of these return market figures that originate at a live venue feed — Binance spot and USDT-M futures — reaching you through the ingest worker’s cache, or straight from the venue’s REST API when that cache is cold. The third tells you which of those two paths you are on.

GETPublic

/api/health

Liveness for the whole data path, not just the web process. “The server answered” is a useless health check for a market data site — it stays green while every price on the page is an hour stale. This reports whether the ingest worker is writing its heartbeat, which is the capability that actually matters.

Takes no parameters. Answers 503 when ingest is down, so a monitor can act on it without parsing the body.

Request

Shell
curl -i 'https://your-deployment/api/health'

Response

FieldTypeNotes
status'ok' | 'degraded'Mirrors ingest.live. Degraded means slower, not down — reads fall back to the venue.
ingest.livebooleanTrue when marketd wrote a heartbeat inside its 30-second expiry window.
ingest.streamednumberSymbols the worker is currently streaming tickers for, across every pipeline.
ingest.watchingnumberSymbols with at least one terminal open on them — the detail sockets the worker holds because someone asked for them.
ingest.atnumber | nullWhen the heartbeat was written, ms. Null when there is no heartbeat at all.
fallbackstring | nullNames what is carrying the site while ingest is down. Null when it is up.
atnumberThis server’s clock when the check ran, ms.
200 — worker writing
{
  "status": "ok",
  "ingest": { "live": true, "streamed": 412, "watching": 3, "at": 1754003123456 },
  "fallback": null,
  "at": 1754003123999
}
503 — worker down, venue fallback carrying the site
{
  "status": "degraded",
  "ingest": { "live": false, "streamed": 0, "watching": 0, "at": null },
  "fallback": "serving market data directly from the venue REST API",
  "at": 1754003123999
}

Status codes

StatusWhen
200A heartbeat is present and inside its window.
503No heartbeat. The body still parses and still carries counts — all zeroes.

Watch it in the product

The system status page is this endpoint’s only first-party client, rendering the same numbers as they arrive.

GETPublic

/api/markets/index

The search palette’s catalogue: six fields per pair instead of a full ticker’s eleven. It is fetched the first time anyone opens search on any page, which is why it is trimmed.

Two filters do real work here. Pairs whose quote asset cannot be identified are dropped — an unrankable match for a search is worse than no match — and only the six quotes people actually search in are kept, because the venue orders by volume denominated in each pair’s own quote, so rupiah pairs would otherwise outrank everything by a factor of thousands.

Parameters

ParameterTypeDefaultNotes
kind'spot' | 'futures''spot'Anything other than the exact string futures is treated as spot.

Request

Shell
curl 'https://your-deployment/api/markets/index?kind=spot'

Response

FieldTypeNotes
kind'spot' | 'futures'Echoes the resolved kind, so a client can tell what it got.
markets[]IndexedMarket[]At most 800 rows, in descending 24h quote volume. Quote asset is one of USDT, USDC, FDUSD, BTC, ETH or BNB.
markets[].symbolstringVenue-native, e.g. BTCUSDT.
markets[].basestringResolved by splitSymbol, never guessed.
markets[].quotestringOne of the six searchable quotes.
markets[].lastnumberLast traded price.
markets[].changePctnumberPercent already multiplied out: 1.84 means +1.84%.
markets[].quoteVolumenumber24h volume in the quote asset. Sent so a client can break ranking ties by depth rather than by catalogue position.
200 — truncated
{
  "kind": "spot",
  "markets": [
    {
      "symbol": "BTCUSDT",
      "base": "BTC",
      "quote": "USDT",
      "last": 64280.1,
      "changePct": 1.84,
      "quoteVolume": 1893456120.5
    },
    { "symbol": "ETHUSDT", "base": "ETH", "quote": "USDT", "last": 3142.55, "changePct": -0.62, "quoteVolume": 964203118.2 }
  ]
}

Caching

An in-process memo per kind, 30 seconds. It is a memo rather than a framework cache because Next’s revalidation does not vary by query string — without it, the first futures request would permanently claim the spot entry.

Status codes

StatusWhen
200Always. There is no error path.

This route cannot fail

On an upstream error it returns 200 with the stale memo, or with markets: [] if there is no memo yet. A client that treats an empty list as “there are no markets” will be wrong during an outage — check /api/health if you need to distinguish the two.

GETPublic

/api/klines

Candles for one pair. Deliberately thin — validate, fetch, return — because the chart hits it on every interval change.

Historical rows come from the venue-backed candle cache, then the current audited pair adjustment is applied before the response is returned. The response exposes the signed basis-point value so a client never has to guess whether prices are raw.

Parameters

ParameterTypeDefaultNotes
symbolstringrequiredBTCUSDT, or any punctuated form of it. Missing or empty → 400.
intervalCandleInterval'1h'One of 1m 5m 15m 30m 1h 4h 1d 1w. Anything else → 400 naming the value.
limitnumber500Clamped into 10–1000 rather than rejected: a limit of 50,000 is a caller mistake, and the useful answer is the largest page that will actually be served.
kind'spot' | 'futures''spot'Selects the venue.

Request

Shell
curl 'https://your-deployment/api/klines?symbol=BTCUSDT&interval=1h&limit=2'
JavaScript
const params = new URLSearchParams({
  symbol: 'BTCUSDT',
  interval: '1h',
  limit: '500',
})

const response = await fetch(`/api/klines?${params}`)
if (!response.ok) throw new Error((await response.json()).error)

const { candles, priceAdjustmentBps } = await response.json()
// 500 means +5%; -1000 means -10%; 0 means raw venue prices.
// candles[0].time is SECONDS — multiply before constructing a Date
const opened = new Date(candles[0].time * 1000)

Response

FieldTypeNotes
symbolstringThe normalised symbol that was queried.
intervalCandleIntervalEchoed back, already validated.
priceAdjustmentBpsnumberSigned basis points applied to every OHLC value: 500 is +5%, -1000 is -10%, and 0 is unadjusted.
candles[]Candle[]Oldest first. See Candle — note that time is in seconds.
200
{
  "symbol": "BTCUSDT",
  "interval": "1h",
  "priceAdjustmentBps": 500,
  "candles": [
    { "time": 1753996800, "open": 67315.71, "high": 67572.75, "low": 67284.105, "close": 67516.575, "volume": 312.847, "priceAdjustmentBps": 500 },
    { "time": 1754000400, "open": 67516.575, "high": 67641.0, "low": 67389.315, "close": 67494.105, "volume": 208.114, "priceAdjustmentBps": 500 }
  ]
}

Status codes

StatusWhen
200Candles returned. An empty array is a legal answer for a symbol with no history at that interval.
400symbol is required, or unsupported interval: 3h.
502The venue rejected or failed the request. The body carries the upstream message when there is one.

Streams

Three server-sent event endpoints. They are the most interesting part of this API and the part most likely to be integrated against incorrectly, so the mechanics get their own section before the routes.

How the streams behave

Server-sent events rather than websockets, deliberately. The data travels one way, the browser reconnects by itself, it survives proxies that mangle websocket upgrades, and it needs no protocol of its own on top of HTTP.

Response headers

Every stream route
content-type: text/event-stream; charset=utf-8
cache-control: no-cache, no-store, no-transform
connection: keep-alive
x-accel-buffering: no

no-transform is the load-bearing one. Nothing between the route and the browser may compress or buffer these bodies. A compressor assumes a body that ends; these do not, so its buffer grows without bound until the process is killed. If you put a proxy in front of this app, exclude /api/stream/* from compression — the README carries a Caddy block that does exactly that.

The wire format

Anatomy of a frame
  • retry: 3000Sent once, before anything else. Tells the browser to wait 3s before reconnecting, so a server restart is not met by every open tab at once.
  • A blank line ends a frame. Without it the client buffers forever.
  • event: snapshotThe listener name. Every frame here is named, so `onmessage` never fires — use addEventListener.
  • data: {"kind":"spot","at":1754003123456,"tickers":[…]}One line of JSON. Parse it yourself; EventSource hands you the string.
  • And again — one blank line per frame, always.
  • : pingA comment, not an event. Sent every 15s so an idle connection is not closed by a proxy. Clients ignore it for free.

The trap worth repeating: every frame is named, so EventSource.onmessage never fires on any of these streams. Use addEventListener with the event names documented on each route.

A connection, start to finish

Both market streams open with a snapshot before forwarding anything live, so a client that connects between two publishes renders complete instead of staring at an empty table. That is why reconnection needs no replay: the new connection starts with a fresh snapshot, and there are no sequence gaps to reconcile. The account stream has nothing to snapshot — it carries pokes, not state — so it sends ready instead.

The retry: 3000 hint sets the browser’s reconnect delay so a server restart is not met by every open tab at the same instant. A route whose backend has failed can raise it — /api/stream/account raises it to 15 seconds before closing.

When Redis is cold

Redis is a cache here, never a dependency. If a stream route cannot subscribe, it does not fail — it falls back to re-reading the venue on a timer and keeps emitting the same event names, so a client needs no branch for it. The cadence changes and nothing else does.

RouteTypeNotes
/api/stream/tickersevery 4sRe-reads every ticker and emits it as a tickers event. Slower and heavier than the subscription, and the markets page keeps moving.
/api/stream/symbolevery 3sRe-reads the book and the tape. Visibly coarser than the 100ms live book — and a terminal that refreshes every three seconds still works, while one that never refreshes does not.
/api/stream/accountno fallbackDeliberately different. It announces failure and closes, because a stream that stays open while silently delivering nothing looks exactly like an account where nothing is happening.

A client that handles all of it

EventSource
const stream = new EventSource('/api/stream/symbol/BTCUSDT?kind=spot')

// Named events only — onmessage will never fire on these streams.
stream.addEventListener('snapshot', (event) => {
  const { ticker, book, trades } = JSON.parse(event.data)
  render(ticker, book, trades)
})

stream.addEventListener('book', (event) => {
  const book = JSON.parse(event.data)
  // 20 levels a side. bids descend from the best bid, asks ascend from the best ask.
  drawDepth(book.bids, book.asks)
})

stream.addEventListener('trade', (event) => {
  const trade = JSON.parse(event.data)
  // side is the AGGRESSOR: 'buy' means a taker lifted the ask.
  appendToTape(trade)
})

stream.addEventListener('candle', (event) => {
  const { candle } = JSON.parse(event.data)
  updateLiveBar(candle) // candle.time is seconds
})

// 'error' is BOTH a frame this route can send AND EventSource's own transport
// event, so one listener receives both. Only the in-band frame carries data —
// branch on that rather than parsing blind.
stream.addEventListener('error', (event) => {
  const payload = event.data
  if (typeof payload === 'string') {
    // The route said so: the seed snapshot failed. The connection is still
    // open and live frames may still arrive.
    console.warn(JSON.parse(payload).message)
  } else {
    // The transport dropped. EventSource reconnects by itself after the retry
    // interval, so this is a place to show a badge, not to reconnect.
    markFeedDegraded()
  }
})

// Nothing closes the venue subscription for you — call this when you unmount.
// The worker stops streaming the symbol ~90s after the last watcher leaves.
window.addEventListener('beforeunload', () => stream.close())

“error” is two events wearing one name

error is a frame these routes send and the name EventSource uses for its own transport failure, so a single listener receives both and there is no way to register for only one. Tell them apart by event.data: the in-band frame carries a JSON string with a message, the transport event carries nothing. Parsing blind throws on every ordinary reconnect.

GETSSEPublic

/api/stream/tickers

Batched 24h tickers for every actively-trading pair — what the markets table and every price in the site chrome run on. One upstream venue connection feeds every connected browser.

Batched rather than per-symbol on purpose: the markets table wants all of them at once, and a pattern-subscribe across hundreds of per-symbol channels costs more than it saves.

Parameters

ParameterTypeDefaultNotes
kind'spot' | 'futures''spot'Selects which venue’s ticker channel is forwarded.

Events

EventTypeNotes
snapshotTickerBatchOnce, immediately on connect. Every tradable ticker, so a client renders before the first batch arrives.
tickersTickerBatchA batch of tickers that changed. Identical shape to the snapshot — a client can use one handler for both.
error{ message: string }Sent only when the initial snapshot read threw. The stream stays open and live batches may still follow.

TickerBatch is { kind, at, priceAdjustmentPolicy, tickers: Ticker[] }. The policy token identifies the exact adjustment generation used for the complete batch — see Ticker.

Request

Shell
# -N disables curl's own buffering. Without it you will see nothing.
curl -N 'https://your-deployment/api/stream/tickers?kind=spot'
Raw frames — truncated
retry: 3000

event: snapshot
data: {"kind":"spot","at":1754003123456,"priceAdjustmentPolicy":"sha256:…","tickers":[{"symbol":"BTCUSDT","last":67494.105,"open":66276,"high":68135.025,"low":66129.21,"changePct":1.84,"quoteVolume":1988128926.525,"baseVolume":29412.88,"at":1754003123001,"priceAdjustmentBps":500}]}

event: tickers
data: {"kind":"spot","at":1754003127890,"tickers":[{"symbol":"ETHUSDT","last":3142.55,"open":3162.1,"high":3180.4,"low":3110.9,"changePct":-0.62,"quoteVolume":964203118.2,"baseVolume":306812.4,"at":1754003127801}]}

: ping

Status codes

StatusWhen
200Always, including when the snapshot failed — an SSE route reports failure in-band, because the response head is committed before the first read is attempted.
GETSSEPublic

/api/stream/symbol/{symbol}

One symbol’s live detail: a seed snapshot, then order book, trade prints, ticker updates and the forming 1-minute candle. This is what a trading terminal is connected to.

Opening it has a side effect worth knowing about, described below — it is what tells the ingest worker the symbol is being watched.

Parameters

ParameterTypeDefaultNotes
symbolstringrequired (path)A path segment, not a query parameter. BTCUSDT or btc-usdt.
kind'spot' | 'futures''spot'Query parameter. Selects the venue.

Events

EventTypeNotes
snapshot{ symbol, ticker, book, trades }Once on connect: the 24h ticker, the top 20 levels a side, and the 40 most recent prints. Any of ticker or book may be absent if the seed read failed — see the error event.
tickerTickerThe rolling 24h figures as they change.
bookOrderBookTop 20 levels a side, roughly every 100ms on the live path. bids descend from the best bid; asks ascend from the best ask.
tradePublicTradeOne print. side is the aggressor — buy means a taker lifted the ask, which is inverted from the venue field it is derived from.
candle{ symbol, interval, candle }The forming 1m bar, folded live from the same tape. interval is always 1m here; candle.time is seconds.
error{ message: string }The seed snapshot failed for this symbol. The subscription is still attempted and live frames may follow.

Request

Shell
curl -N 'https://your-deployment/api/stream/symbol/BTCUSDT?kind=spot'
Raw frames — truncated
event: snapshot
data: {"symbol":"BTCUSDT","ticker":{"symbol":"BTCUSDT","last":64301.2,"changePct":1.84},"book":{"symbol":"BTCUSDT","bids":[{"price":64295.1,"size":0.58}],"asks":[{"price":64301.2,"size":0.42}],"sequence":8234511234,"at":1754003123456},"trades":[{"id":"3021837421","symbol":"BTCUSDT","price":64301.2,"size":0.014,"side":"buy","at":1754003123001}]}

event: book
data: {"symbol":"BTCUSDT","bids":[{"price":64295.1,"size":0.58}],"asks":[{"price":64301.2,"size":0.42}],"sequence":8234511250,"at":1754003123556}

event: trade
data: {"id":"3021837440","symbol":"BTCUSDT","price":64302,"size":0.2,"side":"sell","at":1754003123602}

event: candle
data: {"symbol":"BTCUSDT","interval":"1m","candle":{"time":1754003100,"open":64290.5,"high":64305,"low":64288.2,"close":64302,"volume":18.443}}

Side effect: it registers interest

The worker does not stream every symbol’s book and tape — there are over a thousand and nobody is looking at most of them. Opening this stream writes the symbol into a Redis sorted set scored by expiry, and the route renews that entry every 30 seconds for as long as you stay connected. Interest expires 90 seconds after the last renewal, so the worker stops streaming a symbol about a minute and a half after the last terminal closes.

First frames can lag a cold symbol

If nobody was watching the symbol, the worker has to open its venue subscriptions before any book or trade event can exist. The snapshot is unaffected — it is read directly and arrives immediately — which is exactly why the snapshot is sent first.

Status codes

StatusWhen
200Always, including for a symbol that does not exist — that case arrives as an in-band error event rather than a 404.
GETSSESession

/api/stream/account

A private, per-user notification stream. It carries pokes, never balances: the payload says something moved, and roughly why, and the client refetches over REST. A dropped message therefore costs one refresh interval of staleness rather than a wrong number rendered as money.

It exists because the interesting half of an order’s life happens where nobody is looking — a resting limit filled by the worker’s sweep, a funding charge, a liquidation.

Events

EventTypeNotes
ready{ at: number }The subscription is live. Treat this, not the EventSource open event, as “the stream will deliver” — open only means the response head arrived, and it arrives on doomed attempts too.
pokeAccountPoke{ type: "account", reason } where reason is one of order, fill, transfer, position, funding or liquidation. Refetch either way; the reason lets a surface prioritise.
fail{ reason: string }The subscription could not be established, or was lost for good. The stream closes immediately after, having first raised the browser’s retry interval to 15 seconds.

Request

EventSource
// A relative URL, because the session cookie is the credential: EventSource
// sends cookies to its own origin, and no CORS headers exist here anyway.
const stream = new EventSource('/api/stream/account')

// 'ready', not the open event — open only means the response head arrived.
stream.addEventListener('ready', () => stopPolling())

stream.addEventListener('poke', (event) => {
  const { reason } = JSON.parse(event.data)
  refetchAccount(reason) // 'order' | 'fill' | 'transfer' | 'position' | 'funding' | 'liquidation'
})

// The route said it cannot deliver, and is about to close. Poll from here
// until a later connection sends 'ready' again.
stream.addEventListener('fail', (event) => {
  console.warn('account events unavailable:', JSON.parse(event.data).reason)
  startPolling()
})

A closed SSE stream does not look closed

Per the EventSource specification, a server-side close of an established 200 stream leaves readyState at CONNECTING and the browser quietly reconnects. The client cannot see the close as terminal, which is why the failure is announced with a fail frame rather than by the close alone. If you write a client against this route, arm your polling on fail — not on onerror, which fires on every ordinary reconnect too.

Status codes

StatusWhen
200The stream opened. Whether it can deliver is answered by ready or fail.
401No session cookie. A plain text body, not JSON: Sign in required.

Account

These four read the double-entry ledger. Every movement in it is real: spot and futures orders fill by walking the live venue book, margin positions carry funding and liquidation, and every figure below is the result of a posted transfer rather than a display calculation.

All four authenticate with the session cookie and none of them writes. Three answer 401 without a session; /api/account/orders is the deliberate exception described in Errors.

GETSession

/api/account/balances

All three wallets’ balances, straight from the ledger, in the account store’s canonical shape. Nothing else serves balance data to a client, so no second authority can disagree with it.

Takes no parameters.

Request

Shell
# The session cookie is the credential. Copy a jar out of a signed-in browser;
# there is no token endpoint to obtain one programmatically.
curl -b cookies.txt 'https://your-deployment/api/account/balances'

Response

FieldTypeNotes
walletsRecord<WalletKind, Balance[]>Keyed by spot, futures and funding — always all three, even when a wallet is empty. Each wallet is created on first read if it did not exist.
wallets[].assetstringAsset symbol, e.g. USDT.
wallets[].availablestringDecimal string. Spendable right now.
wallets[].lockedstringDecimal string. Reserved by resting orders or posted as margin.
atnumberWhen the snapshot was assembled, ms.
200
{
  "wallets": {
    "spot": [
      { "asset": "USDT", "available": "8420.15000000", "locked": "1200.00000000" },
      { "asset": "BTC", "available": "0.04120000", "locked": "0" }
    ],
    "futures": [{ "asset": "USDT", "available": "2500.00000000", "locked": "480.00000000" }],
    "funding": []
  },
  "at": 1754003123999
}

Status codes

StatusWhen
200Balances returned.
401No session. An empty portfolio and no portfolio are different claims, so this is never an empty 200.
503The exchange database is unreachable. Carries Retry-After: 5. Keep your last good snapshot rather than zeroing.
GETOptional

/api/account/orders

The 40 most recent orders across the spot and futures wallets, newest first, as the smallest projection that lets a client notice a status change. Seven fields, not the whole order — this is polled, and a poll should be cheap.

Takes no parameters. Responds cache-control: no-store.

Response

FieldTypeNotes
orders[].idstringOrder id.
orders[].symbolstringVenue-native symbol.
orders[].side'buy' | 'sell'The side the order was placed on — not an aggressor flag.
orders[].statusOrderStatusuntriggered, new, partially_filled, filled, cancelled, rejected or expired.
orders[].quantitystringDecimal string. Ordered size.
orders[].filledQuantitystringDecimal string.
orders[].averagePricestring | nullVWAP across fills, or null before the first one. Never 0 — a zero renders as a real-looking price a user could act on.
200
{
  "orders": [
    {
      "id": "0b6f2f1e-1c33-4f4a-9b6d-6a2f1f7d2c11",
      "symbol": "BTCUSDT",
      "side": "buy",
      "status": "partially_filled",
      "quantity": "0.02000000",
      "filledQuantity": "0.00800000",
      "averagePrice": "64295.10000000"
    }
  ]
}

Status codes

StatusWhen
200Orders returned — or an empty list when signed out, which is a normal state here rather than an error.
503Database outage. This one matters more than it looks: a client that diffs polls would read an empty list as “every open order vanished” and announce a burst of fills for orders that are still resting.
GETSession

/api/account/positions

Futures positions, in two modes. By default: open positions plus the current mark per held symbol. The mark rides along on purpose — the funding cache it comes from is the same source the liquidation sweep reads, so the liquidation price a user watches and the one that actually fires are computed against the same number.

With ?status=closed: position history instead, folded from each position’s own events.

Parameters

ParameterTypeDefaultNotes
status'open' | 'closed''open'Omitted is treated as open. Any other value is a 400 naming it — unlike kind elsewhere, this one is rejected rather than defaulted, because silently returning open positions to a caller who asked for history is a wrong answer rather than a slow one.
limitnumber50Closed mode only; ignored otherwise. Truncated to an integer and clamped into 1–100.

Response — open

FieldTypeNotes
positions[]FuturesPositionRow[]id, symbol, side ("long" | "short"), quantity, entryPrice, leverage, margin, realizedPnl, openedAt, lastFundingAt. Money fields are decimal strings; leverage is an integer.
marksRecord<string, { price, at } | null>One entry per held symbol. Null when neither the cache nor the venue can price it right now — absence, never a zero. Judge staleness against the top-level at.
atnumberWhen the snapshot was assembled. Mark staleness is measured against this, so rendering never has to read the clock.
200 — status=open
{
  "positions": [
    {
      "id": "8f0a1c22-93ad-4f13-9c0e-2f1b7d55aa10",
      "symbol": "BTCUSDT",
      "side": "long",
      "quantity": "0.05000000",
      "entryPrice": "63980.00000000",
      "leverage": 10,
      "margin": "319.90000000",
      "realizedPnl": "0",
      "openedAt": 1753996800000,
      "lastFundingAt": 1754000400000
    }
  ],
  "marks": { "BTCUSDT": { "price": 64301.2, "at": 1754003120000 } },
  "at": 1754003123999
}

Response — closed

FieldTypeNotes
positions[].quantitystringTotal size closed over the position’s life, summed from its closing events — not the row’s own quantity, which is 0 once closed.
positions[].entryPricestringFinal entry VWAP, re-weighted across every opening fill.
positions[].exitPricestring | nullVWAP across the closing events, or null when none carry a price. Renders as an em dash, never as a zero.
positions[].liquidatedbooleanTrue when a liquidation event ended it.
positions[].realizedPnlstringDecimal string. Also present: side, leverage, symbol, id, openedAt and closedAt.

Status codes

StatusWhen
200Positions returned. An empty array means flat.
400status must be 'open' or 'closed', not 'x'
401No session.
503Database outage. Never an empty list — a panel rendering “no open positions” during an outage would tell a margin trader they are flat when they are not.
GETSession

/api/account/activity

The futures wallet’s ledger feed: margin reserved and released, PnL settlements, funding charges, liquidations and wallet transfers. Every row’s label is built from that transfer’s own ledger legs rather than from a stored description, so it cannot describe a movement that did not happen.

Only kind=futures is served. The spot side of the same feed is rendered server-side on the account page and has no route.

Parameters

ParameterTypeDefaultNotes
kind'futures'requiredMust be exactly futures. Anything else, including omitting it, is a 400 — and that check runs before the session check, so a bad kind answers 400 even signed out.
limitnumber50Truncated to an integer and clamped into 1–100.
symbolstringall symbolsUppercased and trimmed. Filters on the symbol resolved through each transfer’s own order or position — so a wallet transfer, which is about no market, is excluded rather than guessed at.

Response

FieldTypeNotes
activity[].idstringTransfer id.
activity[].labelstringOne human sentence, composed from the transfer’s legs.
activity[].detailstringThe amounts and assets that moved.
activity[].tone'neutral' | 'up' | 'down'Whether the movement was in the account’s favour. Presentational, and never the only signal — the label says it too.
activity[].symbolstring | nullThe market this movement belongs to, resolved from the transfer’s order or position. Null for wallet transfers, which have none.
activity[].atnumberms since epoch.

Status codes

StatusWhen
200Activity returned, newest first.
400kind was not futures.
401No session.
503Database outage, with Retry-After: 5.

Composite and AI

Two routes that do not fit the pattern above. One assembles several reads into a single round trip for a specific interaction; the other streams prose instead of JSON.

GETOptional

/api/terminal/context

Everything an in-place market switch needs that a full route render would otherwise have supplied: the market definition, funding and open interest for perpetuals, and — when signed in — that symbol’s open orders, order history and fills.

Balances are deliberately absent. The account store is the single owner of balance data on the client, and a second copy riding this response would be a second authority that could disagree with it.

Parameters

ParameterTypeDefaultNotes
symbolstringrequiredMissing, empty, or made only of separators → 400. A symbol that does not exist on that venue → 404.
kind'spot' | 'futures''spot'Funding and open interest are perpetuals-only and are null on spot.

Response

FieldTypeNotes
marketMarketThe instrument and the constraints an order against it must satisfy — precisions and minimum notional.
fundingFunding | nullPerpetuals only. Null on spot, and on contracts the venue publishes no funding for.
openInterestOpenInterest | nullPerpetuals only, from a 30-second in-process memo over the venue. Null rather than 0 when unavailable — the header omits the stat instead of rendering a zero.
account{ openOrders, orderHistory, fills } | nullNull when signed out — unknown is not empty. All three lists are filtered to the requested symbol. Open orders are unbounded; history and fills are windowed at 50 before filtering, so they may be shorter.
accountUnavailableboolean | undefinedTrue when the account half was skipped because the database is unreachable. Distinct from account: null, which means signed out — the shell shows an outage notice for one and a sign-in invite for the other.

Request

Shell
curl 'https://your-deployment/api/terminal/context?symbol=BTCUSDT&kind=futures'

It also registers interest

Like the symbol stream, this route tells the worker the symbol is being watched — the same head start the server-rendered terminal gives it, because the SSE connection for that symbol is about to open.

Status codes

StatusWhen
200Context returned. The market half and the account half fail independently: an unreachable ledger still lets a market switch complete.
400symbol is required.
404Unknown spot market XYZUSDT.
POSTPublic

/api/ai/analyze

Market commentary for one spot symbol, streamed as plain text. The model is given the numbers rather than asked to recall them: the prompt carries this venue’s current ticker and its last 120 hourly candles, so the commentary describes the market that exists right now rather than whatever was true at training time.

Needs an ANTHROPIC_API_KEY in the server environment — operator configuration, set by whoever runs the deployment and never sent by callers. It is the only paid credential in this project, and the only route that requires one.

Request body

FieldTypeNotes
symbolstringRequired. Normalised the same way as everywhere else. Always read as a spot symbol at the 1h interval — there is no kind or interval parameter.
Shell
curl -N -X POST 'https://your-deployment/api/ai/analyze' \
  -H 'content-type: application/json' \
  -d '{"symbol":"BTCUSDT"}'
Reading the stream
const response = await fetch('/api/ai/analyze', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ symbol: 'BTCUSDT' }),
})

// Failures BEFORE the stream opens are JSON with a status code.
if (!response.ok) {
  const { error, message } = await response.json()
  throw new Error(`${error}: ${message}`)
}

// Once it opens, the body is markdown text. Render it as it lands.
const reader = response.body.getReader()
const decoder = new TextDecoder()

for (;;) {
  const { value, done } = await reader.read()
  if (done) break
  append(decoder.decode(value, { stream: true }))
}

Response

text/plain; charset=utf-8, streamed. The body is markdown with four fixed headings — What happened, Structure, What to watch and Risks — under 350 words, and it never contains advice, a price target or a recommendation. That is a constraint in the system prompt, not a filter after the fact.

Failures after the stream opens are text, not a status

The response head commits before the model is called. A rate limit, a rejected key or a safety refusal therefore arrives as an italic sentence appended to the body — _Rate limited — try again shortly._ and its siblings — on an otherwise 200 response. If you parse this programmatically, check the tail.

Status codes

StatusWhen
200The stream opened. See the caution above.
400bad_request — the body was not JSON, or carried no symbol.
404no_data — no ticker or no candles for that symbol.
503not_configured — no Anthropic key in the environment. Stated plainly rather than dressed as a server error, because the operator is the one who can fix it.

Types

The shapes the responses above are made of, as they are declared in @basexbit/core. They are venue-agnostic by design — nothing in this vocabulary mentions Binance, so replacing the upstream feed never reaches a client.

Ticker, Candle, OrderBook

The three market-data shapes. All of their numbers are JSON numbers: this is the read path, and the precision loss at display scale is invisible.

priceAdjustmentBps is present when an audited pair adjustment is active. It is signed basis points: 500 is +5% and -1000 is -10%. Price fields and quote-denominated volume carry the adjustment; percent change, base volume, sizes, rates, and timestamps do not.

TypeScript
interface Ticker {
  symbol: string
  last: number
  open: number
  high: number
  low: number
  changePct: number   // already multiplied out: 1.8 means +1.80%
  quoteVolume: number // 24h volume in the quote asset — sort by liquidity with this
  baseVolume: number
  at: number          // venue clock, ms
  priceAdjustmentBps?: number
}

interface Candle {
  time: number        // SECONDS, not ms — the charting convention
  open: number
  high: number
  low: number
  close: number
  volume: number
  priceAdjustmentBps?: number
}

interface BookLevel {
  price: number
  size: number
}

interface OrderBook {
  symbol: string
  bids: BookLevel[]   // descending — best bid first
  asks: BookLevel[]   // ascending — best ask first
  sequence: number    // venue sequence id, so an out-of-order frame can be dropped
  at: number
  priceAdjustmentBps?: number
}

PublicTrade

One print off the tape. The field worth reading twice is side.

TypeScript
interface PublicTrade {
  id: string
  symbol: string
  price: number
  size: number
  side: 'buy' | 'sell' // the AGGRESSOR: 'buy' means a taker lifted the ask
  at: number
  priceAdjustmentBps?: number
}

The aggressor mapping is inverted from the venue's field

Binance sends isBuyerMaker, which is true when the buyer was resting — meaning the aggressor was a seller. The adapter inverts it so side always names the taker. Get this backwards and an entire trade tape renders the wrong colour while looking completely plausible.

Order, Balance

Ledger shapes. Every money field is a string. Postgres stores these as numeric, which round-trips exactly; a binary float does not, and a rounding error in a balance is money that does not exist.

TypeScript
interface Order {
  id: string
  accountId: string
  symbol: string
  kind: 'spot' | 'futures'
  side: 'buy' | 'sell'
  type: 'limit' | 'market' | 'stop_limit'
  timeInForce: 'GTC' | 'IOC' | 'FOK'
  price: string | null          // null for market orders
  triggerPrice: string | null   // stop orders only
  quantity: string
  filledQuantity: string
  averagePrice: string | null   // null before the first fill — never 0
  status:
    | 'untriggered'             // an armed stop, resting OFF-book
    | 'new'
    | 'partially_filled'
    | 'filled'
    | 'cancelled'
    | 'rejected'
    | 'expired'
  leverage: number | null       // futures margin orders only: 1–75, fixed at open
  reduceOnly: boolean
  createdAt: number
  updatedAt: number
}

interface Balance {
  asset: string
  available: string // spendable right now
  locked: string    // reserved by resting orders or posted as margin
}

untriggered is not a synonym for new. An untriggered stop is not working the book, and a client that renders it as “Open” is claiming that it is.

Market, Funding, OpenInterest

The instrument definition and the two perpetuals-only figures. Market is the one to validate against before submitting anything: its precisions and minimum notional are the venue’s real rules, and the matching engine enforces the same ones.

TypeScript
interface Market {
  symbol: string
  base: string
  quote: string
  kind: 'spot' | 'futures'
  status: 'trading' | 'halted'
  pricePrecision: number    // decimal places the venue accepts
  quantityPrecision: number
  minNotional: number       // smallest order value, in the quote currency
}

interface Funding {
  symbol: string
  markPrice: number   // what unrealised PnL and liquidation are measured against
  indexPrice: number  // the spot composite the contract tracks
  rate: number        // a fraction: 0.0001 is +0.01%. Negative means shorts pay longs
  nextAt: number | null // null when the venue publishes no schedule
  at: number
  priceAdjustmentBps?: number
}

interface OpenInterest {
  symbol: string
  openInterest: number // outstanding contracts in BASE-asset units, not quote
  at: number
}

nextAt is nullable for a measured reason: 31 of 855 contracts report 0 for it, including dated futures and suspended pairs. A 0 rendered as a countdown is a date in 1970, so it is omitted instead.

Something here wrong?

Every claim on this page was read out of the route handler that serves it, or the shared type it responds with. If a route behaves differently from what you just read, the page is the thing that is out of date — the source is the contract.