Streaming: SSE and WebSocket
Two transports for real-time data. Pick the one that fits your stack. SSE is simpler and works through corporate proxies; WebSocket is bidirectional and lets you filter to a single game after connecting.
Comparison
| SSE | WebSocket | |
|---|---|---|
| Tier | Starter+ ($5/mo) for PBP, Business+ ($40/mo) for odds | Business+ ($40/mo) |
| Direction | Server-to-client only | Bidirectional |
| Filtering | Set via URL query string | Send {"type":"subscribe","event_id":...} after connect |
| Browser native | Yes (EventSource) | Yes (WebSocket) |
| Through corporate proxies | Yes, plain HTTP | Sometimes blocked |
| Reconnect logic | Browser auto-reconnects | You implement |
| Latency | ~1s from DB write to client | ~1s from DB write to client |
SSE endpoints
GET /v1/sports/{sport_key}/live/sse?apiKey=YOUR_KEY # PBP events
GET /v1/sse/odds/{sport_key}?apiKey=YOUR_KEY # Odds updates
GET /v1/sse/hot/{sport_key}?apiKey=YOUR_KEY # Same as /odds, alt name
GET /v1/odds-drop/{sport_key}?apiKey=YOUR_KEY # Line-move alerts (one sport)
GET /v1/odds-drop/all?apiKey=YOUR_KEY # Line-move alerts (every active sport)
Drop-alert stream (/v1/odds-drop)
Filters the raw odds stream to only emit events when a tracked price moves by >= a configured threshold. Useful for arb scanners, +EV monitors, and line-shopping bots that need server-side line-move detection rather than maintaining their own previous-price state on every (event, book, side) tuple.
Required tier: Business+ ($40/mo). Same as /v1/sse/odds.
Endpoints (single-sport vs full-firehose):
GET /v1/odds-drop/{sport_key}?apiKey=YOUR_KEY&threshold=10&direction=both
GET /v1/odds-drop/all?apiKey=YOUR_KEY&threshold=10
GET /v1/odds-drop/multi?apiKey=YOUR_KEY&sports=baseball_mlb,basketball_nba,soccer_epl
Use the /all form when you want every drop from every sport in one
connection (currently subscribes to ~300 active sport_keys, refreshes the
universe every 5 minutes). Use /multi?sports=... for a custom subset.
Drops emitted by the multi-sport endpoint include a sport_key field on
every event so you can route them downstream.
Query params:
threshold— integer 1-200, minimum American-odds delta to trigger an event. Default10(a -110 becoming -120 fires; a -110 becoming -115 does not). A move from +120 to +110 also has delta=-10 and counts.direction—both|toward_favorite|toward_dog. Defaultboth.toward_favoritemeans the price moved in the direction that reduces the implied dog payout (e.g. -110 to -120, OR +120 to +110, both qualify). Useful for filtering to sharp-money line moves only.event_id— optional, narrow to a single gamebookmakers— comma-separated books, e.g.pinnacle,fanduelmarkets— comma-separated market keys, e.g.player_points,h2hheartbeat_s— 1-30, default 5
Event shape for a triggered drop:
data: {
"type": "odds_drop",
"event_id": "2026-05-12_Lakers_Warriors",
"bookmaker": "pinnacle",
"side": "h2h_home",
"kind": "game",
"prev": -110,
"new": -120,
"delta": -10,
"direction": "toward_favorite",
"home_team": "Los Angeles Lakers",
"away_team": "Golden State Warriors",
"commence_time": "2026-05-12T22:30:00Z",
"last_update": 1747000000123,
"timestamp": 1747000000124
}
For player-prop drops the event also carries player, market_key, market, and line fields. side values follow the schema:
- Game lines:
h2h_home,h2h_away,[email protected],[email protected],[email protected],[email protected] - Props:
{market_key}:{player}:over@{line},{market_key}:{player}:under@{line}
Behavior notes:
- The first observation of each (event_id, bookmaker, side) tuple is recorded silently. No emit until the next price change crosses the threshold. This means a freshly-connected client takes 1-3 seconds to "prime" before drops start arriving.
- Per-connection state is local and resets on reconnect. If you reconnect frequently you re-prime each time.
- Pulse-stamped freshness via
last_updateon every event, separate from the SSEtimestampfield (which is when we pushed the event to your socket).
Coverage by book and market
Drop tracking depends on whether we collect the per-side price for each market. Today's coverage:
| Source style | Books | h2h / moneyline | spread | total | player props |
|---|---|---|---|---|---|
| Per-market feed | pinnacle, bet365, betmgm, betrivers, bovada, underdog, kalshi, novig, fanatics, caesars, betr, fliff | Yes | Yes | Yes | Yes |
| Game-line feed (line-only spreads / totals) | fanduel, draftkings, bwin, unibet, pmu, betclic | Yes | No (line stored, no per-side price) | No (line stored, no per-side price) | Per-book, see prop feed |
What this means in practice: a Pinnacle moneyline move from -110 to -120 fires a drop. A Pinnacle spread move from -1.5 (-105) to -1.5 (-110) also fires a drop now, since we ingest the per-side spread and total prices directly from Pinnacle's straight-markets endpoint.
FanDuel / DraftKings / European-region books on the game-line feed still only fire moneyline drops; spread and total drops for those books require pulling each side's price into the per-market feed (planned).
Note: Pinnacle is famously sharp and doesn't move much. A 60-second stretch with zero Pinnacle drops is normal. Sharp moves on Pinnacle, when they happen, are exactly the signal arb / +EV scanners care about.
SSE example: Python (drop alerts)
import requests, json
from sseclient import SSEClient
url = "https://parlay-api.com/v1/odds-drop/basketball_nba"
params = {"apiKey": "YOUR_KEY", "threshold": 10, "bookmakers": "pinnacle"}
resp = requests.get(url, params=params, stream=True)
for ev in SSEClient(resp).events():
if not ev.data: continue
drop = json.loads(ev.data)
if drop.get("type") != "odds_drop": continue
print(f"{drop['bookmaker']} {drop['side']}: {drop['prev']} -> {drop['new']} "
f"({drop['direction']})")
Optional query params:
match_id=...— subscribe to a single gameevent_id=...— same purpose for the odds streambookmakers=draftkings,fanduel— filter to specific booksmarkets=h2h,player_points— filter to specific market keyskinds=game,prop— filter to game-line vs prop rowsheartbeat_s=5— heartbeat interval (1-30, default 5)
SSE example: Python
import httpx, json
with httpx.stream("GET",
"https://parlay-api.com/v1/sports/basketball_nba/live/sse",
params={"apiKey": "YOUR_KEY"}, timeout=None) as r:
for line in r.iter_lines():
if line.startswith("data: "):
evt = json.loads(line[6:])
print(evt)
SSE example: JavaScript
const es = new EventSource(
"https://parlay-api.com/v1/sports/basketball_nba/live/sse?apiKey=YOUR_KEY"
);
es.addEventListener("pbp_event", (e) => {
const evt = JSON.parse(e.data);
console.log(evt.event_type, evt.team_or_player_a, evt.score_a);
});
WebSocket endpoints
wss://parlay-api.com/ws/odds/{sport_key}?apiKey=YOUR_KEY # Odds + props
wss://parlay-api.com/ws/live/{sport_key} # Session-cookie auth (dashboard)
WebSocket example: Python
import asyncio, json, websockets
async def main():
url = "wss://parlay-api.com/ws/odds/basketball_nba?apiKey=YOUR_KEY"
async with websockets.connect(url) as ws:
# Optional: subscribe to one game
await ws.send(json.dumps({"type": "subscribe", "event_id": "..."}))
async for msg in ws:
print(json.loads(msg))
asyncio.run(main())
WebSocket example: JavaScript
const ws = new WebSocket(
"wss://parlay-api.com/ws/odds/basketball_nba?apiKey=YOUR_KEY"
);
ws.onmessage = (e) => {
const evt = JSON.parse(e.data);
console.log(evt);
};
ws.onopen = () => {
// Optional: subscribe to one game
ws.send(JSON.stringify({"type": "subscribe", "event_id": "..."}));
};
Event payload shape
PBP events (from /v1/sports/{sport}/live/sse):
{
"id": 12345,
"match_id": "0042500222",
"sport_key": "basketball_nba",
"source": "nba_live",
"role": "primary",
"event_type": "made_3",
"team_or_player_a": "Lakers",
"team_or_player_b": "Thunder",
"score_a": "107",
"score_b": "125",
"description": "C. Holmgren 3PT JUMPER from 26ft",
"state": {"period": 4, "clock": "PT2:14"},
"occurred_at_ms": 1778214012700,
"captured_at_ms": 1778214028111
}
Concurrent connection caps
| Tier | Max concurrent SSE+WS per key |
|---|---|
| Free | 1 (polling only, SSE not allowed) |
| Starter | 3 |
| Pro | 25 |
| Business | 100 |
| Enterprise / Scale | 1000 |
Hit the cap and a new SSE connection returns 429; a new WS connection closes with code 4002. Existing connections aren’t affected.
Reconnection strategy
Both SSE and WS connections can drop on network blips. Recommended approach:
- SSE: browser
EventSourcereconnects automatically. For server-side clients, retry on disconnect with exponential backoff (1s, 2s, 4s, 8s, capped at 30s). - WebSocket: same pattern. The
parlay-apiSDK handles this for you. - On reconnect, re-fetch the current state via
/v1/sports/{sport}/live/pointsso you don’t miss any events between disconnect and reconnect.
Source-freshness diagnostic
If you want to know your data is fresh and a source hasn’t silently gone stale, poll GET /v1/sports/{sport_key}/live/source-health every 30 seconds. Returns per-source freshness; alert when seconds_since_last_event > 60 on a source you depend on during a live game window.