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 -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.
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`. |
All 15 fields of this route (from the contract)
computed_at | ISO 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_count | count | Public feeds checked. |
stale_feed_count | count | Feeds older than their threshold. |
error_feed_count | count | Feeds that could not be checked. |
max_age_s | seconds | Age of the oldest feed. |
feeds | One row per public feed. | |
feeds.feed | Public feed name. The closed list of this contract version; `routes` gives the routes each one supplies. | |
feeds.family | Data 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.routes | The 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_s | seconds | Age 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_s | seconds | Maximum legitimate age of the feed. |
feeds.threshold_s | seconds | Age above which the feed is stale (1.5 × expected_interval_s). |
feeds.is_stale | Whether 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_count | count | Venues lagging on this feed; `null` when the feed is not split by venue (every non-market feed). |
All 5 fields of this route (from the contract)
symbol | Trading pair, e.g. `BTCUSDT`. | |
base_asset | Base asset of the pair. | |
name | Human-readable name. | |
quote_asset | Quote currency of the pair (`USDT` for BTCUSDT, `USDC` for USDTUSDC): the currency of its prices. | |
markets | Markets 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`). |
# 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.
# 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
| Channel | Response | Cause |
|---|---|---|
| REST | 401, WWW-Authenticate | Header 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. » |
| REST | 403, X-Deny-Reason: plan_family | The key is valid, but the family of this endpoint is not in your plan. |
| MCP | 401, WWW-Authenticate | Header 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. |
| WebSocket | 401 (key) or 403 (plan) at the handshake | Key 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. |
| WebSocket | close 4003 / 4005 | 4003: 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 HTTP | 403, https_required | A call to http:// instead of https://: refused, never redirected. If the request carried your key, it travelled unencrypted: rotate it. |
| WebSocket | 503 at the handshake | The 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.