MillionScan is an on-chain perpetual futures trader analytics platform. We score and surface public on-chain trader performance data, continuously monitor it, and refresh it in real time. Built for developers, researchers, and informed observers. Information only. Not investment advice.

Skip to main content

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.

HTTP
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.

SurfaceWindowLimit
REST (per key)1 minute60 requests
REST (per key)1 day (UTC)5,000 requests
WS connections (per key)concurrent10 sockets
WS messages (per key)1 minute500 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

CodeSurfaceMeaning
401RESTMissing, malformed, or revoked API key.
402RESTAPI access expired or never purchased. Renew at /settings#api.
404RESTTrader public_id not found.
429RESTRate limit exceeded. Retry-After header included.
4001WSMissing or invalid api_key query parameter.
4002WSAPI access expired or never purchased.
4008WSPer-key WS connection limit (10) exceeded.
1013WSServer at capacity (200 sockets per worker).
GET/api/public/v1/leaderboard

Leaderboard

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

NameTypeRequiredDescription
tierstringoptional · default featured'featured' (verified, currentness-met set), 'active' (broader active pool), 'advanced' (high-tier excluding featured).
pageintegeroptional · default 11-indexed page number.
page_sizeintegeroptional · default 50Items per page. Range 1–100.
sort_bystringoptional · default score'score', 'roi_30d', 'pnl_30d', 'win_rate', 'account_value'.
sort_orderstringoptional · default desc'desc' (default) or 'asc'.

LeaderboardResponse

FieldTypeDescription
tierstringTier echoed back.
pageinteger1-indexed page returned.
page_sizeintegerItems in this page.
totalintegerTotal 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_atdatetimeServer-side UTC ISO-8601 timestamp.
cURL
curl -H "Authorization: Bearer msk_live_..." \
  "https://millionscan.com/api/public/v1/leaderboard?tier=featured&page_size=10"
Python
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"])
TypeScript
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);
}
GET/api/public/v1/traders/{public_id}/positions

Open 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

NameTypeRequiredDescription
public_idstringrequiredPath parameter — stable trader identifier returned by /leaderboard, e.g. '#38291'. URL-encode the leading '#'.

PositionsResponse

FieldTypeDescription
public_idstringEchoed back.
open_positions[]PublicPosition[]coin, side ('long' | 'short'), size, notional_value, leverage, entry_price, mark_price, unrealized_pnl, opened_ago.
meta.fetched_atdatetimeServer-side UTC ISO-8601.
cURL
curl -H "Authorization: Bearer msk_live_..." \
  "https://millionscan.com/api/public/v1/traders/%2338291/positions"
Python
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"])
TypeScript
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);
}
GET/api/public/v1/traders/{public_id}/trades

Trade 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

NameTypeRequiredDescription
public_idstringrequiredPath parameter — see Positions.
pageintegeroptional · default 11-indexed page number.
page_sizeintegeroptional · default 50Items per page. Range 1–100.

TradesResponse

FieldTypeDescription
public_idstringEchoed back.
pageinteger1-indexed page returned.
page_sizeintegerItems in this page.
totalintegerTotal 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_atdatetimeServer-side UTC ISO-8601.
cURL
curl -H "Authorization: Bearer msk_live_..." \
  "https://millionscan.com/api/public/v1/traders/%2338291/trades?page=1&page_size=100"
Python
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)}")
TypeScript
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}`);
WS/api/public/v1/events

Live 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

NameTypeRequiredDescription
api_keystringrequiredQuery parameter. Same key issued for REST. The WebSocket handshake reads it before accept().

Stream messages

FieldTypeDescription
{type:'subscribed'}ackServer confirms a subscribe message; echoes the trader_ids filter applied.
{type:'position_event', ...}eventPushed for each new position event. Fields: public_id, action, coin, side, size, leverage, entry_price, realized_pnl, timestamp.
{type:'ping'}heartbeatSent every 30 s to keep the connection alive through proxy idle timeouts. Reply with {type:'pong'}.
Close codescontrol4001 missing/invalid api_key, 4002 expired access, 4008 per-key WS connection limit (10), 1013 server at capacity.
cURL
# 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"]}
Python
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())
TypeScript
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.