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

URLWhat 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_keyCoverage
baseball_mlbMLB regular + playoffs, game lines + 40+ player prop markets
basketball_nbaNBA regular + playoffs, game lines + 60+ player prop markets
basketball_wnbaWNBA game lines + player props
icehockey_nhlNHL game lines + player props (SOG, PPP, etc.)
americanfootball_nflNFL game lines + player props
soccer_epl et al.EPL, La Liga, MLS, UCL, Bundesliga, etc.
mma_mixed_martial_artsUFC + other MMA cards
tennis_atp / tennis_wtaTennis 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.

FormWhereUse it when
Query param?apiKey=YOUR_KEY on the URLBrowser WebSocket client (can't set custom headers), simple Node / Python clients, curl-style testing.
X-API-Key headerX-API-Key: YOUR_KEYServer-side clients (Node, Python websockets, Go gorilla/websocket) where you'd rather keep the key out of URL logs.
Sec-WebSocket-ProtocolSubprotocol 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
Keys must belong to a Business, Enterprise, or Scale tier. Free, Starter, and Pro keys are rejected at handshake with close code 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:

typeDirectionWhen sent
connectedserver → clientImmediately after accept. Tells you your tier, the coalesce window, and that the snapshot is coming.
initial_stateserver → clientOne snapshot frame containing up to 500 current rows for the requested sport.
odds_updateserver → clientEach time we detect a price change for any event in scope.
heartbeatserver → clientEvery 30 s if the client has been silent (no subscribe / unsubscribe).
subscribeclient → serverFilter all subsequent updates to a single event_id.
unsubscribeclient → serverDrop the event filter; resume receiving all events.
subscribed / unsubscribedserver → clientAck 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.

Tiermin_push_interval_spush_modeEffect
Business1.0coalescedAt most one envelope per second, per connection. Multiple changes inside the window are merged.
Enterprise0.5coalescedAt most one envelope every 500 ms.
Scale0.0rawNo 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:

Close codes

CodeReasonRecoverable?
1000Normal close (you disconnected cleanly).
1001We're going away (deploy, restart). Reconnect after 5 s.Yes
1006Abnormal close (network hiccup). Reconnect with backoff.Yes
1008Invalid apiKey. Don't retry; the key string is wrong.No
1011Server error. Reconnect with backoff.Yes
4001Tier gate: your key is Free / Starter / Pro. Upgrade to Business+. Don't retry.No
4002Concurrent connection cap reached for your tier. Close idle connections, or upgrade.Yes (after closing)
4003Missing apiKey entirely. Add it as query param, header, or subprotocol.No (fix client)

Tier matrix

TierWebSocket?Concurrent conns / keyCoalesce window
FreeNo (4001)
StarterNo (4001)
ProNo (4001)
BusinessYes1001.0 s (coalesced)
EnterpriseYes10000.5 s (coalesced)
ScaleYes10000.0 s (raw)

Hitting the concurrent cap returns close code 4002 at handshake. The cap counts live WebSocket sockets and live SSE streams together.

One connection per (sport_key, stream) pair. If you need MLB game lines and MLB live both, that's 2 connections. If you need NBA + NFL game lines, that's 2 connections. Don't multiplex multiple sports through one connection — the protocol assumes one sport per socket.

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.