Candle REST API
The candle REST API exposes historical OHLC price bars, market roster metadata, live match state, and fixture schedules. It is the easiest entry point for building charts, dashboards, or backtesting tools.
| HTTPS (public API) | https://api.sportsperp.xyz |
| HTTPS (Next.js proxy, browser clients) | https://www.sportsperp.xyz/api/candles/… |
| Storage | SQLite (server-side OHLC store) |
| CORS | Wide-open (Access-Control-Allow-Origin: *, GET only) |
HTTPS / mixed-content note
All public access is over TLS via https://api.sportsperp.xyz. Browser clients on HTTPS should not call any non-TLS origin directly — the browser will block it as mixed content. Two HTTPS-safe paths exist:
- Server-side / Node.js / cURL: call
https://api.sportsperp.xyz. - Browser: route through the Next.js API proxies —
/api/candles/[marketKey],/api/roster,/api/fixtures. The proxies’ upstream is configured viaCANDLE_BACKEND_URL(defaults tohttps://api.sportsperp.xyz).
Endpoints
GET /api/candles/:marketKey
Returns OHLC bars for a single market.
Path parameters:
| Param | Example | Format |
|---|---|---|
marketKey | team-arsenal, player-saka | See “Market keys” below |
Query parameters:
| Param | Default | Range |
|---|---|---|
timeframe | 1H | 1m · 5m · 15m · 1H · 4H · 1D |
limit | 200 | Clamped to min(limit, 1000) |
from | omitted | Unix seconds, inclusive. Together with to, returns the wall-clock window. Optional — if omitted, the endpoint returns the most recent limit bars (back-compat shape). |
to | omitted | Unix seconds, inclusive. Required if from is set. to - from must be ≤ 30 days. |
When from / to are present, the response is ordered ascending by time. For aggregated timeframes, the first returned bar can start before from when the requested window begins inside that candle’s UTC bucket. The limit clamp still applies as a safety cap.
Example — last-N (back-compat):
curl "https://api.sportsperp.xyz/api/candles/team-arsenal?timeframe=5m&limit=50"Example — wall-clock range (live since 2026-05-13):
NOW=$(date +%s); FROM=$((NOW - 86400))
curl "https://api.sportsperp.xyz/api/candles/market-12?timeframe=5m&from=$FROM&to=$NOW"Response — a raw array of OHLC bars (no envelope, no volume):
[
{ "time": 1745280000, "open": 598.4, "high": 604.1, "low": 597.9, "close": 602.3 },
{ "time": 1745283600, "open": 602.3, "high": 605.0, "low": 601.7, "close": 604.5 }
]time is Unix seconds at bar start. The store records raw 1m bars and aggregates all higher timeframes on read. Trading volume is not yet tracked server-side and is not part of the response.
Error responses:
| Status | Body | Cause |
|---|---|---|
| 400 | { "error": "Invalid timeframe. Use: 1m, 5m, 15m, 1H, 4H, 1D" } | timeframe not in the allowed set |
| 400 | { "error": "from/to must be unix seconds with to > from" } | Either is missing, non-numeric, negative, or to ≤ from |
| 400 | { "error": "range exceeds 30 days" } | to - from > 2592000 seconds |
GET /api/candles/stats
High-level stats about the candle store.
{
"totalCandles": 184302,
"markets": 68,
"oldestTimestamp": 1733000000,
"newestTimestamp": 1745400000
}oldestTimestamp / newestTimestamp are Unix seconds and may be null if the store is empty. Useful for health checks and capacity planning.
GET /api/roster
Full roster.json contents — the single source of truth for market metadata.
{
"roster_version": 4,
"competition_id": 2,
"season_id": 318,
"teams": [
{ "market_id": 0, "name": "Arsenal", "short_name": "ARS", "source_team_id": 1, "...": "..." }
],
"players": [
{ "market_id": 20, "name": "Bukayo Saka", "team_id": 1, "position_group": "Forward", "sb_player_id": 39461, "...": "..." }
]
}v4 adds sb_player_id on every player entry to harden market ↔ upstream-feed identity across transfers and roster rotations. Clients should fetch this at startup or on a long interval — the roster changes weekly, not per-second. The engine itself hot-reloads via fs.watch, so the served data is always current.
GET /api/fixtures
Next upcoming or in-progress match per team market ID.
{
"fixtures": {
"0": { "kickoff": 1745452800, "opponent": "Chelsea", "home": true },
"1": { "kickoff": 1745539200, "opponent": "Manchester City", "home": false }
}
}Keys are team-market IDs as strings (player markets do not appear here — fixtures are per-team). kickoff is Unix seconds UTC. The window includes matches that kicked off up to 3 hours ago so callers can also use this endpoint to detect live matches. Cached server-side for 1 hour; if the upstream fetch fails, the previous cached snapshot is returned (or {} if there is none).
GET /api/live/matches
Active and recently completed matches with per-team/player live OBV aggregates.
{
"active": [
{
"matchId": 1371134,
"homeTeam": "Crystal Palace",
"awayTeam": "West Ham",
"homeTeamId": 13,
"awayTeamId": 20,
"eventsProcessed": 1347,
"teamObv": { "13": 0.12, "20": -0.08 },
"playerObv": { "45": 0.03, "67": -0.01 }
}
],
"completed": [
{
"matchId": 1371129,
"homeTeam": "Arsenal",
"awayTeam": "Everton",
"officialDataExpectedAt": 1745382000
}
],
"wsConnections": 12
}teamObv / playerObv keys are REST IDs (the canonical IDs used in roster.json); the Live↔REST ID bridge translates live-side IDs before they appear here. If the crank has not yet attached a LiveProcessor, the endpoint returns { "active": [], "completed": [] }.
GET /health / GET /api/health
{
"status": "ok",
"uptime": 842391,
"lastCycleAt": "2026-05-12T11:30:00.000Z",
"crankCycles": 4317,
"wsConnections": 12,
"liveObvSources": [
{
"matchId": 1371134,
"team": { "source": "authoritative", "reason": "obv-engine returned totals", "since": 1745399700000 },
"player": { "source": "authoritative", "reason": "obv-engine returned totals", "since": 1745399700000 }
}
],
"liveProcessor": {
"lastSuccessfulPollMs": 1745399945123,
"pollErrorsInLastMinute": 0,
"lastPollSummary": "12 matches polled, 1 active"
},
"shadowObv": null,
"idBridge": null
}shadowObv is present only when the crank runs V2 in shadow mode (SHADOW_REALTIME_OBV=true). idBridge is present when the Live↔REST ID bridge has registered a provider. liveProcessor is null when no live processor is attached.
Returns HTTP 200 unconditionally — the monitor service parses the body. Use status === "ok" as the readiness signal.
Market keys
Market keys are the URL-safe identifier format:
| Format | Example |
|---|---|
team-{slug} | team-arsenal, team-manchester-united |
player-{slug} | player-saka, player-haaland |
The slug is the team or player name lowercased, diacritics stripped, spaces → hyphens. Both the exact and normalized variants are stored in the engine’s market map so lookups by either resolve cleanly.
Always prefer market_id for programmatic access. Use /api/roster to resolve name → ID and then build the key, or store the key as returned by the roster — IDs are the authoritative identifier and survive renames.
Timeframes and aggregation
The crank writes a tick every cycle (every 5 minutes outside live windows; faster during live matches as event-driven updates arrive). The store records raw 1-minute bars and aggregates on read:
| Timeframe | Bucket | Typical cadence outside live |
|---|---|---|
1m | 60 s | 1 (sparse — only populated for recorded ticks) |
5m | 300 s | 1 candle per 5-minute tick |
15m | 900 s | every 3 ticks |
1H | 3600 s | every 12 ticks |
4H | 14400 s | every 48 ticks |
1D | 86400 s | every 288 ticks |
Bar boundaries are aligned to UTC wall-clock buckets (floor(time / bucket) * bucket). 1m candles may be sparse between the 5-minute oracle ticks — outside live matches a 60-bar ?timeframe=1m query can span several wall-clock hours of stored buckets, not one hour.
The trading app treats the selector as candle intervals (1m / 5m / 15m / 1H / 4H / 1D), fetches by from / to, and defaults to 5m. Candlestick mode renders only real aggregated bars. Area mode applies client-side carry-forward gap fill so sparse periods still have a continuous x-axis.
Rate limits
Please be a good citizen with request volume — the API is rate-limited and abusive traffic is throttled at the edge:
- Cache the roster response client-side — it changes at most weekly.
- Poll candles no more often than the bar duration allows.
- Use the WebSocket feed for real-time updates instead of polling — see WebSocket Feed.
If you are planning high-volume consumption (backtesting, academic research), reach out — we can provide a direct dump from the SQLite store rather than hammering the API.
Historical backfill gaps
The candle store is append-only since the crank was deployed. Markets initialized after deployment only have candles from that point forward. There is no backfill of candles from before a market’s on-chain initialization — even if the engine calculated indices privately, they were not persisted.
If you need longer historical data for modeling, the underlying event data is available directly from the source vendor with appropriate credentials; composite index values can be replayed from historical inputs using the published index formula.
Further reading
- WebSocket Feed — real-time updates, complementary to this REST API.
- SDK — typed client for the on-chain program (the complementary “state” API to this REST “data” API).