WebSocket Streaming
Push-based, sub-second 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
The WebSocket transport carries the same data as our REST endpoints, push-delivered. After connecting and authenticating, you receive a small connected envelope within ~1 ms, then an initial_state snapshot of every event currently in scope, then odds_update frames whenever any tracked price changes. We change-detect at the collector layer (88–90% skip rate), so the WebSocket pipe is silent on no-change cycles instead of flooding you with no-op frames.
Latency from book-side change to your client is typically 300–800 ms end-to-end on Scale tier (raw, uncoalesced), or up to the tier coalesce window (Business 1 s, Enterprise 0.5 s). We use Postgres LISTEN/NOTIFY internally so the broadcast pipe wakes within ~50 ms of a collector write, not on a poll tick.
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} | In-play / live-only markets for the requested sport_key. Higher-frequency updates during games; silent in pre-game windows. |
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 | Every 30 s if the client has been silent (no subscribe / unsubscribe). |
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",
"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.
initial_state snapshot
One frame, immediately after connected. Contains up to 500 of the most recent rows for the requested sport (a row = one outcome at one book in one market for one event).
{
"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",
"market_key": "h2h",
"outcome": "Boston Celtics",
"price_american": -135,
"price_decimal": 1.741,
"line": null,
"last_update": "2026-05-13T23:24:18Z"
},
...
]
}
Each row carries enough metadata to update your local state without a second REST call. last_update is per-row so you can judge freshness directly.
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",
"outcome": "Boston Celtics", "price_american": -140, "last_update": "..." },
{ "event_id": "...", "bookmaker": "fanduel", "market_key": "h2h",
"outcome": "Boston Celtics", "price_american": -142, "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. No data is dropped — only flush frequency is gated.
| 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 coalescing — every collector tick fires its own envelope as it lands. |
If you need the raw, uncoalesced firehose, you're on Scale. If you only need a 1 s resolution, Business is fine; the network and your downstream pipeline don't care about the 600 ms of micro-batching.
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
If your client is silent for 30 s, we send:
{ "type": "heartbeat", "timestamp": 1715587260, "connections": 1487 }
You don't need to respond to it; it's purely a keepalive so corporate proxies and mobile carriers don't kill an idle socket. Most WebSocket libraries also do a transport-layer ping/pong; ours runs on top of that as a defensive belt-and-suspenders.
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) |
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.
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.