Getting started

Authentication for Apps and AI Agents

A single key opens the three channels. It travels in a header on each of them (X-API-KEY, in REST, in MCP and at the WebSocket handshake); a web page, which cannot keep a secret, opens the stream with a short-lived token issued by your server instead. Its value is the same everywhere, and it is the rights of your plan that apply on each.

#REST API

The key goes in the X-API-KEY header, or in Authorization: Bearer for a client that can only set a bearer token (when both are sent, X-API-KEY wins). Never in the URL: a key in a query string would end up in the access logs of every intermediary along the way. HTTPS only: a request over plain http:// is refused with 403 https_required, never redirected, because a redirect would let the key travel unencrypted on every call. If you ever see that error, rotate the key it carried.

curl
curl -H "X-API-KEY: $BYTNODE_API_KEY" \
  "https://api.bytnode.com/v1/basis?symbol=ETHUSDT"

The comparison runs in constant time, so a wrong key takes exactly as long to be rejected as an almost-right one.

#The route without a key

Only one route answers without authentication: /v1/health, which says whether the service is up. Everything else requires the key. Two routes are never counted against the monthly quota (only the rate limit applies): /v1/health and /v1/limits, the limits of your key. /v1/status (the freshness of the data) and /v1/symbols (the asset catalogue) belong to the price and volume family, open on every plan, and count like any other call.

GET/v1/healthno keyFull reference
All 1 fields of this route (from the contract)
database`connected` when the service reaches its database. A failing check answers 503 `service_unavailable` with `database` in `error.details`.
GET/v1/statusFull reference
All 15 fields of this route (from the contract)
computed_atISO 8601 UTC, milliseconds (2026-09-28T14:00:00.000Z)When the status was measured: the OLDEST of its measurements (each data family is measured on its own, cached up to 5 minutes for market feeds and 30 seconds for the others). Every `age_s` is measured at or after this instant, not at `server_time`.
state`ok`, or `degraded` when at least one feed is stale or failed its check. The envelope's `status` stays `ok` (the request succeeded).
feed_countcountPublic feeds checked.
stale_feed_countcountFeeds older than their threshold.
error_feed_countcountFeeds that could not be checked.
max_age_ssecondsAge of the oldest feed.
feedsOne row per public feed.
feeds.feedPublic feed name. The closed list of this contract version; `routes` gives the routes each one supplies.
feeds.familyData family the feed belongs to, as in `/v1/limits` and the `x-family` of each route: `price_volume`, `derivatives`, `microstructure`, `trades`, `options`, `onchain`, `macro`, `etf`, `tokenomics` or `sentiment`.
feeds.routesThe public routes (contract paths) this feed supplies: watch this feed to know whether they are fresh. A per-timeframe feed (`cvd_1h`) supplies them at that timeframe.
feeds.age_ssecondsAge of the latest row of the feed. For a feed made of several series (one per asset, per macro series, per ETF), the age of its OLDEST series. For the market-hours intraday series (`macro_intraday_<tf>`, not `_continuous`), counted on weekdays only, like their threshold. `null` if the feed is empty or could not be checked.
feeds.expected_interval_ssecondsMaximum legitimate age of the feed.
feeds.threshold_ssecondsAge above which the feed is stale (1.5 × expected_interval_s).
feeds.is_staleWhether the feed is stale: older than `threshold_s`, or (series feed) at least one of its series is. `null` when the feed could not be checked (counted in `error_feed_count`).
feeds.stale_venue_countcountVenues lagging on this feed; `null` when the feed is not split by venue (every non-market feed).
GET/v1/symbolsFull reference
All 5 fields of this route (from the contract)
symbolTrading pair, e.g. `BTCUSDT`.
base_assetBase asset of the pair.
nameHuman-readable name.
quote_assetQuote currency of the pair (`USDT` for BTCUSDT, `USDC` for USDTUSDC): the currency of its prices.
marketsMarkets served for the pair: `spot` and `futures` (USDT-margined perpetual), or `spot` only for the two stablecoin pairs (their `/futures` routes answer `unavailable: not_applicable`).
curl
# The only route that answers without a key.
curl https://api.bytnode.com/v1/health

# /v1/limits requires the key but is never counted against the quota.
curl -H "X-API-KEY: $BYTNODE_API_KEY" https://api.bytnode.com/v1/limits

# /v1/status and /v1/symbols require the key and count as one request
# of the price & volume family, like any other call.
curl -H "X-API-KEY: $BYTNODE_API_KEY" https://api.bytnode.com/v1/status
curl -H "X-API-KEY: $BYTNODE_API_KEY" https://api.bytnode.com/v1/symbols

#WebSocket stream

From a server, the key goes in the X-API-KEY header of the handshake, exactly as in REST. A web page must not hold your key, and a browser cannot set a header on a WebSocket: the page opens the stream with ?token=, a short-lived token your server obtains from POST /v1/ws/token. Either way, the credential is checked at the handshake, before the connection opens.

import asyncio
import json
import os

import websockets  # pip install websockets


async def main() -> None:
    # From a server, the key goes in the X-API-KEY header of the handshake:
    # the connection opens already authenticated. A missing or invalid key gets
    # 401 before it opens (a plan without the stream: 403).
    headers = {"X-API-KEY": os.environ["BYTNODE_API_KEY"]}
    async with websockets.connect("wss://api.bytnode.com/ws", additional_headers=headers) as ws:
        await ws.send(json.dumps({
            "op": "subscribe",
            "channels": ["trades.futures.BTCUSDT"],
        }))
        print(await ws.recv())   # {"op": "subscribed", "channels": [...]}


asyncio.run(main())

POST /v1/ws/token, called by your server with its key, returns a token and a ready-to-open url. A token is valid 60 seconds and opens ONE connection: once used or expired it is refused, so request a new one for each connection and each reconnection. It opens the WebSocket only, never a REST route, and the rights applied are those of the key at the moment of the handshake (a key revoked in between no longer connects). A plan without the stream gets 403 plan_websocket; issuing a token is not counted in the monthly quota.

curl
# On YOUR server (the key stays there): issue a token for the browser.
curl -X POST -H "X-API-KEY: $BYTNODE_API_KEY" https://api.bytnode.com/v1/ws/token
# {"status":"ok", …, "data_type":"ws_token","data":{"token":"wst_…",
#  "expires_at":"…","expires_in_s":60,"url":"wss://api.bytnode.com/ws?token=wst_…"}}

The session opens already authenticated: the first message can be the subscription. A missing credential gets a 401 missing_api_key at the handshake; an unknown or revoked key, or an invalid, expired or already used token, a 401 invalid_api_key; a plan that does not include the stream a 403 plan_websocket; and nothing is established. A 503 at the handshake has nothing to do with the key: the stream service has reached its capacity, and the connection should be retried a little later. The plan’s ceiling on simultaneous connections applies to the whole account: the connection too many is closed with code 4003. The old frame {"op": "auth", "api_key": "…"} is still accepted and answers authenticated, but it is optional.

The ?token= and ?api_key= parameters are only accepted on the WebSocket handshake, never on the REST routes, and the gateway does not log the request line of /ws. ?api_key= still works for existing clients, but a key in a URL ends up in browser history and proxy logs: prefer the header from a server and the token in a page. The details of the protocol are on the WebSocket stream page.

#MCP server

The MCP server expects the same key, in the same X-API-KEY header, set on the HTTP connection. Without it, no tool is exposed: the server refuses by default rather than opening by default.

import asyncio
import os

from fastmcp import Client  # pip install fastmcp
from fastmcp.client.transports import StreamableHttpTransport


async def main() -> None:
    transport = StreamableHttpTransport(
        "https://api.bytnode.com/mcp", headers={"X-API-KEY": os.environ["BYTNODE_API_KEY"]}
    )
    async with Client(transport) as client:
        info = await client.call_tool("get_system_info", {})
        print(info)


asyncio.run(main())

The exact shape of the configuration file depends on the AI agent; the principle does not change: a URL and a header. See MCP server.

#What a refusal says

ChannelResponseCause
REST401, WWW-AuthenticateHeader missing: error.code: missing_api_key. Key unknown or revoked: error.code: invalid_api_key. The message says what to do, for example « API key missing: send it in the X-API-KEY header. Create a free key at https://bytnode.com/sign-up. »
REST403, X-Deny-Reason: plan_familyThe key is valid, but the family of this endpoint is not in your plan.
MCP401, WWW-AuthenticateHeader missing on the connection, or key refused. The check happens before a single tool is announced; after that, every tool call is judged against the rights of the key.
WebSocket401 (key) or 403 (plan) at the handshakeKey or token missing, invalid, expired or already used (missing_api_key, invalid_api_key), or plan without the stream (plan_websocket). The connection does not open.
WebSocketclose 4003 / 40054003: the plan’s ceiling on simultaneous connections reached, for the whole account, and the reason gives the ceiling. 4005: a client too slow to consume the stream, reason client too slow.
Plain HTTP403, https_requiredA call to http:// instead of https://: refused, never redirected. If the request carried your key, it travelled unencrypted: rotate it.
WebSocket503 at the handshakeThe stream service has reached its capacity, all accounts together. Neither the key nor the plan is at fault: retry a little later, with an increasing delay.

#Protecting and rotating the key

Where to keep it

  • In an environment variable or a secrets manager, never in the code repository.
  • One key per environment: development, staging, production. A leak can then be revoked without stopping the rest.
  • Never in a web page: a key in front-end code is readable by every visitor. Call the REST routes from your backend; for the stream, hand the page a token from POST /v1/ws/token.

Rotating it

  • Create the new key in the console. Both stay valid during the overlap.
  • Deploy it everywhere, then check that traffic on the old one has fallen back to zero.
  • Revoke the old one. Revocation is immediate.