The same odds feed that flows over WebSocket, delivered as plain HTTP text/event-stream. Use SSE when a long-lived WebSocket isn't an option: corporate proxies that don't pass Upgrade, browser-only with built-in EventSource, or short-window serverless scans.
| Situation | Pick |
|---|---|
| Long-lived backend worker, full control of the network stack | WebSocket |
| Behind a corporate proxy that drops Upgrade | SSE |
| Browser front-end, want zero-dep, willing to give up client->server messages | SSE (uses native EventSource) |
| Serverless function (Lambda, Cloud Run) for a 30-90 s scan | SSE |
| Need to send subscribe/unsubscribe frames after connect | WebSocket (SSE is server-to-client only, filter via query param at connect) |
GET /v1/sse/odds/{sport_key}?apiKey=YOUR_KEY
GET /v1/sse/hot/{sport_key}?apiKey=YOUR_KEY # same feed, hot-path branding for marketing
GET /v1/odds-drop/{sport_key}?apiKey=YOUR_KEY # alt URL for compatibility
Accept: text/event-stream
Same tier gate as WebSocket: Business / Enterprise / Scale only. Same concurrent connection cap counts both WS and SSE together.
| Form | Where |
|---|---|
| Query param | ?apiKey=YOUR_KEY on the URL (browser EventSource can't set headers) |
| X-API-Key header | Server-side clients (curl, Python httpx, Node eventsource) |
| Authorization Bearer | Authorization: Bearer YOUR_KEY: accepted alias |
| Param | Type | Description |
|---|---|---|
event_id | string | Filter to a single event for the life of the stream. SSE has no subscribe message, so the filter is fixed at connect time. |
bookmakers | csv | Only push rows from these books. pinnacle,novig,fanduel. |
markets | csv | Only push rows for these markets. h2h,player_points. |
kinds | csv | Filter by kind: game,props,live. |
heartbeat_s | int 1..30 | Heartbeat cadence. Default 5 s. Match this to your proxy's keep-alive minimum. |
Each frame is a single JSON object on a single line, sent as the data: field of an SSE event. The contents match the WebSocket protocol verbatim, same type discriminator, same row shape.
: heartbeat at 1715587200 ← SSE comment line, ignored by parsers
event: connected
data: {"type":"connected","sport_key":"basketball_nba","tier":"scale",
"min_push_interval_s":0.0,"push_mode":"raw","transport":"sse",
"timestamp":1715587200}
event: initial_state
data: {"type":"initial_state","sport_key":"basketball_nba","count":487,
"timestamp":1715587200,"data":[...]}
event: odds_update
data: {"type":"odds_update","sport_key":"basketball_nba","count":4,
"timestamp":1715587235,"data":[...]}
If one requested bookmaker's snapshot cannot be built in time, the stream is
served without that book rather than refused. The initial_state
event then carries three extra fields, and the connection stays open:
event: initial_state
data: {"type":"initial_state","sport_key":"basketball_nba","count":412,
"partial":true,
"partial_reason":"pinnacle_current_unavailable",
"missing_books":["pinnacle"],
"truncated":true,"timestamp":1715587200,"data":[...]}
Rows for a book listed in missing_books are never substituted from
another source: an absent price is safer than a stale one presented as live. Keep
streaming. When the missing book is attached, a fresh initial_state
arrives without partial; treat it as authoritative and replace
your local snapshot with it, exactly as you would on reconnect.
The same fields appear on the WebSocket initial_state frame. A
stream_closed event with
reason:"current_state_unavailable" now means no snapshot path at all
could be built, which is a reconnect, not a partial.
If the missing book is the only one you asked for (for example
bookmakers=pinnacle), the frame arrives with count:0 and
an empty data array. That is not an empty market: partial,
missing_books and pinnacle_resume:"retrying" together say
the board is empty because the book is temporarily unavailable, and the stream is
still open and still trying. Branch on partial before you render
"no lines".
const KEY = "pk_live_xxxx"; // ⚠️ proxy through your backend, don't ship the key
const es = new EventSource(`/v1/sse/odds/basketball_nba?apiKey=${KEY}`);
es.addEventListener("connected", (ev) => {
const f = JSON.parse(ev.data);
console.log("connected:", f.tier, f.push_mode);
});
es.addEventListener("initial_state", (ev) => {
const f = JSON.parse(ev.data);
console.log("snapshot:", f.count, "rows");
});
es.addEventListener("odds_update", (ev) => {
const f = JSON.parse(ev.data);
console.log("update:", f.count, "rows");
});
es.onerror = (e) => console.error("sse error", e);
// npm install eventsource
import EventSource from "eventsource";
const KEY = process.env.PARLAY_API_KEY;
const URL = `https://parlay-api.com/v1/sse/odds/basketball_nba`;
const es = new EventSource(URL, { headers: { "X-API-Key": KEY } });
es.addEventListener("odds_update", (ev) => {
const f = JSON.parse(ev.data);
for (const row of f.data) {
console.log(row.bookmaker, row.outcome, row.price_american);
}
});
es.onerror = (e) => console.error("error", e);
import httpx, json, os
KEY = os.environ["PARLAY_API_KEY"]
URL = "https://parlay-api.com/v1/sse/odds/basketball_nba"
with httpx.stream("GET", URL, headers={"X-API-Key": KEY, "Accept": "text/event-stream"}, timeout=None) as r:
event_type = None
for line in r.iter_lines():
if line.startswith("event: "):
event_type = line[7:].strip()
elif line.startswith("data: "):
frame = json.loads(line[6:])
if event_type == "odds_update":
print(f"{frame['count']} updates")
elif line == "":
event_type = None
curl --no-buffer \
--header "X-API-Key: $PARLAY_API_KEY" \
--header "Accept: text/event-stream" \
"https://parlay-api.com/v1/sse/odds/basketball_nba"
You should see the heartbeat comment, then the connected event, then initial_state, then odds_update as prices change. Ctrl-C to stop.
SSE intermediaries (proxies, CDNs, load balancers) tend to drop "silent" connections more aggressively than WebSocket. We send an SSE-comment heartbeat every heartbeat_s seconds (default 5) to keep the path alive:
: heartbeat 1715587205
Comments start with : and are ignored by all spec-compliant SSE parsers, you don't have to handle them in code. They're purely there so your proxy / CDN sees activity on the socket.
Browser EventSource reconnects automatically with a 3 s delay by default. Server-side clients should implement the same: on disconnect, wait 1-3 s, reopen the GET.
The Last-Event-ID header is part of the SSE spec but we don't currently use it for replay, on reconnect you'll get a fresh initial_state with the current snapshot. Treat that as authoritative and discard any local cache from the previous session.
Same as WebSocket: Business 1 s coalesce, Enterprise 0.5 s, Scale 0 s (raw). The push_mode field on the connected envelope tells you which one your key gets. See the WebSocket reference for the full coalesce behavior, SSE inherits it.
event_id at connect time as a query param. To change the filter, close and reconnect with the new value.event: + data: prefix (~30 bytes); negligible for our payload sizes (~500-5000 bytes).