đźš§ SportsPerp is currently live on devnet. Mainnet target: before Jun 12, 2026 (World Cup kickoff).
For DevelopersCandle REST API

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/…
StorageSQLite (server-side OHLC store)
CORSWide-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 via CANDLE_BACKEND_URL (defaults to https://api.sportsperp.xyz).

Endpoints

GET /api/candles/:marketKey

Returns OHLC bars for a single market.

Path parameters:

ParamExampleFormat
marketKeyteam-arsenal, player-sakaSee “Market keys” below

Query parameters:

ParamDefaultRange
timeframe1H1m · 5m · 15m · 1H · 4H · 1D
limit200Clamped to min(limit, 1000)
fromomittedUnix seconds, inclusive. Together with to, returns the wall-clock window. Optional — if omitted, the endpoint returns the most recent limit bars (back-compat shape).
toomittedUnix 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:

StatusBodyCause
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:

FormatExample
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:

TimeframeBucketTypical cadence outside live
1m60 s1 (sparse — only populated for recorded ticks)
5m300 s1 candle per 5-minute tick
15m900 severy 3 ticks
1H3600 severy 12 ticks
4H14400 severy 48 ticks
1D86400 severy 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).