Autenticación de apps y agentes de IA
Una sola clave abre los tres canales. Viaja en una cabecera en cada uno (X-API-KEY, en REST, en MCP y en el handshake de WebSocket); una página web, que no puede guardar secretos, abre el flujo con un token de corta duración que le entrega tu servidor. Su valor es el mismo en todas partes, y en cada uno se aplican los permisos de tu plan.
#API REST
La clave va en la cabecera X-API-KEY, o en Authorization: Bearer para un cliente que solo sabe enviar un token Bearer (si se envían ambas, gana X-API-KEY). Nunca en la URL: una clave en una query string acabaría en los registros de acceso de cada intermediario del camino. Solo HTTPS: una petición en http:// sin cifrar se rechaza con 403 https_required, nunca se redirige, porque una redirección dejaría viajar la clave sin cifrar en cada llamada. Si ves ese error, rota la clave que llevaba.
curl -H "X-API-KEY: $BYTNODE_API_KEY" \
"https://api.bytnode.com/v1/basis?symbol=ETHUSDT"La comparación se ejecuta en tiempo constante, así que una clave errónea tarda exactamente lo mismo en rechazarse que una casi correcta.
#La ruta sin clave
Solo una ruta responde sin autenticación: /v1/health, que indica si el servicio está en marcha. Todo lo demás exige la clave. Dos rutas nunca se descuentan de la cuota mensual (solo se aplica el límite de velocidad): /v1/health y /v1/limits, los límites de tu clave. /v1/status (la frescura de los datos) y /v1/symbols (el catálogo de activos) pertenecen a la familia precio y volumen, abierta en todos los planes, y cuentan como cualquier otra llamada.
Los 1 campos de esta ruta (según el contrato)
database | `connected` when the service reaches its database. A failing check answers 503 `service_unavailable` with `database` in `error.details`. |
Los 15 campos de esta ruta (según el contrato)
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). |
Los 5 campos de esta ruta (según el contrato)
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#Flujo WebSocket
Desde un servidor, la clave va en la cabecera X-API-KEY del handshake, exactamente como en REST. Una página web no debe tener tu clave, y un navegador no puede poner cabeceras en un WebSocket: la página abre el flujo con ?token=, un token de corta duración que tu servidor obtiene de POST /v1/ws/token. En ambos casos, la credencial se comprueba en el handshake, antes de que se abra la conexión.
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, llamada por tu servidor con su clave, devuelve un token y una url lista para abrir. Un token vale 60 segundos y abre UNA conexión: usado o caducado, se rechaza; pide uno nuevo para cada conexión y cada reconexión. Solo abre el WebSocket, nunca una ruta REST, y los permisos aplicados son los de la clave en el momento del handshake (una clave revocada entretanto ya no se conecta). Un plan sin el flujo recibe 403 plan_websocket; emitir un token no se descuenta de la cuota mensual.
# 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_…"}}La sesión se abre ya autenticada: el primer mensaje puede ser la suscripción. Una credencial ausente recibe un 401 missing_api_key en el handshake; una clave desconocida o revocada, o un token inválido, caducado o ya usado, un 401 invalid_api_key; un plan sin el flujo un 403 plan_websocket; y no se establece nada. Un 503 en el handshake no tiene que ver con la clave: el servicio de flujo ha alcanzado su capacidad y hay que reintentar un poco más tarde. El límite de conexiones simultáneas del plan vale para toda la cuenta: la conexión de más se cierra con el código 4003. La antigua trama {"op": "auth", "api_key": "…"} sigue aceptándose y responde authenticated, pero es opcional.
Los parámetros ?token= y ?api_key= solo se aceptan en el handshake de WebSocket, nunca en las rutas REST, y la pasarela no registra la línea de petición de /ws. ?api_key= sigue funcionando para los clientes existentes, pero una clave en una URL acaba en el historial del navegador y en los registros de los proxies: usa la cabecera desde un servidor y el token en una página. Los detalles del protocolo están en la página Flujo WebSocket.
#Servidor MCP
El servidor MCP espera la misma clave, en la misma cabecera X-API-KEY, puesta en la conexión HTTP. Sin ella no se expone ninguna herramienta: el servidor rechaza por defecto en lugar de abrir por defecto.
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())La forma exacta del archivo de configuración depende del agente de IA; el principio no cambia: una URL y una cabecera. Ver Servidor MCP.
#Lo que dice un rechazo
| Canal | Respuesta | Causa |
|---|---|---|
| REST | 401, WWW-Authenticate | Cabecera ausente: error.code: missing_api_key. Clave desconocida o revocada: error.code: invalid_api_key. El mensaje dice qué hacer, por ejemplo « 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 | La clave es válida, pero la familia de este endpoint no está en tu plan. |
| MCP | 401, WWW-Authenticate | Cabecera ausente en la conexión, o clave rechazada. La comprobación ocurre antes de anunciar una sola herramienta; después, cada llamada de herramienta se evalúa según los permisos de la clave. |
| WebSocket | 401 (clave) o 403 (plan) en el handshake | Clave o token ausente, inválido, caducado o ya usado (missing_api_key, invalid_api_key), o plan sin el flujo (plan_websocket). La conexión no se abre. |
| WebSocket | cierre 4003 / 4005 | 4003: se alcanzó el límite de conexiones simultáneas del plan, para toda la cuenta, y el motivo indica el límite. 4005: un cliente demasiado lento para consumir el flujo, motivo client too slow. |
| HTTP sin cifrar | 403, https_required | Una llamada a http:// en lugar de https://: rechazada, nunca redirigida. Si la petición llevaba tu clave, viajó sin cifrar: rótala. |
| WebSocket | 503 en el handshake | El servicio de flujo ha alcanzado su capacidad, sumando todas las cuentas. Ni la clave ni el plan tienen la culpa: reinténtalo un poco más tarde, con un plazo creciente. |
#Proteger y rotar la clave
Dónde guardarla
- En una variable de entorno o un gestor de secretos, nunca en el repositorio de código.
- Una clave por entorno: desarrollo, preproducción, producción. Así una filtración puede revocarse sin detener el resto.
- Nunca en una página web: una clave en el código del navegador la puede leer cualquier visitante. Llama a las rutas REST desde tu servidor; para el flujo, entrega a la página un token de
POST /v1/ws/token.
Rotarla
- Crea la nueva clave en la consola. Ambas siguen siendo válidas durante el solapamiento.
- Despliégala en todas partes y comprueba que el tráfico de la antigua ha caído a cero.
- Revoca la antigua. La revocación es inmediata.