WebSocket Streaming
Push-based odds updates over WebSocket. Available on Business tier and up. This page is the complete reference: connect URLs, the three accepted auth forms, the snapshot-then-diff protocol, tier-aware coalescing semantics, ping/pong cadence, close codes, and clean reconnect strategy.
Overview
On /v1/ws/odds, after connecting and authenticating you receive a connected envelope, then an initial_state snapshot, followed by eligible odds_update frames. Snapshot filters and limits still apply; receiving a snapshot does not establish a complete sportsbook board. The dashboard-oriented /v1/ws/live route sends connected and then diffs without an initial snapshot. An unchanged collection cycle may produce no odds update.
Business, Enterprise and Scale use minimum WebSocket push intervals of 1.0 s, 0.5 s and 0.0 s respectively. These settings control server-side coalescing after updates reach the streaming pipeline; they do not bound the time from a sportsbook publishing a price to your client receiving it. Source polling, observation age and database-to-client delivery are separate measurements. See timing and freshness before interpreting a latency figure.
Connect URLs
Two streams, four URL aliases each (the /v1/ prefix is the stable, versioned form; the bare path is the original alias and remains supported):
| URL | What it pushes |
|---|---|
wss://parlay-api.com/v1/ws/odds/{sport_key}wss://parlay-api.com/ws/odds/{sport_key} | All current and upcoming markets (h2h / spread / total / player props / alt lines) for the requested sport_key, across every book we cover. |
wss://parlay-api.com/v1/ws/live/{sport_key}wss://parlay-api.com/ws/live/{sport_key} | The dashboard board feed: game lines plus the moneylines used on game cards. It is narrower than /ws/odds, sends no initial snapshot, and is not an in-play-only version of the paid feed. bookmakers= may narrow this board payload; use /ws/odds for the full programmatic market feed. |
Replace {sport_key} with a value from GET /v1/sports. The most common keys:
| sport_key | Coverage |
|---|---|
baseball_mlb | MLB regular + playoffs, game lines + 40+ player prop markets |
basketball_nba | NBA regular + playoffs, game lines + 60+ player prop markets |
basketball_wnba | WNBA game lines + player props |
icehockey_nhl | NHL game lines + player props (SOG, PPP, etc.) |
americanfootball_nfl | NFL game lines + player props |
soccer_epl et al. | EPL, La Liga, MLS, UCL, Bundesliga, etc. |
mma_mixed_martial_arts | UFC + other MMA cards |
tennis_atp / tennis_wta | Tennis match lines |
Authentication (3 accepted forms)
We accept your API key in any of three forms. Pick the one that fits your client; the first present is used.
| Form | Where | Use it when |
|---|---|---|
| Query param | ?apiKey=YOUR_KEY on the URL | Browser WebSocket client (can't set custom headers), simple Node / Python clients, curl-style testing. |
| X-API-Key header | X-API-Key: YOUR_KEY | Server-side clients (Node, Python websockets, Go gorilla/websocket) where you'd rather keep the key out of URL logs. |
| Sec-WebSocket-Protocol | Subprotocol token apikey.YOUR_KEY (dot separator) | Browser-style clients that can set subprotocols (some JS frameworks, EventSource-shim libraries). Spec-compliant token grammar. |
# Query param (works everywhere)
wss://parlay-api.com/v1/ws/odds/baseball_mlb?apiKey=pk_live_xxxx
# X-API-Key header (Node / Python / Go)
wss://parlay-api.com/v1/ws/odds/baseball_mlb
→ with header X-API-Key: pk_live_xxxx
# Sec-WebSocket-Protocol (browser-friendly)
wss://parlay-api.com/v1/ws/odds/baseball_mlb
→ subprotocol apikey.pk_live_xxxx
4001. See close codes and tier matrix.
Protocol
Every frame is a single JSON object on a single line. Parse each message event from your client as JSON. Frames carry a type discriminator:
| type | Direction | When sent |
|---|---|---|
connected | server → client | Immediately after accept. Tells you your tier, the coalesce window, and that the snapshot is coming. |
initial_state | server → client | One snapshot frame containing up to 500 current rows for the requested sport. |
odds_update | server → client | Each time we detect a price change for any event in scope. |
heartbeat | server → client | About every 5 s on WebSocket. SSE uses heartbeat_s (1–30 s). Both continue while odds updates are flowing. |
subscribe | client → server | Filter all subsequent updates to a single event_id. |
unsubscribe | client → server | Drop the event filter; resume receiving all events. |
subscribed / unsubscribed | server → client | Ack for the above. |
connected envelope
The very first frame after the WebSocket handshake. Sent within ~1 ms of accept so a well-behaved client always sees something, even on sports that currently have no live data.
{
"type": "connected",
"sport_key": "basketball_nba",
"tier": "scale",
"min_push_interval_s": 0.0,
"push_mode": "raw",
"warnings": null,
"timestamp": 1715587200,
"note": "initial_state follows. Subsequent frames are change events; push_mode=raw, every change pushed as it lands."
}
Read push_mode if you want to display "raw stream" vs "1 s coalesced" in your UI. min_push_interval_s is the numeric: 0.0 = raw, 0.5 = Enterprise, 1.0 = Business default.
warnings is normally null. If a requested bookmaker is in its announced grace period, it contains a bookmaker_deprecation item with bookmaker, deprecated_at, reject_after, replacement, and message. The filter continues working until the published cutoff. Use GET /v1/bookmakers for accepted keys and GET /v1/bookmakers?all=true for active, deprecated, retired, decommissioned, and not-yet-integrated lifecycle states.
initial_state snapshot
One frame, immediately after connected. The default broad scope contains a bounded sample of up to 500 recent rows for the requested sport. Rows use either a combined game shape or a per-outcome shape, depending on how the source reports the market.
{
"type": "initial_state",
"sport_key": "basketball_nba",
"timestamp": 1715587200,
"count": 487,
"data": [
{
"event_id": "2026-05-13_Boston_Celtics_New_York_Knicks",
"home_team": "Boston Celtics",
"away_team": "New York Knicks",
"commence_time": "2026-05-13T23:30:00Z",
"bookmaker": "draftkings",
"kind": "game",
"market_key": "h2h",
"home_ml": -135,
"away_ml": 115,
"draw_ml": null,
"last_update": "2026-05-13T23:24:18Z"
},
...
]
}
snapshot_complete=false with snapshot_scope="recent_rows" means the frame is a bounded sample, even when truncated=false. last_update is the retained price-row or source timestamp. It is not guaranteed to advance when an upstream poll sees an unchanged price. max_age_s defaults to 600 seconds, accepts values through 3600, and filters both the initial frame and later game-line updates against that timestamp. It does not make a recent-row sample complete or prove that an unchanged price is still offered upstream.
Complete current game-line baseline
To seed a complete current game-line board, explicitly name the books and request all three full-game markets: ?bookmakers=fanduel,draftkings,betmgm&kinds=game&markets=h2h,spreads,totals&limit=1000. WebSocket and SSE start from the same current-board read as REST, then apply the stream's max_age_s filter. Accept the result as a replacement baseline only when the frame has snapshot_scope="current_game_board", snapshot_complete=true, and truncated=false. Here, complete means complete after the requested filters. A requested bookmaker can therefore be absent when all of its retained rows exceed max_age_s; clear absent rows and books from your locally held sport/book/market scope. REST can still show older retained or topped-up rows when its request does not apply the same age bound, including rows older than the stream maximum of 3600 seconds.
A complete baseline reports resume_mode="replace". If since was supplied, it also reports since_honored=false, because the whole current board replaces cursor-filtered state. diff defaults to false and affects subsequent odds_update frames only; the initial frame always carries full rows.
For recent_rows, since is a best-effort retained-observation filter, not an exactly-once replay cursor or a lossless removal log. action="remove" appears when a source supplies a validated current-state retirement; historical removals are not guaranteed for legacy sources. Use the complete scope above, or take a REST baseline before merging live updates.
Moneyline row shapes and market keys
Combined game rows use home_ml, away_ml, and nullable draw_ml. A draw price is included when the source supplies one, including three-way h2h markets. A null or absent draw_ml does not prove that a draw is impossible. Per-outcome rows use the actual participant in team and its price in price; the draw participant is represented as team: "Draw". Consumers should collect the complete actual outcome set before normalizing implied probabilities. h2h alone cannot infer the arity because sports and books can differ.
The full-game moneyline spellings currently recognized by the stream are:
- Common or generic spellings:
moneyline,player_moneyline,h2h,money_line,exchange_money,game_winner,polymarket_moneyline. - Variable-arity sport-specific spellings:
bout_betting,fight_line. - Named three-way aliases:
3_way_moneyline,moneyline_3_way,player_3_way_moneyline,player_3-way_moneyline.
These are supported observed full-game moneyline spellings. Generic or book-specific stream labels can vary, and unknown extra fields or keys may appear as upstream coverage expands. For canonical market metadata, see /v1/meta/markets and /v1/meta/parser-coverage. Those endpoints describe canonical and observed coverage, but they are not an exhaustive registry of every raw book label.
odds_update frames + tier coalescing
Sent each time we detect a price change for any event in scope. The shape is the same as initial_state but typically with a much smaller data array, just the rows that changed.
{
"type": "odds_update",
"sport_key": "basketball_nba",
"timestamp": 1715587235,
"count": 4,
"data": [
{ "event_id": "...", "bookmaker": "draftkings", "market_key": "h2h",
"home_ml": -140, "away_ml": 120, "draw_ml": null, "last_update": "..." },
{ "event_id": "...", "bookmaker": "fanduel", "market_key": "h2h",
"team": "Boston Celtics", "price": -140, "last_update": "..." },
...
],
"coalesced": false
}
Tier-aware coalescing
Every connection picks up a server-side coalesce window based on its tier. If two price changes land within the window, they're merged into a single envelope with coalesced: true and a count reflecting the union. This controls flush frequency; it does not promise a history of every intermediate sportsbook price.
| Tier | min_push_interval_s | push_mode | Effect |
|---|---|---|---|
| Business | 1.0 | coalesced | At most one envelope per second, per connection. Multiple changes inside the window are merged. |
| Enterprise | 0.5 | coalesced | At most one envelope every 500 ms. |
| Scale | 0.0 | raw | No tier-imposed coalescing delay. Delivery still depends on eligible updates reaching the pipeline and the client connection. |
A minimum push interval is not a maximum delivery delay or a source refresh interval. A zero interval does not make the source observation newer. Compare row timestamps and measure client receipt times for your selected books and markets.
Filtering with subscribe
To narrow updates to a single event, send a subscribe frame after connect:
// client → server
{ "type": "subscribe", "event_id": "2026-05-13_Boston_Celtics_New_York_Knicks" }
// server → client
{ "type": "subscribed", "event_id": "...", "timestamp": 1715587212 }
From that point on, your connection only receives odds_update frames whose data rows match that event_id. To drop the filter:
{ "type": "unsubscribe" }
// → { "type": "unsubscribed", "timestamp": ... }
Subscribe is per-connection and persists for the life of the socket. To watch multiple events, open multiple connections (or stay unfiltered and filter client-side).
Heartbeat
WebSocket sends an application heartbeat about every 5 seconds. SSE sends one on the requested heartbeat_s cadence (default 5 seconds, allowed range 1–30). The cadence is independent of odds traffic, so an active stream still carries poll-freshness updates.
{
"type": "heartbeat",
"timestamp": 1715587260,
"bookmaker_freshness": [
{"bookmaker":"draftkings", "polled_at_ms":1715587259200, "poll_age_s":0.8, "status":"observed"},
{"bookmaker":"pinnacle", "polled_at_ms":1715587091000, "poll_age_s":169.0, "status":"observed"}
],
"bookmaker_freshness_note": "Per-book poll freshness for this sport ..."
}
On /ws/odds and /ws/live, when bookmakers= is present the array covers the accepted requested keys; an unknown key can report status="missing". A retired or suppressed key is rejected instead of being silently removed from a mixed filter. With no bookmaker filter the array is empty. The Scale-only /ws/odds-fast pilot does not currently accept query filters, so its array is empty. Poll freshness proves that the source poller observed the sport; it does not verify each retained quote. Keep each row's last_update as its price-age signal.
You don't need to respond to a heartbeat. WebSocket transport ping/pong remains separate.
Reconnect strategy
Recommended client behavior:
- Track
timestampfrom the last frame received. - If
(now_epoch - last_timestamp) > 90, treat the connection as broken and reconnect. - On reconnect, you'll get a fresh
connected+initial_state. Replace your local state frominitial_state; don't try to delta against your previous state. The snapshot is authoritative. - Use exponential backoff: 1 s, 2 s, 4 s, 8 s, capped at 30 s.
- If you hit close code
1008(invalid key) or4001(tier), don't retry, fix the key.
Close codes
| Code | Reason | Recoverable? |
|---|---|---|
| 1000 | Normal close (you disconnected cleanly). | , |
| 1001 | We're going away (deploy, restart). Reconnect after 5 s. | Yes |
| 1006 | Abnormal close (network hiccup). Reconnect with backoff. | Yes |
| 1008 | Invalid apiKey. Don't retry; the key string is wrong. | No |
| 1011 | Server error. Reconnect with backoff. | Yes |
| 4001 | Tier gate: your key is Free / Starter / Pro. Upgrade to Business+. Don't retry. | No |
| 4002 | Concurrent connection cap reached for your tier. Close idle connections, or upgrade. | Yes (after closing) |
| 4003 | Missing apiKey entirely. Add it as query param, header, or subprotocol. | No (fix client) |
| 4005 | The request is invalid or names a bookmaker that is not accepted by live streams. The reason names the rejected key; inspect /v1/bookmakers?all=true for its lifecycle state. | No (fix filter) |
Tier matrix
| Tier | WebSocket? | Concurrent conns / key | Coalesce window |
|---|---|---|---|
| Free | No (4001) | , | , |
| Starter | No (4001) | , | , |
| Pro | No (4001) | , | , |
| Business | Yes | 100 | 1.0 s (coalesced) |
| Enterprise | Yes | 1000 | 0.5 s (coalesced) |
| Scale | Yes | 1000 | 0.0 s (raw) |
Hitting the concurrent cap returns close code 4002 at handshake. The cap counts live WebSocket sockets and live SSE streams together.
Per-source provider state
Every /v1/* response (including the WebSocket upgrade response) carries an X-Provider-State header with machine-readable freshness + role for each ingested data source. Use it to decide on a per-request basis whether to trust a given source's rows, route around degraded sources, or weight your aggregate by freshness. Full spec at the Provider State docs; quick reference shape:
X-Provider-State: {"ts":1778739733,"src":{"pinnacle":{"age_s":1.2,"role":"primary"}, "fanduel":{"age_s":12.8,"role":"primary"}, "caesars":{"age_s":null,"role":"offline"}, ...}}
Untruncated payload also available at GET /v1/meta/provider-state.
Machine-readable spec
The full WebSocket and SSE protocol is described in an AsyncAPI 3 spec (/v1/asyncapi.json). Use it to generate typed SDKs in TypeScript, Python, Go, Java, or any of the 30+ AsyncAPI generator templates. Every message envelope (connected, initial_state, odds_update, heartbeat, subscribed, unsubscribed), the subscribe / unsubscribe client commands, the close-code matrix (1008, 4001, 4002, 4003), and the tier gate are documented there.
npm install -g @asyncapi/cli
asyncapi generate fromTemplate https://parlay-api.com/v1/asyncapi.json \
@asyncapi/python-paho-template -o ./parlay_ws_sdk
Companion to /openapi.json for the REST surface; OpenAPI doesn't model WebSocket message flows, AsyncAPI does.
SSE alternative
If your environment can't hold a WebSocket connection cleanly (corporate proxy, AWS Lambda, certain browser environments), we offer the same feed over Server-Sent Events:
GET /v1/sse/odds/{sport_key}?apiKey=YOUR_KEY
Accept: text/event-stream
Same envelopes, same tier coalescing, same event-filter via query param (&event_id=...). See the SSE deep-dive for the protocol details.