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:

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

typeWhen sent
snapshotOnce, immediately after a successful connect. Contains the full current state for the requested sport_key.
updateEach time a tracked price changes. Contains only the affected event's new state (not a delta of fields, but a full event object).
pingEvery 25 seconds while the connection is idle. You should reply with a pong frame within 30s or we close the connection.
errorIf 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 }
            ]
          }
        ]
      }
    ]
  }
}
Important: the 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:

Errors and close codes

CodeReasonRecoverable?
1000Normal close (you disconnected cleanly).
1001We're going away (deploy, restart). Reconnect after 5s.Yes
1006Abnormal close (network hiccup). Reconnect with backoff.Yes
1008Policy violation. Usually missing or invalid apiKey. Don't retry.No
1011Server error. Could be ping/pong miss or internal failure. Reconnect with backoff.Yes
4001Tier gate: your API key tier doesn't include WebSocket. Upgrade to Pro+. Don't retry.No
4002Sport not supported on the requested stream. Check /v1/sports.No
4003Rate 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:

Both come from the same collector pipeline, so the data agrees. There's no "WebSocket has fresher data" effect.

Limits and tier gating

TierWebSocket?Concurrent connections / key
FreeNo
StarterNo
Pro ($99/mo)Yes3
Business ($499/mo)Yes10
Enterprise ($2,499/mo)Yes50

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.

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