WebSocket Streaming
Push-based, sub-second odds updates over WebSocket. Available on Pro tier and up. This page is the complete reference: connect URLs, auth shape, the snapshot-then-diff protocol, change-detection semantics, ping/pong cadence, and how to reconnect cleanly without losing state.
Overview
The WebSocket transport carries the same data as our REST endpoints, push-delivered. After connecting and authenticating, you receive one initial snapshot frame containing every event currently in scope. Then for as long as you stay connected, you receive update (diff) 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.
Connecting
There are two streams. Pick the one that matches your use case:
| URL | What it pushes |
|---|---|
wss://parlay-api.com/ws/odds/{sport_key} | All current and upcoming game-line markets (h2h / spread / total) for the requested sport_key, across every book we cover. |
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 one of the values from GET /v1/sports (e.g. baseball_mlb, basketball_nba, americanfootball_nfl, icehockey_nhl, soccer_epl, mma_mixed_martial_arts).
Authentication
Pass your API key as the apiKey query parameter on the connect URL. We do not currently accept the key as an Authorization header for WebSocket; browser WebSocket clients can't set custom headers, so the query-param shape works for both Node and browsers.
wss://parlay-api.com/ws/odds/baseball_mlb?apiKey=YOUR_KEY
Keys must belong to a Pro, Business, or Enterprise tier. Free and Starter tier requests will be rejected at the WebSocket handshake with HTTP 403 (see close codes).
Protocol
Every frame is a single JSON object on a single line. There is no framing format on top of WebSocket binary protocol. Parse each message event from your client as JSON.
Frames have a type field. The values you'll see:
| type | When sent |
|---|---|
snapshot | Once, immediately after a successful connect. Contains the full current state for the requested sport_key. |
update | Each time a tracked price changes. Contains only the affected event's new state (not a delta of fields, but a full event object). |
ping | Every 25 seconds while the connection is idle. You should reply with a pong frame within 30s or we close the connection. |
error | If we hit something we can't recover from in-band. Always followed by a connection close. |
Initial snapshot frame
Sent once, immediately after auth succeeds. Shape:
{
"type": "snapshot",
"sport_key": "baseball_mlb",
"as_of_ms": 1717003200000,
"events": [
{
"id": "abc123",
"home_team": "Boston Red Sox",
"away_team": "Houston Astros",
"commence_time": "2026-05-01T19:00:00Z",
"bookmakers": [
{
"key": "draftkings",
"title": "DraftKings",
"last_update": "2026-05-01T18:56:55Z",
"markets": [
{
"key": "h2h",
"last_update": "2026-05-01T18:56:55Z",
"outcomes": [
{ "name": "Boston Red Sox", "price": -126 },
{ "name": "Houston Astros", "price": +108 }
]
}
]
}
]
}
]
}
Each event is the same shape as REST /v1/sports/{sport_key}/odds returns. last_update is per-book per-market so you can judge freshness directly without a separate query.
Update (diff) frames
Sent each time we detect a price change for any event in scope. Shape:
{
"type": "update",
"sport_key": "baseball_mlb",
"as_of_ms": 1717003260000,
"event": {
"id": "abc123",
"home_team": "Boston Red Sox",
"away_team": "Houston Astros",
"commence_time": "2026-05-01T19:00:00Z",
"bookmakers": [
{
"key": "draftkings",
"title": "DraftKings",
"last_update": "2026-05-01T18:57:25Z",
"markets": [
{
"key": "h2h",
"last_update": "2026-05-01T18:57:25Z",
"outcomes": [
{ "name": "Boston Red Sox", "price": -130 },
{ "name": "Houston Astros", "price": +112 }
]
}
]
}
]
}
}
event object is the full event with the changed book, not a delta of fields. To merge into client state: replace the entire event by id in your local map. We considered field-level diffs and decided against; full-event-replace keeps client logic simple and the bandwidth cost is small (typically <2 KB per update frame).
Change detection
Our collector hashes every (event, book, market, outcome) tuple's price each cycle. Only price changes generate frames. This is why a quiet pre-game window can have minutes of no updates; that's not a missing-frame, it's nothing-changed.
If you need to know "did we lose anything" vs "is the market just quiet", look at as_of_ms on incoming frames vs the wall clock. We refresh the underlying poll every 30-60 seconds, so even on no-change cycles, the next live update will carry an as_of_ms within ~90 seconds of now. If you've gone over 5 minutes without any frame and there are upcoming games, your connection has likely dropped — see reconnect strategy.
Heartbeat / ping
We send a {"type": "ping", "ts_ms": 1717003200000} every 25 seconds while the connection is idle (no updates pushed during that window). You must reply with {"type": "pong", "ts_ms": <same value>} within 30 seconds. If you don't, we close with code 1011.
Most WebSocket libraries also do a transport-layer ping/pong; ours runs on top of that as a defensive belt-and-suspenders, mostly because some intermediaries (corporate proxies, mobile carriers) drop transport-layer pings.
Reconnect strategy
Recommended client behavior:
- Track
as_of_msfrom the last frame received. - If
(now - last_received_ms) > 60_000, treat the connection as broken and reconnect. - On reconnect, you'll get a fresh
snapshotframe. Replace your local state from it; don't try to delta against your previous state. The snapshot is authoritative. - Use exponential backoff on reconnect: 1s, 2s, 4s, 8s, capped at 30s.
- If you hit close code
1008(auth) or4001(tier), don't retry — fix the key.
Errors and close codes
| Code | Reason | Recoverable? |
|---|---|---|
| 1000 | Normal close (you disconnected cleanly). | — |
| 1001 | We're going away (deploy, restart). Reconnect after 5s. | Yes |
| 1006 | Abnormal close (network hiccup). Reconnect with backoff. | Yes |
| 1008 | Policy violation. Usually missing or invalid apiKey. Don't retry. | No |
| 1011 | Server error. Could be ping/pong miss or internal failure. Reconnect with backoff. | Yes |
| 4001 | Tier gate: your API key tier doesn't include WebSocket. Upgrade to Pro+. Don't retry. | No |
| 4002 | Sport not supported on the requested stream. Check /v1/sports. | No |
| 4003 | Rate limit hit (more than 10 simultaneous WebSocket connections per key). Close some, then reconnect. | Yes (after closing others) |
Examples
Node.js (ws)
import WebSocket from "ws";
const KEY = process.env.PARLAY_API_KEY;
let lastFrameMs = Date.now();
let reconnectDelay = 1000;
function connect() {
const ws = new WebSocket(
`wss://parlay-api.com/ws/odds/baseball_mlb?apiKey=${KEY}`
);
ws.on("open", () => {
console.log("connected");
reconnectDelay = 1000;
});
ws.on("message", (raw) => {
lastFrameMs = Date.now();
const frame = JSON.parse(raw.toString());
if (frame.type === "ping") {
ws.send(JSON.stringify({ type: "pong", ts_ms: frame.ts_ms }));
} else if (frame.type === "snapshot") {
console.log(`snapshot: ${frame.events.length} events`);
} else if (frame.type === "update") {
console.log(`update: ${frame.event.home_team} vs ${frame.event.away_team}`);
} else if (frame.type === "error") {
console.error("error frame:", frame);
}
});
ws.on("close", (code) => {
if (code === 1008 || code === 4001 || code === 4002) {
console.error(`close ${code} — not retrying`);
return;
}
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
});
// Watchdog: reconnect if no frames for 60s
setInterval(() => {
if (Date.now() - lastFrameMs > 60_000 && ws.readyState === WebSocket.OPEN) {
ws.close();
}
}, 10_000);
}
connect();
Python (websockets)
import asyncio
import json
import os
import websockets
KEY = os.environ["PARLAY_API_KEY"]
URL = f"wss://parlay-api.com/ws/odds/baseball_mlb?apiKey={KEY}"
async def consume():
delay = 1
while True:
try:
async with websockets.connect(URL, ping_interval=None) as ws:
delay = 1
async for raw in ws:
frame = json.loads(raw)
if frame["type"] == "ping":
await ws.send(json.dumps({"type": "pong", "ts_ms": frame["ts_ms"]}))
elif frame["type"] == "snapshot":
print(f"snapshot: {len(frame['events'])} events")
elif frame["type"] == "update":
e = frame["event"]
print(f"update: {e['home_team']} vs {e['away_team']}")
elif frame["type"] == "error":
print("error:", frame)
except Exception as e:
print(f"reconnect after error: {e}")
await asyncio.sleep(delay)
delay = min(delay * 2, 30)
asyncio.run(consume())
REST vs WebSocket
Same data, different transport. Use REST when you need a one-shot snapshot or you're already in a polling loop. Use WebSocket when you want sub-second push delivery and you can manage a long-lived connection. Typical usage:
- Arb scanner that triggers on edge thresholds: WebSocket. Polling will miss windows.
- Daily dashboard / nightly backtest: REST. The connection overhead isn't worth it.
- Discord bot pushing edge alerts: WebSocket. Latency matters.
- One-off lookups inside a longer pipeline: REST.
Both come from the same collector pipeline, so the data agrees. There's no "WebSocket has fresher data" effect.
Limits and tier gating
| Tier | WebSocket? | Concurrent connections / key |
|---|---|---|
| Free | No | — |
| Starter | No | — |
| Pro ($99/mo) | Yes | 3 |
| Business ($499/mo) | Yes | 10 |
| Enterprise ($2,499/mo) | Yes | 50 |
Each open connection counts as 1 toward your tier's REST request rate-limit budget every minute (so a 24/7 connection consumes 1,440 requests/day worth of credits). Most users find this is dramatically cheaper than equivalent polling.