API Reference
MillionScan API
REST + WebSocket reference for the public API. Bearer key authentication, paginated responses, fixed rate-limit windows, and real-time position events. Examples in cURL, Python, and TypeScript for every endpoint.
Base URL https://millionscan.com · Need a key? /settings → API Access
Authentication
Every request carries a Bearer API key issued under Settings → API Access. The same key authorises both REST and WebSocket. Keys are sensitive — store them in environment variables, never in client-side bundles.
Authorization: Bearer msk_live_xxxxxxxxxxxxxxxxxxxxxxxx- 401 — missing, malformed, or revoked key.
- 402 — key valid but the user's API access window has expired or never been purchased. Body carries a renewal pointer.
- WebSocket close codes mirror the REST split — see Error codes below.
Rate limits
Limits are per API key and enforced server-side in Redis (so all upstream workers observe one shared counter). Fixed windows reset at the top of every clock minute and at midnight UTC.
| Surface | Window | Limit |
|---|---|---|
| REST (per key) | 1 minute | 60 requests |
| REST (per key) | 1 day (UTC) | 5,000 requests |
| WS connections (per key) | concurrent | 10 sockets |
| WS messages (per key) | 1 minute | 500 messages |
Over-the-limit REST responses return 429 with a Retry-After header and a JSON body ({error, scope, limit, retry_after_seconds, message}) so SDK clients can back off cleanly. WS message over-limit sends a {type:"rate_limited"} soft notice and keeps the connection open.
Error codes
| Code | Surface | Meaning |
|---|---|---|
| 401 | REST | Missing, malformed, or revoked API key. |
| 402 | REST | API access expired or never purchased. Renew at /settings#api. |
| 404 | REST | Trader public_id not found. |
| 429 | REST | Rate limit exceeded. Retry-After header included. |
| 4001 | WS | Missing or invalid api_key query parameter. |
| 4002 | WS | API access expired or never purchased. |
| 4008 | WS | Per-key WS connection limit (10) exceeded. |
| 1013 | WS | Server at capacity (200 sockets per worker). |
/api/public/v1/leaderboardLeaderboard
Curated trader leaderboard. Three tiers — featured (curated, currentness-met), active (broader active pool), advanced (high-tier excluding featured) — over a stable, paginated response shape.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| tier | string | optional · default featured | 'featured' (verified, currentness-met set), 'active' (broader active pool), 'advanced' (high-tier excluding featured). |
| page | integer | optional · default 1 | 1-indexed page number. |
| page_size | integer | optional · default 50 | Items per page. Range 1–100. |
| sort_by | string | optional · default score | 'score', 'roi_30d', 'pnl_30d', 'win_rate', 'account_value'. |
| sort_order | string | optional · default desc | 'desc' (default) or 'asc'. |
LeaderboardResponse
| Field | Type | Description |
|---|---|---|
| tier | string | Tier echoed back. |
| page | integer | 1-indexed page returned. |
| page_size | integer | Items in this page. |
| total | integer | Total trader count in this tier. |
| traders[] | PublicTrader[] | public_id (e.g. '#38291'), score, status, roi_30d, pnl_30d, win_rate, account_value, last_trade_at. |
| meta.fetched_at | datetime | Server-side UTC ISO-8601 timestamp. |
curl -H "Authorization: Bearer msk_live_..." \
"https://millionscan.com/api/public/v1/leaderboard?tier=featured&page_size=10"import os, requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['MILLIONSCAN_KEY']}"
resp = session.get(
"https://millionscan.com/api/public/v1/leaderboard",
params={"tier": "featured", "page_size": 10, "sort_by": "score"},
)
resp.raise_for_status()
data = resp.json()
for t in data["traders"]:
print(t["public_id"], t["score"], t["roi_30d"])const resp = await fetch(
"https://millionscan.com/api/public/v1/leaderboard?tier=featured&page_size=10",
{ headers: { Authorization: `Bearer ${process.env.MS_KEY}` } },
);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
for (const t of data.traders) {
console.log(t.public_id, t.score, t.roi_30d);
}/api/public/v1/traders/{public_id}/positionsOpen positions
All currently-open positions for a single trader. Fields are abstracted from venue specifics — coin / side / size / notional / leverage / entry / mark price / unrealized PnL / opened-ago. Snapshot cadence is roughly one update every 30 seconds.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| public_id | string | required | Path parameter — stable trader identifier returned by /leaderboard, e.g. '#38291'. URL-encode the leading '#'. |
PositionsResponse
| Field | Type | Description |
|---|---|---|
| public_id | string | Echoed back. |
| open_positions[] | PublicPosition[] | coin, side ('long' | 'short'), size, notional_value, leverage, entry_price, mark_price, unrealized_pnl, opened_ago. |
| meta.fetched_at | datetime | Server-side UTC ISO-8601. |
curl -H "Authorization: Bearer msk_live_..." \
"https://millionscan.com/api/public/v1/traders/%2338291/positions"import urllib.parse, os, requests
key = os.environ["MILLIONSCAN_KEY"]
public_id = "#38291"
encoded = urllib.parse.quote(public_id, safe="")
resp = requests.get(
f"https://millionscan.com/api/public/v1/traders/{encoded}/positions",
headers={"Authorization": f"Bearer {key}"},
)
resp.raise_for_status()
for p in resp.json()["open_positions"]:
print(p["coin"], p["side"], p["size"], "uPnL:", p["unrealized_pnl"])const publicId = encodeURIComponent("#38291");
const resp = await fetch(
`https://millionscan.com/api/public/v1/traders/${publicId}/positions`,
{ headers: { Authorization: `Bearer ${process.env.MS_KEY}` } },
);
const data = await resp.json();
for (const p of data.open_positions) {
console.log(p.coin, p.side, p.size, "uPnL:", p.unrealized_pnl);
}/api/public/v1/traders/{public_id}/tradesTrade history
Full trade history for a single trader, newest event first. API-key callers receive the unrestricted history (no day-window cap). Useful for backtests, cohort analysis, and per-event drill-downs.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| public_id | string | required | Path parameter — see Positions. |
| page | integer | optional · default 1 | 1-indexed page number. |
| page_size | integer | optional · default 50 | Items per page. Range 1–100. |
TradesResponse
| Field | Type | Description |
|---|---|---|
| public_id | string | Echoed back. |
| page | integer | 1-indexed page returned. |
| page_size | integer | Items in this page. |
| total | integer | Total events across all pages. |
| trades[] | PublicTrade[] | timestamp, coin, side, action ('OPEN'|'ADD'|'REDUCE'|'CLOSE'|'FLIP'), size, entry_price, realized_pnl (populated on CLOSE / partial REDUCE). |
| meta.fetched_at | datetime | Server-side UTC ISO-8601. |
curl -H "Authorization: Bearer msk_live_..." \
"https://millionscan.com/api/public/v1/traders/%2338291/trades?page=1&page_size=100"import urllib.parse, os, requests
key = os.environ["MILLIONSCAN_KEY"]
public_id = "#38291"
encoded = urllib.parse.quote(public_id, safe="")
resp = requests.get(
f"https://millionscan.com/api/public/v1/traders/{encoded}/trades",
headers={"Authorization": f"Bearer {key}"},
params={"page": 1, "page_size": 100},
)
resp.raise_for_status()
data = resp.json()
wins = [t for t in data["trades"] if (t["realized_pnl"] or 0) > 0]
print(f"win/loss split: {len(wins)}/{len(data['trades']) - len(wins)}")const publicId = encodeURIComponent("#38291");
const resp = await fetch(
`https://millionscan.com/api/public/v1/traders/${publicId}/trades?page=1&page_size=100`,
{ headers: { Authorization: `Bearer ${process.env.MS_KEY}` } },
);
const { trades } = await resp.json();
const wins = trades.filter((t: any) => (t.realized_pnl ?? 0) > 0);
console.log(`win/loss split: ${wins.length}/${trades.length - wins.length}`);/api/public/v1/eventsLive events stream
WebSocket endpoint that pushes position events (OPEN / ADD / REDUCE / CLOSE / FLIP) in real time. Authenticate by passing the API key as a query parameter; subscribe to a list of public_ids (or empty for all). Server pings every 30s; the SDK responds automatically.
Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| api_key | string | required | Query parameter. Same key issued for REST. The WebSocket handshake reads it before accept(). |
Stream messages
| Field | Type | Description |
|---|---|---|
| {type:'subscribed'} | ack | Server confirms a subscribe message; echoes the trader_ids filter applied. |
| {type:'position_event', ...} | event | Pushed for each new position event. Fields: public_id, action, coin, side, size, leverage, entry_price, realized_pnl, timestamp. |
| {type:'ping'} | heartbeat | Sent every 30 s to keep the connection alive through proxy idle timeouts. Reply with {type:'pong'}. |
| Close codes | control | 4001 missing/invalid api_key, 4002 expired access, 4008 per-key WS connection limit (10), 1013 server at capacity. |
# wscat (npm install -g wscat) — easiest manual test
wscat -c "wss://millionscan.com/api/public/v1/events?api_key=msk_live_..."
# Then send:
{"type":"subscribe","trader_ids":["#38291","#42391"]}import asyncio, json, os
import websockets
async def main():
key = os.environ["MILLIONSCAN_KEY"]
url = f"wss://millionscan.com/api/public/v1/events?api_key={key}"
async with websockets.connect(url) as ws:
# Subscribe to a specific watchlist (empty list = all)
await ws.send(json.dumps({
"type": "subscribe",
"trader_ids": ["#38291", "#42391"],
}))
async for raw in ws:
msg = json.loads(raw)
if msg.get("type") == "ping":
await ws.send(json.dumps({"type": "pong"}))
continue
if msg.get("type") == "position_event":
print(msg["public_id"], msg["action"], msg["coin"], msg["side"])
asyncio.run(main())const key = process.env.MS_KEY!;
const ws = new WebSocket(
`wss://millionscan.com/api/public/v1/events?api_key=${key}`,
);
ws.addEventListener("open", () => {
ws.send(JSON.stringify({
type: "subscribe",
trader_ids: ["#38291", "#42391"],
}));
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data as string);
if (msg.type === "ping") {
ws.send(JSON.stringify({ type: "pong" }));
return;
}
if (msg.type === "position_event") {
console.log(msg.public_id, msg.action, msg.coin, msg.side);
}
});Looking for product context, pricing, or use cases? Back to /developers.