Live Pinnacle odds in Python
To get live Pinnacle odds in Python, open a WebSocket to wss://feed.oddspush.com/v1/stream with the
websockets library, send a login with your OddsPush key, then a
subscribe with the sports you want. Every price change then arrives as an odds
message about 30 ms after Pinnacle publishes it (bots hosted in Europe), and you merge it into a book keyed
by event id and market key, ordered by market version.
This guide builds that client step by step, in plain asyncio. The finished program is the
same reference client as in the docs, listed in full at the
end.
1. What you will build: a live Pinnacle odds feed in Python
One Python file, feed.py, that:
- connects to the OddsPush stream and logs in with your key;
-
subscribes to soccer on the
ldandbothchannels, the price Pinnacle displays; - keeps a correct book of every open market, from a snapshot plus every update after it;
- asks for a fresh snapshot when it falls behind or the feed comes back;
- reconnects with backoff, and stops on the close codes that retrying cannot fix.
Change one list and it follows any of the 13 sports: the ids are in the sports table, and what each one carries is on coverage. You need Python 3.10 or newer and an OddsPush key. Every plan includes REST, and the WebSocket stream comes with the free trial and with REST + WebSocket (pricing).
2. Install the Python WebSocket library
The client needs one package, websockets 13 or newer. Everything else is the standard library.
pip install "websockets>=13"
3. Put your OddsPush API key in an environment variable
Your key is opk_ and 43 characters. It is shown once, on the page you reach from the sign-up
email, and works from one IP at a time: the last one to connect takes over. Keep it out of your code and
your repo; the examples read it from ODDSPUSH_KEY.
export ODDSPUSH_KEY=opk_...
python feed.py
A new account receives no data until its free trial is active. Message
@oddspush on Telegram with the email of
your account and we switch it on; the 7 days count from then. Until it is active the stream rejects
the key with unauthorized (4101). Details in Your key.
4. Connect to the live odds WebSocket and log in
One endpoint, TLS only. Turn per-message compression off (it only adds latency on large frames) and accept
messages up to 16 MB, since a snapshot part always holds at least one whole event. The
first message must be the login, within 15 seconds of the socket opening.
import asyncio
import json
import os
from websockets.asyncio.client import connect
URL = "wss://feed.oddspush.com/v1/stream"
KEY = os.environ["ODDSPUSH_KEY"] # opk_ and 43 characters
async def main() -> None:
async with connect(URL, compression=None, max_size=16 * 2**20) as ws:
await ws.send(json.dumps({"type": "login", "token": KEY}))
print(json.loads(await ws.recv())) # welcome, or an error and then a close
asyncio.run(main())
If the key is accepted, the first message back is welcome:
{
"type": "welcome",
"server_time": 1793458714075,
"feed": {"alive": true, "ready": true},
"limits": {
"queue_size": 1000,
"keepalive_s": 15,
"max_events": 1000,
"snapshot_buffer_bytes": 4194304,
"max_lag_s": 15
},
"account": {
"id": "acme",
"plan": "paid",
"expires_at": 1796050714075,
"limits": {
"connections": 1,
"sports": null,
"streams": null,
"snapshots_per_hour": 12,
"snapshot_mb_per_hour": 400,
"reconnects_per_minute": 12,
"rest": true,
"stream": true
}
}
}
account.limits holds your plan's values. If the login is refused you get one
error and the connection closes with its code:
{"type": "error", "code": "unauthorized"}
Every field is described in Connect and log in.
5. Subscribe to live Pinnacle odds by sport
A subscribe adds sports, events or channels to your filter, and by default brings a snapshot of
what it adds. Send it right after the login: there is no need to wait for
welcome.
{"type": "subscribe", "sports": [1], "channels": ["ld", "both"], "snapshot": true}
SUBSCRIBE = {
"type": "subscribe",
"sports": [1], # soccer
"channels": ["ld", "both"], # the displayed price; dz stays out
"snapshot": True, # the default, spelled out
}
-
Always set
channels. Without it every channel arrives, the volatiledzincluded. For the displayed price use["ld", "both"]:bothcarries matches that never appear onld. -
Subscriptions add up. The server answers each one with
subscription, your whole filter. -
Single events go in
events, byrec.id: up to 1,000 across all your subscribes.
Every update then arrives as odds, with Pinnacle's record in data:
{
"type": "odds",
"data": {
"type": "live",
"sport_id": 1,
"topic": "matchups/reg/sp/29/live/ld",
"op": "upd",
"rec": {
"id": 1638780974,
"parentId": 1638780960,
"type": "matchup",
"startTime": "2026-10-31T15:00:00Z",
"league": {"id": 1980, "name": "England - Premier League"},
"participants": [
{
"name": "Brentford",
"alignment": "home",
"state": {"score": 1}
},
{"name": "Fulham", "alignment": "away", "state": {"score": 0}}
],
"state": {"minutes": 38},
"periods": [
{"period": 0, "status": "open"},
{"period": 1, "status": "open"}
],
"markets": [
{
"key": "s;0;m",
"type": "moneyline",
"period": 0,
"status": "open",
"version": 3772905262,
"isAlternate": false,
"prices": [
{"designation": "home", "price": -238},
{"designation": "away", "price": 655},
{"designation": "draw", "price": 318}
],
"limits": [{"type": "maxRiskStake", "amount": 3000}]
},
{
"key": "s;0;s;-0.75",
"type": "spread",
"period": 0,
"status": "open",
"version": 3772905262,
"isAlternate": false,
"prices": [
{"designation": "home", "price": -105, "points": -0.75},
{"designation": "away", "price": -115, "points": 0.75}
]
},
{
"key": "s;0;ou;2.5",
"type": "total",
"period": 0,
"status": "open",
"version": 3772905263,
"isAlternate": false,
"prices": [
{"designation": "over", "price": 104, "points": 2.5},
{"designation": "under", "price": -122, "points": 2.5}
]
}
]
},
"ts": 1793458714075
}
}
Prices are American odds, in rec.markets[i].prices. Filter rules are in
Subscribe and the channels in Channels.
6. Keep a correct odds book from snapshots and updates
The snapshot comes in parts of at most 100 events or about 256 KB. Apply each part as it
arrives, and updates in between: merging by version makes the order irrelevant. The last part has
"final": true.
{
"type": "snapshot",
"part": 0,
"final": false,
"events": [
{
"id": 1638780974,
"type": "matchup",
"startTime": "2026-10-31T15:00:00Z",
"markets": [
{
"key": "s;0;m",
"type": "moneyline",
"period": 0,
"status": "open",
"version": 3772905262,
"isAlternate": false,
"prices": [
{"designation": "home", "price": -238},
{"designation": "away", "price": 655},
{"designation": "draw", "price": 318}
],
"limits": [{"type": "maxRiskStake", "amount": 3000}]
}
]
},
{
"id": 1638781120,
"type": "matchup",
"startTime": "2026-10-31T15:00:00Z",
"markets": [
{
"key": "s;0;ou;2.5",
"type": "total",
"period": 0,
"status": "open",
"version": 3772905263,
"isAlternate": false,
"prices": [
{"designation": "over", "price": 104, "points": 2.5},
{"designation": "under", "price": -122, "points": 2.5}
]
}
]
}
]
}
None of these rules raises an error when broken. You just end up with wrong prices.
-
Key events by
rec.id. Several records can describe the same fixture (seeparentId), so names are not a key. -
Key markets by
key. It is unique within an event. -
Apply a market only if its
versionis higher. Lower or equal is a reordered update: skip it. Never userec.version. -
A sharp move with a higher version is real. Apply it. Do not filter prices that look odd.
-
Remove what has closed. Drop the event on
op: "del". Drop a market when its period is"closed"or"settled", or when itsstatusis anything but"open". -
Merge
ldandbothby version; keepdzapart. When the same key appears on both, the higher version is the current price. -
Sweep after a snapshot of everything you follow. Note when you ask for it: on connect, on
missed, onfeed"up". When its final part arrives, drop every market and event that neither the snapshot nor an update has mentioned since then. Never sweep after a snapshot of a subset.
The book class from the reference client does all seven:
class Book:
"""Events by rec.id, markets by key, ordered by markets[i].version."""
def __init__(self) -> None:
# events[id] = {"rec": event fields, "markets": {key: market}, "seen": {key: tick}, "tick": n}
self.events: dict[int, dict] = {}
self.tick = 0 # bumped on every record applied
self.sweep_from = None # tick when the snapshot being served was asked for
self.sweep_next = None # the same, for one asked while another is in flight
def apply_rec(self, rec: dict) -> None:
"""Merge one event record (from odds or from a snapshot)."""
self.tick += 1
ev = self.events.setdefault(rec["id"], {"rec": {}, "markets": {}, "seen": {}, "tick": 0})
ev["tick"] = self.tick
ev["rec"].update((k, v) for k, v in rec.items() if k != "markets")
markets, seen = ev["markets"], ev["seen"]
for m in rec.get("markets") or []:
key = m.get("key")
if key is None:
continue
seen[key] = self.tick # mentioned, even if not newer
prev = markets.get(key)
new_v, old_v = m.get("version"), prev.get("version") if prev else None
if isinstance(new_v, int) and isinstance(old_v, int) and new_v <= old_v:
continue # older than what we hold: skip
if m.get("status") == "open":
markets[key] = m
else:
markets.pop(key, None) # anything but "open" closes it
periods = rec.get("periods") or []
for p in periods.values() if isinstance(periods, dict) else periods:
if isinstance(p, dict) and p.get("status") in ("closed", "settled"):
n = str(p.get("period"))
for key in [k for k, m in markets.items() if str(m.get("period")) == n]:
del markets[key] # a closed period closes its markets
def delete(self, event_id) -> None:
self.events.pop(event_id, None)
# A snapshot lists only open markets: a close you missed never shows up in it.
# So note when you ask for one, and on its final part drop what nobody mentioned since.
def snapshot_asked(self) -> None:
"""Call when you ask for a snapshot of everything you follow (subscribe included)."""
if self.sweep_from is None:
self.sweep_from = self.tick
elif self.sweep_next is None:
self.sweep_next = self.tick # served after the one in flight
def snapshot_refused(self) -> None:
"""On snapshot_rate_limited. It is about the latest request, and it can arrive
before the final part of the one still being delivered: forget the latest."""
if self.sweep_next is not None:
self.sweep_next = None
else:
self.sweep_from = None
def snapshot_done(self) -> None:
"""On the final part."""
since, self.sweep_from, self.sweep_next = self.sweep_from, self.sweep_next, None
if since is None:
return
for eid in [i for i, ev in self.events.items() if ev["tick"] <= since]:
del self.events[eid] # not in the snapshot, no update since
for ev in self.events.values():
for key in [k for k in ev["markets"] if ev["seen"].get(k, 0) <= since]:
del ev["markets"][key] # closed while we were not looking
ev["seen"].pop(key, None)
def new_connection(self) -> None:
"""A snapshot in flight dies with its socket. Keep the book, forget the requests."""
self.sweep_from = self.sweep_next = None
And the session loop that feeds it: log in, subscribe, and route each message type. Ask for a snapshot
on missed and on feed "up", never on a timer.
async def session(self, ws) -> None:
self.book.new_connection()
await ws.send(json.dumps({"type": "login", "token": KEY}))
await ws.send(json.dumps(SUBSCRIBE)) # no need to wait for welcome
self.book.snapshot_asked() # the subscribe brings one
while True:
msg = json.loads(await asyncio.wait_for(ws.recv(), SILENCE_S))
kind = msg.get("type")
if kind == "odds":
self.on_odds(msg["data"])
elif kind == "snapshot":
for rec in msg.get("events") or []:
self.book.apply_rec(rec)
if msg.get("final"):
self.book.snapshot_done()
log.info("snapshot done, %d events in book", len(self.book.events))
elif kind == "missed":
log.warning("missed %s updates: asking for a snapshot", msg.get("count"))
await self.ask_snapshot(ws)
elif kind == "feed":
log.warning("feed %s", msg.get("status"))
if msg.get("status") == "up": # updates were lost while it was down
await self.ask_snapshot(ws)
elif kind == "error":
self.on_error(msg) # before a close, the close code follows
elif kind == "welcome":
log.info("logged in, plan %s", msg["account"]["plan"])
# subscription, keepalive, pong, status and any new type: nothing to do
The full recipe, with the reasons, is in Keeping a correct book.
7. Convert American odds to decimal in Python
price is in American odds. To decimal: positive prices add price / 100, negative
ones add 100 / -price.
def decimal(american: int) -> float:
return 1 + american / 100 if american > 0 else 1 + 100 / -american
decimal(-238) # 1.42
decimal(655) # 7.55
Guard against a price of 0 or a missing price before converting.
8. Reconnect with backoff and handle close codes
Connections drop. Before closing, the server sends an error naming the reason, then closes with
the matching code. Four of them will not clear up by retrying, so the client stops:
| Close | error.code | Meaning |
|---|---|---|
| 4101 | unauthorized | Unknown or replaced key, or a trial not active yet. |
| 4103 | subscription_expired | Your plan has ended. |
| 4107 | replaced | Your key connected from another IP, or a newer connection of yours took the slot. |
| 4109 | stream_not_in_plan | Your plan is REST only. |
For anything else, reconnect, and do it well:
- Back off exponentially, with jitter. Start at 1 s, double up to 30 s, and wait a random time between half the delay and all of it. Grow each delay from the previous one.
- Reset only after a healthy session: 60 s connected, not on connect.
-
Honour
retry_after_safterreconnect_rate_limited(4106). -
Watch for silence. A
keepalivecomes every 15 s of idleness, so 40 s without any message means the connection is dead. Close it and reconnect. - Log in and subscribe again; keep your book, then sweep it on the final part of the new snapshot.
async def run(self) -> None:
loop = asyncio.get_running_loop()
delay = BACKOFF_MIN_S
while True:
opened = None
try:
async with connect(URL, compression=None, max_size=16 * 2**20,
open_timeout=10) as ws:
opened = loop.time()
await self.session(ws)
except ConnectionClosed as e:
code = e.rcvd.code if e.rcvd else 1006
if code in STOP:
log.error("closed with %s: not reconnecting", code)
return
log.warning("closed with %s", code)
except (OSError, asyncio.TimeoutError, WebSocketException) as e:
log.warning("connection lost: %r", e) # DNS, TCP, TLS, handshake, silence
if opened is not None and loop.time() - opened > STABLE_S:
delay = BACKOFF_MIN_S
wait = max(delay / 2 + random.uniform(0, delay / 2), self.retry_after)
self.retry_after = 0.0
log.info("reconnecting in %.1f s", wait)
await asyncio.sleep(wait)
delay = min(delay * 2, BACKOFF_MAX_S) # grow from the last delay, capped
All the codes are in Errors and close codes, and the policy in Reconnecting.
9. Or poll the Pinnacle odds REST API from Python
If you only need the current book now and then, skip the stream and poll REST. Same key, in the
x-token header; only GET. /v1/events lists the events with the number
of open markets per channel, and /v1/events/{event_id} returns one event with
merged: ld and both merged by version, the price to show.
import json
import os
import urllib.request
BASE = "https://feed.oddspush.com/v1"
KEY = os.environ["ODDSPUSH_KEY"]
def get(path: str) -> dict:
req = urllib.request.Request(BASE + path, headers={"x-token": KEY})
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read())
rows = get("/events?sport_id=1")["events"] # soccer
for row in [r for r in rows if r["ld"] or r["both"]][:5]: # open markets on the price channels
ev = get(f"/events/{row['event_id']}")
for key, m in ev["merged"].items(): # ld and both, merged by version
print(ev["event_id"], key, [(x["designation"], x["price"]) for x in m["prices"]])
-
Requests are rate limited per account: a burst, then a steady rate per minute. A
429carries aretry-afterheader in seconds;urllibraises it asHTTPError. -
Poll from the same machine as your stream. A request from a new IP takes over the key and
closes an open stream from the old one with
replaced(4107). - REST is as fresh as you poll. The REST plan is $69 a month; REST + WebSocket, with every change pushed, is $138.
Endpoints, parameters and errors: REST.
10. Let a coding agent write the Python client
Claude Code, Codex, Cursor or a similar agent can write this for you. Give it oddspush-agent-guide.md, one file with the whole protocol, two reference clients and an acceptance checklist, and a prompt like:
Read oddspush-agent-guide.md and follow it. Build a Python client that streams Pinnacle odds for soccer and tennis from OddsPush, keeps a correct price book, and prints every price change in decimal odds. My key is in the ODDSPUSH_KEY environment variable.
If your agent can open links, give it the file's address instead of saving it in your project.
11. Full Python code: the reference OddsPush client
Everything above in one file: log in, subscribe to soccer on ld and both, keep the
book by version and sweep it after each snapshot, ask for a snapshot on missed and on
feed "up", watch for silence and reconnect with backoff. Set
ODDSPUSH_KEY and run it.
"""OddsPush stream client: log in, subscribe, keep a book, reconnect with backoff.
pip install "websockets>=13"
ODDSPUSH_KEY=opk_... python feed.py
"""
import asyncio
import json
import logging
import os
import random
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed, WebSocketException
URL = "wss://feed.oddspush.com/v1/stream"
KEY = os.environ["ODDSPUSH_KEY"] # opk_ and 43 characters
SUBSCRIBE = {
"type": "subscribe",
"sports": [1], # soccer
"channels": ["ld", "both"], # the displayed price; dz stays out
"snapshot": True, # the default, spelled out
}
PRICE = ("/ld", "/both") # the channels of the price book (topic suffix)
STOP = {4101, 4103, 4107, 4109} # unauthorized, plan ended, replaced, REST-only plan
SILENCE_S = 40 # nothing at all for this long: the connection is dead
BACKOFF_MIN_S, BACKOFF_MAX_S = 1.0, 30.0
STABLE_S = 60 # a session this long resets the backoff
log = logging.getLogger("oddspush")
class Book:
"""Events by rec.id, markets by key, ordered by markets[i].version."""
def __init__(self) -> None:
# events[id] = {"rec": event fields, "markets": {key: market}, "seen": {key: tick}, "tick": n}
self.events: dict[int, dict] = {}
self.tick = 0 # bumped on every record applied
self.sweep_from = None # tick when the snapshot being served was asked for
self.sweep_next = None # the same, for one asked while another is in flight
def apply_rec(self, rec: dict) -> None:
"""Merge one event record (from odds or from a snapshot)."""
self.tick += 1
ev = self.events.setdefault(rec["id"], {"rec": {}, "markets": {}, "seen": {}, "tick": 0})
ev["tick"] = self.tick
ev["rec"].update((k, v) for k, v in rec.items() if k != "markets")
markets, seen = ev["markets"], ev["seen"]
for m in rec.get("markets") or []:
key = m.get("key")
if key is None:
continue
seen[key] = self.tick # mentioned, even if not newer
prev = markets.get(key)
new_v, old_v = m.get("version"), prev.get("version") if prev else None
if isinstance(new_v, int) and isinstance(old_v, int) and new_v <= old_v:
continue # older than what we hold: skip
if m.get("status") == "open":
markets[key] = m
else:
markets.pop(key, None) # anything but "open" closes it
periods = rec.get("periods") or []
for p in periods.values() if isinstance(periods, dict) else periods:
if isinstance(p, dict) and p.get("status") in ("closed", "settled"):
n = str(p.get("period"))
for key in [k for k, m in markets.items() if str(m.get("period")) == n]:
del markets[key] # a closed period closes its markets
def delete(self, event_id) -> None:
self.events.pop(event_id, None)
# A snapshot lists only open markets: a close you missed never shows up in it.
# So note when you ask for one, and on its final part drop what nobody mentioned since.
def snapshot_asked(self) -> None:
"""Call when you ask for a snapshot of everything you follow (subscribe included)."""
if self.sweep_from is None:
self.sweep_from = self.tick
elif self.sweep_next is None:
self.sweep_next = self.tick # served after the one in flight
def snapshot_refused(self) -> None:
"""On snapshot_rate_limited. It is about the latest request, and it can arrive
before the final part of the one still being delivered: forget the latest."""
if self.sweep_next is not None:
self.sweep_next = None
else:
self.sweep_from = None
def snapshot_done(self) -> None:
"""On the final part."""
since, self.sweep_from, self.sweep_next = self.sweep_from, self.sweep_next, None
if since is None:
return
for eid in [i for i, ev in self.events.items() if ev["tick"] <= since]:
del self.events[eid] # not in the snapshot, no update since
for ev in self.events.values():
for key in [k for k in ev["markets"] if ev["seen"].get(k, 0) <= since]:
del ev["markets"][key] # closed while we were not looking
ev["seen"].pop(key, None)
def new_connection(self) -> None:
"""A snapshot in flight dies with its socket. Keep the book, forget the requests."""
self.sweep_from = self.sweep_next = None
class Feed:
def __init__(self) -> None:
self.book = Book()
self.retry_after = 0.0 # seconds, from a reconnect_rate_limited error
async def ask_snapshot(self, ws) -> None:
await ws.send(json.dumps({"type": "snapshot"})) # everything you follow
self.book.snapshot_asked()
def on_odds(self, data: dict) -> None:
if data.get("type") == "live":
rec = data.get("rec") or {}
if data.get("op") == "del":
self.book.delete(rec.get("id"))
elif "id" in rec and str(data.get("topic")).endswith(PRICE):
self.book.apply_rec(rec) # other channels pass any filter: not a price
elif data.get("type") == "snapshot":
# A partial state that can arrive at any time: merge, never sweep.
for rec in data.get("events") or []:
self.book.apply_rec(rec)
# other data.type values: skip
def on_error(self, msg: dict) -> None:
code = msg.get("code")
if code == "reconnect_rate_limited":
self.retry_after = float(msg.get("retry_after_s") or 0)
elif code == "snapshot_rate_limited":
self.book.snapshot_refused() # no final part will come for it
log.warning("server error: %s", msg)
async def session(self, ws) -> None:
self.book.new_connection()
await ws.send(json.dumps({"type": "login", "token": KEY}))
await ws.send(json.dumps(SUBSCRIBE)) # no need to wait for welcome
self.book.snapshot_asked() # the subscribe brings one
while True:
msg = json.loads(await asyncio.wait_for(ws.recv(), SILENCE_S))
kind = msg.get("type")
if kind == "odds":
self.on_odds(msg["data"])
elif kind == "snapshot":
for rec in msg.get("events") or []:
self.book.apply_rec(rec)
if msg.get("final"):
self.book.snapshot_done()
log.info("snapshot done, %d events in book", len(self.book.events))
elif kind == "missed":
log.warning("missed %s updates: asking for a snapshot", msg.get("count"))
await self.ask_snapshot(ws)
elif kind == "feed":
log.warning("feed %s", msg.get("status"))
if msg.get("status") == "up": # updates were lost while it was down
await self.ask_snapshot(ws)
elif kind == "error":
self.on_error(msg) # before a close, the close code follows
elif kind == "welcome":
log.info("logged in, plan %s", msg["account"]["plan"])
# subscription, keepalive, pong, status and any new type: nothing to do
async def run(self) -> None:
loop = asyncio.get_running_loop()
delay = BACKOFF_MIN_S
while True:
opened = None
try:
async with connect(URL, compression=None, max_size=16 * 2**20,
open_timeout=10) as ws:
opened = loop.time()
await self.session(ws)
except ConnectionClosed as e:
code = e.rcvd.code if e.rcvd else 1006
if code in STOP:
log.error("closed with %s: not reconnecting", code)
return
log.warning("closed with %s", code)
except (OSError, asyncio.TimeoutError, WebSocketException) as e:
log.warning("connection lost: %r", e) # DNS, TCP, TLS, handshake, silence
if opened is not None and loop.time() - opened > STABLE_S:
delay = BACKOFF_MIN_S
wait = max(delay / 2 + random.uniform(0, delay / 2), self.retry_after)
self.retry_after = 0.0
log.info("reconnecting in %.1f s", wait)
await asyncio.sleep(wait)
delay = min(delay * 2, BACKOFF_MAX_S) # grow from the last delay, capped
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
asyncio.run(Feed().run())
Run it against the real feed
The free trial gives you REST and the live stream, all 13 sports, for 7 days. No card.
Coming from Pinnacle's own API? See the Pinnacle API alternative.
OddsPush is not affiliated with, endorsed by or sponsored by Pinnacle.