Free trial · 7 days

Get a free key

We email you a link; opening it shows your API key. Then message @oddspush on Telegram and we activate your 7-day free trial. No card.

By continuing you agree to the Terms and the Privacy Policy.

Already have a key?

Lost your key?

Live Pinnacle odds in Python

Last updated

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 ld and both channels, 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.

install Shell
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.

shell Shell
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.

connect.py Python
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:

welcome JSON
{
  "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:

error · then close 4101 JSON
{"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.

subscribe JSON
{"type": "subscribe", "sports": [1], "channels": ["ld", "both"], "snapshot": true}
feed.py · excerpt Python
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 volatile dz included. For the displayed price use ["ld", "both"]: both carries matches that never appear on ld.
  • Subscriptions add up. The server answers each one with subscription, your whole filter.
  • Single events go in events, by rec.id: up to 1,000 across all your subscribes.

Every update then arrives as odds, with Pinnacle's record in data:

odds JSON
{
  "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.

snapshot JSON
{
  "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.

  1. Key events by rec.id. Several records can describe the same fixture (see parentId), so names are not a key.
  2. Key markets by key. It is unique within an event.
  3. Apply a market only if its version is higher. Lower or equal is a reordered update: skip it. Never use rec.version.
  4. A sharp move with a higher version is real. Apply it. Do not filter prices that look odd.
  5. Remove what has closed. Drop the event on op: "del". Drop a market when its period is "closed" or "settled", or when its status is anything but "open".
  6. Merge ld and both by version; keep dz apart. When the same key appears on both, the higher version is the current price.
  7. Sweep after a snapshot of everything you follow. Note when you ask for it: on connect, on missed, on feed "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:

book.py Python
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.

feed.py · excerpt Python
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.

odds.py Python
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_s after reconnect_rate_limited (4106).
  • Watch for silence. A keepalive comes 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.
feed.py · excerpt Python
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.

poll.py Python
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 429 carries a retry-after header in seconds; urllib raises it as HTTPError.
  • 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:

prompt Text
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.

feed.py Python
"""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.