WebSocket Feed
The WebSocket feed streams real-time candle updates, live-match events, OBV deltas, and match lifecycle notifications. It complements the REST candle API — REST gives you history, WS gives you the tape.
| Endpoint (public) | wss://api.sportsperp.xyz/ws |
| Protocol | One JSON object per ws.send() (UTF-8). Each message stands alone — no framing beyond the WS frame itself. |
| Heartbeat | Server sweep every 30 s; client connections idle > 90 s are terminate()d |
HTTPS / WSS note
Connect over wss://api.sportsperp.xyz/ws. The trading frontend’s real-time candle hook automatically falls back to HTTP polling when the WebSocket is unavailable.
Connection
import WebSocket from "ws";
const ws = new WebSocket("wss://api.sportsperp.xyz/ws");
ws.on("open", () => {
console.log("Connected");
ws.send(JSON.stringify({ type: "subscribe", markets: ["team-arsenal", "player-saka"] }));
});
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
console.log(msg);
});Standard ws library usage. No authentication required — all data is public.
Client → Server messages
All messages are JSON objects with a type field. Unknown types receive an error reply but the connection stays open.
subscribe
Adds market keys to this connection’s subscription set. Subsequent matching candle_update messages (and broadcasts with non-empty subscriptions) will be delivered.
{ "type": "subscribe", "markets": ["team-arsenal", "player-saka"] }Server responds with a subscribed ack listing the full current subscription set (not just the added keys):
{ "type": "subscribed", "markets": ["team-arsenal", "player-saka"] }unsubscribe
Removes market keys from this connection’s subscription set. Server responds with the same subscribed type (there is no separate unsubscribed type), listing what remains:
{ "type": "unsubscribe", "markets": ["team-arsenal"] }{ "type": "subscribed", "markets": ["player-saka"] }subscribe_all / unsubscribe_all
Firehose mode. subscribe_all adds the sentinel "*" to the subscription set, which matches every market for candle_update delivery. unsubscribe_all clears the set entirely.
{ "type": "subscribe_all" }
{ "type": "unsubscribe_all" }The server acks both with subscribed:
{ "type": "subscribed", "markets": ["*"] }
{ "type": "subscribed", "markets": [] }Firehose mode is useful for analytics or archival clients; per-market subscribe scales better for trading UIs.
ping
Keepalive. The server responds with { "type": "pong" }. Any client message — including ping — also updates the server’s lastPing timestamp for that connection, so a regular trickle of subscribe/unsubscribe/ping is enough to avoid the 90-second idle timeout.
{ "type": "ping" }Server → Client messages
candle_update
A 1-minute candle tick for a subscribed market. Broadcast at crank cadence (every 5 minutes outside live windows; more frequently during live matches as event-driven updates arrive). Delivered only to connections whose subscription set contains the market key (or "*").
{
"type": "candle_update",
"market": "team-arsenal",
"price": 602.4,
"time": 1745399700
}price is the post-scaling index value (100–900 range) as a float for display convenience. For settlement math, always read the on-chain mark_price_ema via the SDK — it is in 10⁶ fixed-point.
live_event
A real-time in-match event annotated with its index impact.
{
"type": "live_event",
"matchId": 1371134,
"homeMarketId": 6,
"awayMarketId": 18,
"scoreHome": 1,
"scoreAway": 0,
"teamId": 13,
"playerId": 45,
"playerName": "Bukayo Saka",
"eventType": "goal",
"indexDelta": 8.0,
"minute": 34,
"liveTeamId": 21,
"livePlayerId": 106232
}playerName is the player’s display name for this event, sourced from the match-day lineup (live_lineup) and keyed by the live-side player ID — so it is populated for any player in the live match, including those without a tradeable market. It is null only when the event has no attributed player (the upstream live player_id is null — e.g. a team-level event) or when the lineup name is not yet known, and is absent on engine builds predating this field. Because the name is keyed off the live player ID, it can be present even when the output playerId is null: with the bridge attached a translation miss nulls playerId while livePlayerId and playerName stay populated. Do not infer “no player” from playerId: null — check livePlayerId / playerName instead. Treat the name as a display label only; for market mapping use homeMarketId/awayMarketId, never the name.
liveTeamId and livePlayerId are included only when the Live↔REST ID bridge is attached — teamId/playerId are then the bridge-translated StatsBomb REST IDs (for players this matches roster.json’s sb_player_id); the live* fields preserve the original live-side IDs for debugging. Without the bridge attached, teamId/playerId are passed through unchanged. These are the data provider’s IDs — not the 0–99 on-chain market IDs (roster teams carry no StatsBomb team ID at all). Use homeMarketId/awayMarketId for any market mapping or feed scoping.
scoreHome / scoreAway are the match’s goal-derived running score (home / away), stamped on live_event / match_start / match_end frames — but not live_obv. Each is a number, or null when the engine can’t resolve the match’s score; both are absent on engine builds predating this feature (Wave E). The score is derived server-side from goal events (the data partner’s own match_*_score fields were null in real captures), so treat it as authoritative for display — a client takes the newest frame carrying non-null scores as the headline (the latestScore helper).
Delivery scope: broadcast to every connection whose subscription set is non-empty — including subscribe_all clients. The server does not pre-filter live frames per connection; instead every live_event / live_obv / match_start / match_end frame carries homeMarketId / awayMarketId — the two roster market IDs of the match’s teams, resolved from the team names (so they are stable across the Live/REST ID split). Either is null when that side’s team is not in the roster, and both are absent on engine builds predating this feature. A client scopes its feed to one market by keeping frames where homeMarketId or awayMarketId equals the viewed team market. These are the only stable market-scope keys. Do not scope on teamId / playerId (StatsBomb data IDs) or on live_obv.entityId (a source/entity ID — see below): all three live in ID spaces distinct from the on-chain market IDs and from each other, so they cannot scope the feed consistently.
eventType reflects whatever the upstream LiveProcessor emits as an EventImpact. Common values include goal, own_goal, red_card, yellow_card, substitution, penalty_scored, penalty_missed, plus the OBV-category strings (pass, shot, defensive_action, dribble_carry, gk_action).
indexDelta is the post-z-score point change in the index contributed by this single event.
live_obv
Aggregated per-match OBV snapshot for a team or player.
{
"type": "live_obv",
"matchId": 1371134,
"homeMarketId": 6,
"awayMarketId": 18,
"entityId": 6,
"entityName": "Crystal Palace",
"entityType": "team",
"liveObv": 0.34
}entityId / entityName identify the entity this snapshot is for. entityId is the source/aggregation entity ID emitted by the obv-engine sidecar — on the authoritative live path it is the sidecar’s own ID (live-keyed, before any Live→REST translation), so treat it as an entity identifier, not as a reliable on-chain market ID or scope key (scope with homeMarketId/awayMarketId). liveObv is the raw aggregated OBV value, not z-scored, typically in the [−2.0, +2.0] band over a full match. Delivered when the underlying obv-engine sidecar returns a new aggregation. Broadcast scope is the same as live_event (any non-empty subscription set).
obv_teams_breakdown / obv_players_breakdown
Per-(entity × category) breakdown maintained by engine/obv-store.ts. Categories are the five OBV action types: pass, dribble_carry, defensive, shot, gk. Each category exposes a net value (carried as a plain field on the row for backward compatibility), with *_gf and *_ga siblings for the goals-for / goals-against cross-tab.
{
"type": "obv_teams_breakdown",
"matchId": 1371134,
"perCategory": [
{
"teamId": 13,
"pass": 0.42,
"dribble_carry": 0.11,
"defensive": 0.08,
"shot": 0.31,
"gk": -0.03,
"total": 0.89,
"pass_gf": 0.54, "pass_ga": 0.12,
"dribble_carry_gf":0.14, "dribble_carry_ga": 0.03,
"defensive_gf": 0.10, "defensive_ga": 0.02,
"shot_gf": 0.31, "shot_ga": 0.00,
"gk_gf": 0.00, "gk_ga": 0.03,
"total_gf": 1.09, "total_ga": 0.20
}
]
}obv_players_breakdown has the same shape but each row carries playerId and teamId (the player’s team, may be null if unknown) instead of teamId alone.
Note: total values are the sum of the five category values modulo uncategorized events (fouls, subs, stoppages contribute ~0 OBV anyway — observed ~8.8% on fixture match 1371134).
This channel is the source of the per-category bars in the trading UI’s live match feed. The split into gf (offensive contribution) vs ga (defensive cost) lets traders see why a team’s net OBV is moving, not just that it is.
obv_source
A per-match live-OBV source-tier transition. Emitted when the fallback chain changes which scoring source it is consuming for a match (e.g., authoritative → aggregated when the obv-engine sidecar stalls).
{
"type": "obv_source",
"matchId": 1371134,
"team": { "source": "authoritative", "reason": "obv-engine totals received", "since": 1745399700000 },
"player": { "source": "aggregated", "reason": "player totals timed out (30s)", "since": 1745399730000 }
}since is Unix milliseconds (capture time of the last transition). team and player can diverge — one dimension can drop a tier while the other stays stable.
match_start / match_end
Match lifecycle notifications. Broadcast to every connection with a non-empty subscription set.
{ "type": "match_start", "matchId": 1371134, "home": "Crystal Palace", "away": "West Ham", "homeMarketId": 6, "awayMarketId": 18, "scoreHome": 0, "scoreAway": 0 }
{ "type": "match_end", "matchId": 1371134, "homeMarketId": 6, "awayMarketId": 18, "scoreHome": 2, "scoreAway": 1 }Like the live frames above, match_start / match_end carry homeMarketId / awayMarketId for per-market scoping (null for an unrostered side, absent on pre-scoping engine builds), plus scoreHome / scoreAway — the goal-derived score, 0–0 at match_start and the final at match_end (null when unresolved, absent on pre-Wave-E engines).
After match_end, expect a 30-minute wait before the official post-match OBV arrives from the data partner’s REST feed and the 4-hour EMA blend begins (see Real-Time vs Post-Match).
subscribed
Acknowledgment of subscribe, unsubscribe, subscribe_all, or unsubscribe_all. Always lists the full current subscription set for the connection.
{ "type": "subscribed", "markets": ["team-arsenal", "player-saka"] }pong
Reply to a client ping. Clients can track the round-trip for connection health monitoring.
error
Server reports an error (invalid JSON, unknown message type, etc.). Does not close the connection.
{ "type": "error", "message": "Invalid JSON" }Shadow channels
Whenever the crank runs V2 in shadow mode alongside the authoritative V1 pipeline (SHADOW_REALTIME_OBV=true), it broadcasts the would-be V2 output on shadow_* channels so dashboards can observe the V2 trajectory without existing consumers receiving duplicate ticks:
shadow_candle_update— same payload shape ascandle_updateshadow_obv_source— same shape asobv_sourceshadow_obv_teams_breakdown/shadow_obv_players_breakdown— same shape as the non-shadow variants
A consumer migrates by renaming the channel it listens to; nothing else changes.
Heartbeat and reconnection
- Server sweep interval: every 30 s, the server checks each connection’s
lastPing(updated on any received message). - Idle timeout: > 90 s of silence →
ws.terminate(). - Reconnect: subscriptions do not persist server-side across disconnects. After a reconnect, re-send your subscribe messages. Exponential backoff (2 s → 4 s → 8 s, cap at 30 s) is a reasonable client default.
Message ordering guarantees
Within a single TCP connection:
candle_updatemessages for the same market are delivered in order bytime.live_eventmessages within a single match arrive in the order the upstream processor produced them.- Cross-market / cross-match messages may be interleaved arbitrarily.
No total ordering across all messages is guaranteed. If you need a deterministic replay, use the REST API with timestamp ranges rather than reconstructing from WS.
Rate and throughput
Peak throughput during a typical EPL matchday (5 concurrent matches, ~500 events/match):
- Live events: ~2500 messages/minute during active matches
- Candle updates: ~68 messages per crank cycle (every 5 minutes) outside live windows
- Idle connections: < 1 message/minute on non-matchdays
A single client subscribed to all markets during peak matchday load will see ~50 KB/s of traffic. The server comfortably handles a large number of concurrent connections.
Example: minimal React hook
import { useEffect, useState } from "react";
export function useMarketTick(marketKey: string) {
const [tick, setTick] = useState<{ price: number; time: number } | null>(null);
useEffect(() => {
const ws = new WebSocket("wss://api.sportsperp.xyz/ws");
ws.onopen = () => ws.send(JSON.stringify({ type: "subscribe", markets: [marketKey] }));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "candle_update" && msg.market === marketKey) {
setTick({ price: msg.price, time: msg.time });
}
};
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, 30_000);
return () => { clearInterval(pingInterval); ws.close(); };
}, [marketKey]);
return tick;
}The production frontend hook additionally implements reconnection, HTTP polling fallback, and candle-buffer management.
Further reading
- Candle REST API — historical data, the WS feed’s companion.
- SDK — on-chain state, the settlement-accurate source.
- Real-Time vs Post-Match — how
live_obvandlive_eventmessages are produced upstream.