Stream Live Odds in Python: SSE + WebSocket

Stream live odds in Python: subscribe to Pinnacle SSE drop alerts and the /ws/feed WebSocket with reconnects, plus pip install pinnodds. Real copy-paste code.
Short version: if you want Pinnacle odds pushed to a Python process the instant they move — not polled on a five-second timer — you have two streams to choose from. /odds-drop gives you lean SSE drop alerts you read with httpx or sseclient; /ws/feed gives you the raw WebSocket passthrough you read with the websockets library. Both are on the paid push tier, both need a reconnect policy, and pip install pinnodds wraps the SSE half so you don't hand-write it.
This is the companion to the REST guide. That one covered asking for the current state of a market; this one is about being told when it changes. If you're still writing while True: requests.get(...); time.sleep(5), this is the post that gets you off it.
Two streams, two jobs
Before any code, get the shapes straight — picking the wrong stream is how people waste an afternoon.
SSE at /odds-drop (live) and /odds-drop-prematch (prematch) is a curated firehose of drop events only. When a market's price falls against its recent history, one compact JSON object arrives. It carries sect (the market), outcome (the moving selection), from_price, to_price, id (the event), limit (Pinnacle's max stake on that line) and nvp (the no-vig fair price). It does not carry a drop percentage. This is the stream you want if your product is alerting: steam moves, closing-line drift, a bot that fires when a sharp line moves before the soft books catch up.
WebSocket at /ws/feed is the raw Pinnacle passthrough — the full market frames, unfiltered, exactly as they come off the upstream feed. Every price change on every market you're subscribed to, not just the drops. Heavier, chattier, and you do the parsing. This is the stream you want if you're maintaining a live picture of the whole board in memory, not reacting to a single signal.
Rule of thumb: SSE when you want events, WebSocket when you want state. Most people who think they need the WebSocket actually want the SSE drop stream and a periodic REST snapshot.
Auth, and the one gotcha
One API key does everything. On REST it's a header, x-api-key: YOUR_KEY. On the streams it goes in the query string, ?key=YOUR_KEY — because neither EventSource-style SSE nor browser WebSocket clients can set custom headers, so the key rides the URL. Same key for both, and it's your login too. Keep it out of source; read it from the environment.
pip install pinnodds httpx websockets
export PINNODDS_KEY="your_key_here"
The honest caveat up front: push is a paid tier. SSE and the WebSocket feed start at Pro + SSE ($149/mo), with Scale at $229/mo. The entry Pro plan ($99/mo) is REST only — on that plan the correct architecture is polling /api/drops, not streaming, and the precomputed drop_pct there is a genuine convenience. If your design is stream-first, budget for the push tier from the start. See pricing.
Streaming SSE drops with httpx
You don't need a special library — SSE is a plain-text text/event-stream over a long-lived HTTP GET. httpx streams it fine, and doing it by hand makes the reconnect logic explicit, which you need anyway.
import os, json, time, logging
import httpx
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("odds")
KEY = os.environ["PINNODDS_KEY"]
URL = f"https://pinnodds.com/odds-drop?key={KEY}"
def drops():
"""Yield each SSE drop event as a dict, reconnecting with backoff."""
backoff = 1
while True:
try:
with httpx.stream("GET", URL, timeout=None) as r:
r.raise_for_status()
for line in r.iter_lines():
if not line.startswith("data:"):
continue # skip comments / event: lines
backoff = 1 # a real message = healthy
payload = line[len("data:"):].strip()
if payload:
yield json.loads(payload)
except (httpx.HTTPError, json.JSONDecodeError) as e:
log.warning("sse dropped (%s); reconnect in %ds", e, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, 60)
for d in drops():
# sect = market, outcome = moving selection
# nvp = no-vig fair price, limit = Pinnacle max stake, NO drop_pct
log.info(
"%s %s %s: %s -> %s (nvp=%s limit=%s)",
d["id"], d["sect"], d["outcome"],
d["from_price"], d["to_price"], d.get("nvp"), d.get("limit"),
)
Two details are load-bearing. The backoff resets to 1 only after a message actually arrives, so a server that accepts the connection and immediately closes doesn't get hammered in a tight loop. And every drop-alert line is one self-contained JSON object — pinnodds sends single-line data: frames, so you don't need a multi-line SSE buffer here, though the if line.startswith("data:") guard keeps you safe against comment keepalives.
For prematch, point the same code at /odds-drop-prematch. Everything downstream is identical.
Prefer a library? sseclient collapses the parse:
import os, json, requests, sseclient
KEY = os.environ["PINNODDS_KEY"]
resp = requests.get(f"https://pinnodds.com/odds-drop?key={KEY}", stream=True)
for event in sseclient.SSEClient(resp).events():
d = json.loads(event.data)
print(d["sect"], d["outcome"], d["from_price"], "->", d["to_price"])
It's tidier, but you still own the reconnect loop around it — sseclient won't rebuild a dead connection for you, and it certainly won't close the replay gap. Which is the next thing.
The SSE + REST reconciliation you can't skip
A dropped stream is never replayed. Whatever moved during the gap between disconnect and reconnect is simply gone — SSE has no backfill. In a toy script that's fine. In anything that decides money, it's a silent data-loss bug.
The fix is the same discipline as the REST guide: stream for speed, REST for truth. On boot and after every reconnect, pull /api/drops to rebuild your picture, then let the stream take over. /api/drops?mode=live|prematch is the enriched form of the same event — market, designation, from, to, event_id, and the precomputed drop_pct that SSE omits. Treat the two as two views of one event, keyed on id / event_id.
import os, httpx
KEY = os.environ["PINNODDS_KEY"]
def rest_drops(mode="live", max_age_sec=120):
"""Enriched drop rows — includes drop_pct that SSE does not send."""
r = httpx.get(
"https://pinnodds.com/api/drops",
params={"mode": mode, "max_age_sec": max_age_sec},
headers={"x-api-key": KEY}, # REST = header, not ?key=
timeout=15,
)
r.raise_for_status()
return r.json() # rows: market, designation, from, to, event_id, drop_pct
Note the field-name shift between the two surfaces — sect ↔ market, outcome ↔ designation, from_price ↔ from, id ↔ event_id. If you consume both, normalise to one dict shape at the ingest boundary and never think about it again. (The Python REST guide has a drop-in normalise() helper for exactly this.)
The raw WebSocket passthrough
When you want the whole board pushed rather than only drops, connect to /ws/feed with the websockets library. This is the raw Pinnacle passthrough — you receive the market frames as they come off the upstream feed, so the payload is heavier and the shape is Pinnacle's, not a curated alert. websockets.connect as an async iterator handles reconnection for you, which is most of the boilerplate gone.
import os, json, asyncio, logging
import websockets
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
log = logging.getLogger("feed")
KEY = os.environ["PINNODDS_KEY"]
URL = f"wss://pinnodds.com/ws/feed?key={KEY}"
async def consume():
# async-for over connect() auto-reconnects on a dropped socket
async for ws in websockets.connect(URL, ping_interval=20, max_size=None):
try:
async for raw in ws:
frame = json.loads(raw)
# Raw Pinnacle passthrough: full market frame, not a curated
# drop. Shape is upstream's — inspect a few frames before you
# commit to field names, and expect chunky payloads.
log.info("frame: %d bytes", len(raw))
except websockets.ConnectionClosed:
log.warning("ws closed; reconnecting")
continue
if __name__ == "__main__":
asyncio.run(consume())
Two settings earn their place. ping_interval=20 keeps the connection warm through proxies and CDNs that would otherwise reap an idle socket. max_size=None lifts the default 1 MB frame cap — prematch board frames are large, and the default will raise on them. Because this is a raw passthrough, print a handful of frames and read the actual keys before you build against them, rather than trusting a shape from a blog post.
Or skip the plumbing: pip install pinnodds
If you'd rather not maintain the parse-and-reconnect code, the official SDK wraps the REST endpoints and the SSE drop streams:
import os
from pinnodds import Client
client = Client(api_key=os.environ["PINNODDS_KEY"])
for msg in client.stream_drops(mode="live"): # reconnects handled for you
print(msg["sect"], msg["outcome"], msg["from_price"], "->", msg["to_price"])
It's low-dependency and gives you the same field names as the raw streams, so you can start with the SDK and drop to raw httpx/websockets later without relearning the shapes. The SDK covers the SSE and REST surface; for the raw /ws/feed passthrough you use websockets directly as above.
When streaming is the wrong call
Being specific, because "just stream everything" wastes real time.
- You're on the Pro (REST-only) plan. Push is Pro + SSE and up. On Pro, poll
/api/dropson a sensible interval — you getdrop_pctprecomputed and you don't fight reconnect logic. Don't architect for streams you can't reach yet. - You only need a snapshot. A one-off "what are tonight's lines" script is a single REST call. Opening a long-lived socket for it is pure overhead — that's the REST guide's territory.
- You want history. There's no archive; streams are the present tense only. If you need a backtest set, start a
/ws/feedlogger writing every frame to Postgres or Parquet today and wait — the dataset only exists if you begin collecting it. - You need more than Pinnacle. pinnodds is Pinnacle-only. Streaming won't turn one book into an arbitrage feed; pair it with a multi-book aggregator and use Pinnacle as the sharp reference.
Takeaway
Streaming live odds in Python is two connections and one discipline. Use /odds-drop (SSE) for lean drop events and /ws/feed (WebSocket) for the raw board — both authenticate with ?key=, both need a backoff-reconnect loop, and both lose the gap on a drop, which is why a REST snapshot from /api/drops after every reconnect isn't optional. Reach for pip install pinnodds when you'd rather not own the SSE plumbing, and drop to raw httpx/websockets when you need control. Full endpoint and field reference is in the docs; the streaming product is detailed on the WebSocket API page.
Frequently asked questions
How do I stream live odds in Python?
Connect to a pushed feed rather than polling REST. pinnodds gives you two options: Server-Sent Events at /odds-drop for drop alerts, which you read with httpx or sseclient, and a raw WebSocket passthrough at /ws/feed, which you read with the websockets library. Both authenticate with ?key=YOUR_KEY on the URL because streaming clients cannot set headers.
What is the difference between the SSE stream and the WebSocket feed?
SSE at /odds-drop is a curated, lean stream of drop events — one JSON object per alert with sect, outcome, from_price, to_price, id, limit and nvp. The WebSocket at /ws/feed is the raw Pinnacle passthrough: the full market frames, heavier and unfiltered, for when you want the whole board pushed rather than only the drops.
Do I need a paid plan to stream odds in Python?
Yes for push. Pushed SSE and WebSocket access start at the Pro + SSE tier ($149/mo), with Scale at $229/mo. The entry Pro plan ($99/mo) is REST only, so on that tier you poll /api/drops instead of streaming. A free trial key takes seconds and needs no card if you only want to inspect the payload shape first.
Why does my SSE odds alert have no drop percentage?
SSE alerts are deliberately lean — they carry sect, outcome, from_price, to_price, id, limit and nvp, but no drop_pct. Either compute the percentage yourself from from_price and to_price, or read the enriched REST form at /api/drops, whose rows include a precomputed drop_pct.
How do I handle reconnects when streaming odds in Python?
Wrap the stream in a loop with exponential backoff, and re-fetch a REST snapshot on every reconnect. A dropped stream is never replayed — any movement during the gap is lost — so the snapshot after reconnect is how you rebuild the board and close that gap.
Is there a Python SDK for streaming Pinnacle odds?
Yes. Run pip install pinnodds for the official low-dependency SDK; it wraps both the REST endpoints and the SSE drop streams so you do not hand-write the parsing or reconnect loop. For the raw WebSocket passthrough at /ws/feed you use the standard websockets library directly.
Get real-time Pinnacle odds in your code
The Pinnacle APIby pinnodds: live & prematch markets with instant odds-drop alerts over SSE and WebSocket. See plans and pricing, or get a free key in seconds — no card, no Pinnacle account.
Get your free API key