Skip to main content
Every event on both WebSocket channels is a JSON object with a top-level type field that disambiguates the payload shape. This page documents all five event types plus the welcome handshake message.

welcome

Sent exactly once, immediately after a successful WebSocket upgrade. Always the first frame. Real captured:
{
  "type": "welcome",
  "channel": "live",
  "conn_id": 1,
  "message": "connected to books-relayer live channel"
}
FieldTypeDescription
typestringAlways "welcome".
channelstring"live" or "odds" — which channel you’re subscribed to.
conn_idintegerServer-assigned unique connection id. Useful for tracing in server logs.
messagestringHuman-readable handshake message.

snapshot

The second frame every subscriber receives, immediately after welcome and before any delta events. Contains the complete list of currently-live games with their full state. Use this as your baseline — apply all subsequent score_change / status_change / game_final / price_change events on top of this snapshot to maintain a correct live view. Emitted once, per-subscriber. Not broadcast. Not repeated. Each new connection gets its own fresh snapshot. Real captured (from the public endpoint during a Copa Libertadores match):
{
  "type": "snapshot",
  "channel": "live",
  "ts": 1776210900000,
  "count": 11,
  "games": [
    {
      "game_id": "40664-16839-2026-04-14",
      "pn_slug": "lib-gar-est-2026-04-14",
      "pn_league_code": "lib",
      "sport_category": "soccer",
      "start_date": "2026-04-14T22:00:00Z",
      "status": "live",
      "venue": "Estadio Jorge Luis Hirschi",
      "home_team": {
        "name": "Club Estudiantes de La Plata",
        "abbreviation_pn": "est",
        "logo": "https://cdn.opticodds.com/team-logos/soccer/2857.png"
      },
      "away_team": {
        "name": "Cusco FC",
        "abbreviation_pn": "gar",
        "logo": "https://cdn.opticodds.com/team-logos/soccer/12453.png"
      },
      "scores": {
        "status": "Live",
        "score_home": 2,
        "score_away": 1,
        "period": "2H",
        "clock": "94",
        "is_live": true
      }
    }
  ]
}
FieldTypeDescription
typestringAlways "snapshot".
channelstring"live" or "odds" — matches the channel you subscribed to.
tsintegerUnix epoch milliseconds when the snapshot was built server-side.
countintegerNumber of live games in games[]. May be 0 during quiet periods.
gamesarrayFull Game objects for every game where scores.is_live = true at snapshot time. Same shape as List Live Games.

Why snapshot-on-connect?

Before this existed, a new subscriber on /ws/live would see nothing until the next score change fired — which could be seconds or minutes depending on the match. With the snapshot, you know the complete live state from the moment you connect, and can start rendering a scoreboard immediately. Apply events in order:
  1. Receive snapshot — initialize your in-memory view of every live game.
  2. Receive score_change / status_change / game_final events — update the matching entry in your view.
  3. On disconnect + reconnect — discard your state, wait for the new snapshot, restart from step 2.
This is the standard pattern for any polynode WebSocket that serves stateful data (scores, orderbooks, position books). Ephemeral event streams (mempool, trade prints) don’t need a snapshot because there’s no persistent state to baseline.

What snapshot does NOT contain

  • Market snapshots are not included even on /ws/odds. For initial market state, call the REST endpoint GET /v1/games/{id}/markets once per game you care about, then stream price_change deltas from there. Blasting full market state for every live game × every book × every outcome on connect would be tens of MB and pointless for most consumers.
  • Unplayed games are not included. Only games where scores.is_live = true at snapshot build time appear.
  • Final games are not included. Once a game transitions to final, it drops out of the live set immediately.

score_change

Emitted whenever score_home, score_away, period, or clock changes on any polled game. Real captured (Estudiantes 2-1 Cusco, Copa Libertadores, 64th minute):
{
  "type": "score_change",
  "game_id": "40664-16839-2026-04-14",
  "pn_slug": "lib-gar-est-2026-04-14",
  "pn_league_code": "lib",
  "score_home": 2,
  "score_away": 1,
  "period": "2H",
  "clock": "64",
  "ts": 1776208972203
}
FieldTypeDescription
typestringAlways "score_change".
game_idstringUpstream canonical game id.
pn_slugstring | nullPolynode slug. null for games without a resolvable slug.
pn_league_codestringLeague code (nba, epl, lib, etc.).
score_homeintegerCurrent home score.
score_awayintegerCurrent away score.
periodstring | nullCurrent period (e.g. "2H", "Q3", "7").
clockstring | nullClock within the period (sport-specific format).
tsintegerUnix epoch milliseconds when the change was detected.
Channels: /ws/live, /ws/odds

status_change

Emitted when a game transitions between unplayed → live → final. Fires alongside score_change when the transition happens at a boundary. Schema:
{
  "type": "status_change",
  "game_id": "40664-16839-2026-04-14",
  "pn_slug": "lib-gar-est-2026-04-14",
  "pn_league_code": "lib",
  "old_status": "unplayed",
  "new_status": "live",
  "ts": 1776208972203
}
FieldTypeDescription
typestringAlways "status_change".
game_id, pn_slug, pn_league_codeSee score_change above.
old_statusstringPrevious status. Typically "unplayed" or "live".
new_statusstringNew status. Typically "live" or "final".
tsintegerUnix epoch milliseconds.
Channels: /ws/live, /ws/odds

game_final

Fires immediately after a status_change into final or ended. Provides the final score as a convenience so you don’t have to join status_change + the last score_change. Schema:
{
  "type": "game_final",
  "game_id": "40664-16839-2026-04-14",
  "pn_slug": "lib-gar-est-2026-04-14",
  "pn_league_code": "lib",
  "score_home": 2,
  "score_away": 1,
  "ts": 1776210000000
}
FieldTypeDescription
typestringAlways "game_final".
game_id, pn_slug, pn_league_codeSee score_change above.
score_home, score_awayintegerFinal score.
tsintegerUnix epoch milliseconds of the transition.
Channels: /ws/live, /ws/odds

new_game

Emitted the first time the poller sees a particular game_id. Typically fires when a game first appears on upstream’s schedule (days before start), not when it kicks off. For kickoff, watch status_change from unplayed → live. Schema:
{
  "type": "new_game",
  "game_id": "40664-16839-2026-04-14",
  "pn_slug": "lib-gar-est-2026-04-14",
  "pn_league_code": "lib",
  "ts": 1776200000000
}
FieldTypeDescription
typestringAlways "new_game".
game_id, pn_slug, pn_league_codeSee score_change above.
tsintegerUnix epoch milliseconds when we first ingested the game.
Channels: /ws/live, /ws/odds

price_change

The core odds-movement event. Fires whenever any book’s price on any outcome changes. Real captured (888sport shortening their “Estudiantes 2:1” correct-score price from +275 to +230 as Estudiantes went up 2-1):
{
  "type": "price_change",
  "game_id": "40664-16839-2026-04-14",
  "pn_slug": "lib-gar-est-2026-04-14",
  "pn_league_code": "lib",
  "pn_market_type": "correct_score",
  "book": "888sport",
  "outcome": "Club Estudiantes de La Plata 2:1",
  "old_price": 275.0,
  "new_price": 230.0,
  "points": null,
  "ts": 1776208974186
}
FieldTypeDescription
typestringAlways "price_change".
game_id, pn_slug, pn_league_codeSee score_change above.
pn_market_typestringPolynode market type code (moneyline, spreads, totals, points, correct_score, etc.). See List Market Types for the complete set.
bookstringSportsbook name. See List Sportsbooks.
outcomestringOutcome label. Sport-specific: team name for moneyline ("Miami Heat"), "Over 2.5" / "Under 2.5" for totals, player name for player props, score string like "Club Estudiantes de La Plata 2:1" for correct score.
old_pricenumber | nullPrevious American odds. null for a brand-new outcome we hadn’t seen before.
new_pricenumberCurrent American odds. Negative = favorite (-150 = risk 150towin150 to win 100), positive = underdog (+200 = risk 100towin100 to win 200).
pointsnumber | nullSpread or total line when applicable (-6.5, 2.5, 217.5). null for moneyline and most player props.
tsintegerUnix epoch milliseconds.
Channels: /ws/odds only. (This event is not delivered on /ws/live.)

pong

Response to a client-sent {"action":"ping"} JSON message. Used for application-level keepalive when WebSocket protocol Pings are stripped by intermediate proxies. Client sends:
{"action": "ping"}
Server replies:
{"type": "pong", "ts": 1776208974186}

Putting it together — correlating events after a goal

The real wire sequence after a goal in a soccer game:
  1. Poll cycle runs, detects is_live=true + new score.
  2. score_change fires on both channels with the new score.
  3. Multiple price_change events fire on /ws/odds as each book reprices the relevant outcomes (moneyline, correct score, totals, next goal, anytime goalscorer, etc.).
  4. Clients see the score before the price movements because the diff engine always emits state changes first.
This ordering means you can reliably implement “when a goal is scored, wait N ms and see which books moved fastest” logic without race conditions.