ParlayAPI · Docs · Webhooks

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

SSEWebSocket
TierStarter+ ($5/mo) for PBP, Business+ ($40/mo) for oddsBusiness+ ($40/mo)
DirectionServer-to-client onlyBidirectional
FilteringSet via URL query stringSend {"type":"subscribe","event_id":...} after connect
Browser nativeYes (EventSource)Yes (WebSocket)
Through corporate proxiesYes, plain HTTPSometimes blocked
Reconnect logicBrowser auto-reconnectsYou 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:

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:

Behavior notes:

Coverage by book and market

Drop tracking depends on whether we collect the per-side price for each market. Today's coverage:

Source styleBooksh2h / moneylinespreadtotalplayer props
Per-market feedpinnacle, bet365, betmgm, betrivers, bovada, underdog, kalshi, novig, fanatics, caesars, betr, fliffYesYesYesYes
Game-line feed (line-only spreads / totals)fanduel, draftkings, bwin, unibet, pmu, betclicYesNo (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:

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

TierMax concurrent SSE+WS per key
Free1 (polling only, SSE not allowed)
Starter3
Pro25
Business100
Enterprise / Scale1000

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:

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.