🚧 SportsPerp is currently live on devnet. Mainnet target: before Jun 12, 2026 (World Cup kickoff).
For DevelopersWebSocket Feed

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 (devnet, raw)ws://178.104.120.151:3456/ws
Sourceengine/ws-server.ts
ProtocolOne JSON object per ws.send() (UTF-8). Each message stands alone — no framing beyond the WS frame itself.
HeartbeatServer sweep every 30 s; client connections idle > 90 s are terminate()d

HTTPS / WSS note

The devnet server exposes ws:// only. The frontend connects via the same proxy approach as the REST API (the hook useRealtimeCandles automatically falls back to HTTP polling when wss:// is unavailable). For Node.js or server-to-server clients, use ws:// directly.

Connection

import WebSocket from "ws";
 
const ws = new WebSocket("ws://178.104.120.151:3456/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,
  "teamId": 13,
  "playerId": 45,
  "eventType": "goal",
  "indexDelta": 8.0,
  "minute": 34,
  "liveTeamId": 21,
  "livePlayerId": 106232
}

liveTeamId and livePlayerId are included only when the Live↔REST ID bridge is attachedteamId/playerId are then the bridge-translated REST IDs (the canonical IDs used in roster.json and the on-chain markets); the live* fields preserve the original live-side IDs for debugging. Without the bridge attached, teamId/playerId are passed through unchanged.

Delivery scope: broadcast to every connection whose subscription set is non-empty — including subscribe_all clients. There is no per-market filter on live events today; clients that only care about a subset must filter locally on teamId / playerId.

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,
  "entityId": 13,
  "entityName": "Crystal Palace",
  "entityType": "team",
  "liveObv": 0.34
}

entityId is a REST ID (team market ID for teams, StatsBomb player ID for players). 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" }
{ "type": "match_end",   "matchId": 1371134 }

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 as candle_update
  • shadow_obv_source — same shape as obv_source
  • shadow_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_update messages for the same market are delivered in order by time.
  • live_event messages 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 can comfortably handle thousands of concurrent connections on a CPX22.

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("ws://178.104.120.151:3456/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;
}

See the production version in app/src/hooks/useRealtimeCandles.ts, which adds 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_obv and live_event messages are produced upstream.