Reference clients available. Production-grade WebSocket clients with the correct silent-watchdog, capped exponential backoff, and upstream-aware quiet handling are at examples/ws_reference_client.py and examples/ws_reference_client.js. The reconnect cookbook covering frame shapes, the three causes of socket silence, and common anti-patterns is at WebSocket reconnect cookbook.

Server-Sent Events (SSE)

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.

WS reference Quickstart Examples Player props Edge alerts SSE Troubleshooting

When to pick SSE over WebSocket

SituationPick
Long-lived backend worker, full control of the network stackWebSocket
Behind a corporate proxy that drops UpgradeSSE
Browser front-end, want zero-dep, willing to give up client->server messagesSSE (uses native EventSource)
Serverless function (Lambda, Cloud Run) for a 30-90 s scanSSE
Need to send subscribe/unsubscribe frames after connectWebSocket (SSE is server-to-client only, filter via query param at connect)

Endpoints

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.

Auth (same three forms as WebSocket)

FormWhere
Query param?apiKey=YOUR_KEY on the URL (browser EventSource can't set headers)
X-API-Key headerServer-side clients (curl, Python httpx, Node eventsource)
Authorization BearerAuthorization: Bearer YOUR_KEY: accepted alias

Optional query params

ParamTypeDescription
event_idstringFilter to a single event for the life of the stream. SSE has no subscribe message, so the filter is fixed at connect time.
marketscsvOnly push rows for these markets. h2h,player_points.
kindscsvFilter by kind: game,props,live.
heartbeat_sint 1..30Heartbeat cadence. Default 5 s. Match this to your proxy's keep-alive minimum.

Frame format

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":[...]}

A partial initial_state

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:

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, an initial_state with count:0, partial:true and missing_books means unavailable source data, not an empty market. Branch on partial before rendering "no lines". A deliberately suppressed source instead rejects a new subscription with a source-suppressed validation error; it is not promised automatic resumption.

Browser EventSource

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);

Node, eventsource library

// 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);

Python, httpx streaming

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 smoke test

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.

Heartbeats

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.

Reconnect

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.

Tier coalescing applies to SSE too

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.

Limitations vs WebSocket