DEV Community

Ryan
Ryan

Posted on

Pinnacle WebSocket API in 2026: Build a Live Odds Client in Python and Node.js

Building a live odds client on the Pinnacle WebSocket API in Python and Node.js: add, upd, and del frame handling from pinnapi's feed at wss://pinnapi.com/ws/feed

Pinnacle's public API closed on 23 July 2025, and the modern way to hold its live odds in your own process is a WebSocket feed: a connection that pushes every change the moment it happens, into state you control. This post builds a working live-odds client against pinnapi's WS protocol, start to finish: connect, subscribe, seed, merge, survive disconnects. Roughly 60 lines of Python by the end, with the same client in Node.js at the bottom for the JS side of the room. The payoff for those 60 lines is a full local mirror of the book, the substrate for anything that needs complete market state rather than isolated pings: odds screens, derivative pricing, or strategy code that asks "what does the whole board look like right now" a thousand times a second without a network call.

Disclosure first: I run pinnapi, the feed used below. The handling patterns here (snapshot seeding, delta merging, keepalive, reconnect) transfer to any WebSocket odds feed, so the post should be useful even if you never touch mine.

The protocol in one minute

Endpoint: wss://pinnapi.com/ws/feed?key=YOUR_KEY. Query-param auth is the documented form for the WebSocket. Commercially: the raw WS is a +$99/mo add-on on any pinnapi plan (plans run $99–$229/mo, and the free tier of 100 requests a day with no card covers the REST and SSE side).

You subscribe by sending one message:

{"type": "subscribe", "streams": ["live", "prematch"], "sport_ids": [1, 2], "event_ids": []}
Enter fullscreen mode Exit fullscreen mode

Scope it by stream, by sport, or down to individual events. The server then sends:

  • snapshot first: a stream's current events, which seeds your local state
  • live: incremental frames carrying rec (a record) and op (what to do with it)
  • prematch_matchups: sport_id plus data
  • prematch_markets: matchup_id plus data
  • error: code and message

Keepalive is a contract, not a suggestion: the server sends {"type": "ping"} and your client must reply {"type": "pong"}. The op values on live frames: "add" means a new event or new markets appeared, insert; "upd" is an incremental delta, merge it by its composite key and never replace the whole list; "del" means the event was kicked off, settled, or voided, drop your copy. Upstream, Pinnacle pushes incremental updates over MQTT and the feed forwards frames, so the rhythm you handle is the book's own. The field reference per frame type is in the docs.

Step 1: connect and subscribe

The websockets library, the key from an env var, one subscribe message on open. One design note before the code: subscribe as narrowly as your product allows. The subscription is scoped by stream, sport, or event precisely so the connection carries only what your model consumes, and every frame you do not receive is parse work, memory, and merge risk you do not pay for. A client that subscribes to everything "to be safe" is usually a client that has not decided what it is for.

import asyncio, json, os
import websockets

KEY = os.environ["PINNAPI_KEY"]
URL = f"wss://pinnapi.com/ws/feed?key={KEY}"

SUBSCRIBE = {
    "type": "subscribe",
    "streams": ["live", "prematch"],
    "sport_ids": [1, 2],
    "event_ids": [],
}
Enter fullscreen mode Exit fullscreen mode

Step 2: local state and the snapshot

The whole point of a raw WebSocket over filtered alerts is that you hold the book yourself. That is a dict, a key function, and a seeding hook for the snapshot frame.

The key function deserves a moment, because it is the contract that makes the whole protocol coherent. The snapshot and every later delta must agree on what identifies one market row; if they do not, an upd lands beside the row it should have modified instead of on top of it, and your book silently forks into stale and fresh copies of the same market. Whatever fields you build the key from, build it in exactly one place and use it on both the seeding path and the delta path:

book = {}

def composite_key(rec):
    """One market row's identity. Replace with the real key fields
    from the docs' field reference for your subscribed streams."""
    return json.dumps(rec, sort_keys=True)  # placeholder key

def seed(frame):
    # index the snapshot's events by composite_key here;
    # the exact payload layout is in the docs' field reference
    pass
Enter fullscreen mode Exit fullscreen mode

Step 3: the dispatch loop

Every frame is one of five types, and live frames switch on op:

def apply_live(rec, op):
    k = composite_key(rec)
    if op == "add":          # new event, or new markets on one
        book[k] = rec
    elif op == "upd":        # delta: merge, never replace the list
        book.setdefault(k, {}).update(rec)
    elif op == "del":        # kicked off, settled, or voided
        book.pop(k, None)

async def run():
    async with websockets.connect(URL) as ws:
        await ws.send(json.dumps(SUBSCRIBE))
        async for raw in ws:
            msg = json.loads(raw)
            t = msg.get("type")
            if t == "ping":
                await ws.send(json.dumps({"type": "pong"}))
            elif t == "snapshot":
                seed(msg)
            elif t == "live":
                apply_live(msg["rec"], msg["op"])
            elif t == "prematch_matchups":
                print("matchups for sport", msg["sport_id"])
            elif t == "prematch_markets":
                print("markets for matchup", msg["matchup_id"])
            elif t == "error":
                print("error:", msg["code"], msg["message"])
Enter fullscreen mode Exit fullscreen mode

The comment on upd deserves its own sentence, because it is the bug I see most. An upd frame carries the fields that changed, not the record's full current form. Replace your local row wholesale and you erase every field the delta did not mention; merge by the composite key and your local row converges on the truth with each frame. That is the entire discipline of running a local mirror, in one dict.update call.

Step 4: surviving disconnects

Connections drop. The protocol makes recovery cheap: reconnect, resubscribe, and the server's fresh snapshot frame reseeds your state from scratch.

async def main():
    while True:
        try:
            await run()
        except (websockets.ConnectionClosed, OSError) as err:
            print("disconnected:", err, "- reconnecting")
            book.clear()          # snapshot on reconnect reseeds state
            await asyncio.sleep(2)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

One sentence on why this pattern matters: a dropped connection costs you the gap while you were away, but never your correctness, because you throw away the possibly stale book and rebuild present-tense state from the new snapshot instead of guessing what changed in the dark.

The whole client

Assembled, about sixty lines, runnable as-is once you adapt composite_key and seed to the streams you subscribe to. From here the natural next steps are the ones your product defines: expose book to your strategy code behind a read interface, log every del (a vanished market is information, not just cleanup), and stamp receive time on each frame the moment it arrives so you can watch your own end-to-end latency instead of assuming it.

import asyncio, json, os
import websockets

KEY = os.environ["PINNAPI_KEY"]
URL = f"wss://pinnapi.com/ws/feed?key={KEY}"

SUBSCRIBE = {
    "type": "subscribe",
    "streams": ["live", "prematch"],
    "sport_ids": [1, 2],
    "event_ids": [],
}

book = {}

def composite_key(rec):
    """One market row's identity. Replace with the real key fields
    from the docs' field reference for your subscribed streams."""
    return json.dumps(rec, sort_keys=True)  # placeholder key

def seed(frame):
    # index the snapshot's events by composite_key here
    pass

def apply_live(rec, op):
    k = composite_key(rec)
    if op == "add":
        book[k] = rec
    elif op == "upd":
        book.setdefault(k, {}).update(rec)
    elif op == "del":
        book.pop(k, None)

async def run():
    async with websockets.connect(URL) as ws:
        await ws.send(json.dumps(SUBSCRIBE))
        async for raw in ws:
            msg = json.loads(raw)
            t = msg.get("type")
            if t == "ping":
                await ws.send(json.dumps({"type": "pong"}))
            elif t == "snapshot":
                seed(msg)
            elif t == "live":
                apply_live(msg["rec"], msg["op"])
            elif t == "prematch_matchups":
                print("matchups for sport", msg["sport_id"])
            elif t == "prematch_markets":
                print("markets for matchup", msg["matchup_id"])
            elif t == "error":
                print("error:", msg["code"], msg["message"])

async def main():
    while True:
        try:
            await run()
        except (websockets.ConnectionClosed, OSError) as err:
            print("disconnected:", err, "- reconnecting")
            book.clear()
            await asyncio.sleep(2)

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

The same client in Node.js

Identical protocol, identical discipline, the ws package instead of websockets. The browser note first: query-param auth exists precisely because browser WebSocket clients cannot set headers, so this same URL works with the native WebSocket in a browser tab too.

// npm i ws
const WebSocket = require("ws");

const KEY = process.env.PINNAPI_KEY;
const URL = `wss://pinnapi.com/ws/feed?key=${KEY}`;

const book = new Map();
// same caveat as the Python version: replace with the real key
// fields from the docs' field reference for your streams
const keyOf = (rec) => JSON.stringify(rec); // placeholder key

function connect() {
  const ws = new WebSocket(URL);

  ws.on("open", () =>
    ws.send(JSON.stringify({
      type: "subscribe",
      streams: ["live", "prematch"],
      sport_ids: [1, 2],
      event_ids: [],
    }))
  );

  ws.on("message", (raw) => {
    const msg = JSON.parse(raw);
    switch (msg.type) {
      case "ping":                    // liveness contract
        ws.send(JSON.stringify({ type: "pong" }));
        break;
      case "snapshot":                // seed the book here
        break;
      case "live": {
        const k = keyOf(msg.rec);
        if (msg.op === "add") book.set(k, msg.rec);
        else if (msg.op === "upd")    // delta: merge, never replace
          book.set(k, { ...(book.get(k) || {}), ...msg.rec });
        else if (msg.op === "del") book.delete(k);
        break;
      }
      case "error":
        console.error(msg.code, msg.message);
        break;
    }
  });

  ws.on("close", () => {              // snapshot on reconnect reseeds
    book.clear();
    setTimeout(connect, 2000);
  });
}

connect();
Enter fullscreen mode Exit fullscreen mode

Same shape as the Python client: the spread on upd is the merge-never-replace rule, the close handler is the reconnect-and-reseed pattern, and the pong reply keeps liveness provable.

How fast does a frame arrive?

Own figures only, with the span named, because a latency number without its two endpoints is noise: from pinnapi's ingestion edge to a nearby European client, frames arrive in roughly 15–40 ms, median 22 ms, p99 41 ms. The methodology is public at the latency benchmark, and the standing rule applies to my numbers before anyone else's: every latency figure is a hypothesis until you measure it from your own box, in the region you will actually run in. I quote nothing for other feeds here.

One honest caveat before you copy any of this. If all you need is "tell me when a price drops X percent," you do not need this build: the SSE drop stream does the watching server-side with ?min_drop in about ten lines, no state and no merge logic, and the drop alerts guide walks it end to end. The WebSocket earns its keep when the product is a full local mirror of the book. For alert-shaped work it is over-engineering, and I say that as the person selling it.

Questions I keep getting

Is there a WebSocket API for Pinnacle odds? Yes, from independent feeds; Pinnacle itself has had no public API since July 2025. pinnapi's WebSocket runs at wss://pinnapi.com/ws/feed, sold as a +$99/mo add-on on any plan.

What messages does a Pinnacle WebSocket send? On pinnapi's feed: a snapshot frame first to seed local state, then live frames carrying a record and an op (add, upd, del), prematch_matchups and prematch_markets frames for the prematch side, error frames with a code and message, and ping frames your client must answer with pong.

How do I keep the connection alive? Two habits: reply {"type": "pong"} to every server {"type": "ping"}, and wrap the connection in a reconnect loop that resubscribes on drop and lets the fresh snapshot reseed your state. A dropped connection then costs you the gap, not your correctness.

Can I place bets through it? No. It is a data feed, not a sportsbook: prices flow to your code and nothing flows back toward a betting market. pinnapi is independent and not affiliated with Pinnacle.


I run pinnapi, an independent Pinnacle-only odds feed. pinnapi is not affiliated with or endorsed by Pinnacle.

Top comments (0)