Skip to main content
The /ws/odds channel is the raw firehose. It emits every price_change from every book we track, plus everything the /ws/live channel emits (score_change, status_change, game_final, new_game). This is the channel for arbitrage, alerting, and real-time market analysis.
wss://books.polynode.dev/ws/odds?key=pn_live_YOUR_KEY

Event types on this channel

Event typeWhen it fires
welcomeOnce, on connect. First frame.
snapshotOnce, right after welcome. Game-state baseline only — no market snapshots.
price_changeAny outcome’s price moves on any book
score_changeSame as /ws/live
status_changeSame as /ws/live
game_finalSame as /ws/live
new_gameSame as /ws/live
The snapshot frame on /ws/odds contains the same game-state baseline as /ws/live — the currently-live games and their scores/clocks. It does not contain market snapshots. For initial market state, call the REST endpoint GET /v1/games/{id}/markets for each game you care about, then stream price_change deltas from there.

Real captured sample

This is the actual wire content from wss://books.polynode.dev/ws/odds during a live match where Estudiantes went up 2-1 (Copa Libertadores, 2H 64’):
{"type":"welcome","channel":"odds","conn_id":2,"message":"connected to books-relayer odds channel"}
{"type":"score_change","game_id":"10035-20332-2026-04-14","pn_slug":null,"pn_league_code":"lib","score_home":1,"score_away":0,"period":"2H","clock":"64","ts":1776208974186}
{"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":1776208974186}
{"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}
{"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":"BetRivers","outcome":"Club Estudiantes de La Plata 2:1","old_price":275.0,"new_price":240.0,"points":null,"ts":1776208974186}
{"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":"Betano","outcome":"Club Estudiantes de La Plata 2:1","old_price":260.0,"new_price":235.0,"points":null,"ts":1776208974186}
Notice the pattern: Estudiantes scores, their 2-1 correct-score outcome is now more likely, and we see 888sport, BetRivers, and Betano all shortening the price simultaneously (from +275/+260 to +230-240). That’s the sort of signal this channel exists to surface.

Volume expectations

During a mid-afternoon window with 5-10 games live, sustained rate is typically 100-500 price_change events per second, with bursts over 1,000/s immediately after goals or scoring plays. Plan your client-side processing accordingly.
  • Memory: each event is ~400 bytes JSON, so 500/s = ~200 KB/s ≈ 1.6 Mbit/s per subscriber.
  • CPU: JSON parsing + any per-event logic. On a modern laptop, ~5,000 events/sec is easy; beyond that you want to batch or pre-filter.
  • Storage: 500 events/sec × 86,400 seconds/day × 400 bytes ≈ 17 GB/day if you log everything. Filter aggressively or sample.

Filtering

Server-side filtering is not yet implemented in v1. Filter client-side on whichever of these fields you care about:
  • pn_league_code (e.g., only NBA → keep nba)
  • pn_market_type (e.g., only moneyline → keep moneyline)
  • book (e.g., only Polymarket vs DraftKings → keep ["Polymarket", "DraftKings"])
  • pn_slug or game_id (e.g., only a single game)
A proper subscription-filter layer is on the roadmap for v1.1.

Example: arbitrage detector

import asyncio, json, websockets
from collections import defaultdict

KEY = "pn_live_YOUR_KEY"
WATCH_BOOKS = {"Polymarket", "DraftKings", "FanDuel", "BetMGM", "bet365"}

# (game_id, market_type, outcome_name) -> {book: price}
book_prices = defaultdict(dict)

def american_to_decimal(odds):
    return 1 + (odds / 100 if odds > 0 else 100 / -odds)

async def main():
    uri = f"wss://books.polynode.dev/ws/odds?key={KEY}"
    async with websockets.connect(uri) as ws:
        async for raw in ws:
            e = json.loads(raw)
            if e.get("type") != "price_change":
                continue
            if e["book"] not in WATCH_BOOKS:
                continue
            key = (e["game_id"], e["pn_market_type"], e["outcome"])
            book_prices[key][e["book"]] = e["new_price"]

            # Simple arb check: best decimal odds across books
            prices = book_prices[key]
            if len(prices) >= 2:
                best = max(prices.items(), key=lambda x: american_to_decimal(x[1]))
                worst = min(prices.items(), key=lambda x: american_to_decimal(x[1]))
                spread = american_to_decimal(best[1]) - american_to_decimal(worst[1])
                if spread > 0.10:
                    print(f"SPREAD {e['pn_slug'] or e['game_id']} {e['pn_market_type']} {e['outcome']}: "
                          f"{best[0]}@{best[1]} vs {worst[0]}@{worst[1]} (Δ={spread:.3f})")

asyncio.run(main())

Ordering guarantees

  • Within a single poll cycle, score_change events are emitted before price_change events for the same game. Clients can rely on seeing the new score before seeing the price movements that respond to it.
  • Across poll cycles, events are delivered in emission order per connection.
  • If your consumer drops (channel full, 256 events queued), events are silently dropped, not buffered indefinitely. Detect gaps by tracking the ts field.

See also

  • Event Reference — full schema for price_change and all other event types
  • Live Channel — if you only need game state without the odds firehose