On this page
Pinnacle never offered a public WebSocket; its REST API, closed in July 2025, was polled. The pinnodds feed exposes the upstream push stream it consumes as a raw WebSocket at wss://pinnodds.com/ws/feed. This page explains what arrives on that socket, when it beats the SSE drop stream or REST polling, and how to hold a connection correctly in Python and Node. The frame reference itself lives in the pinnodds docs; nothing here replaces it.
What the raw feed is
The WebSocket forwards every upstream Pinnacle frame it receives, unchanged. pinnodds documents this as a fidelity contract: the events, rec and data payloads inside a frame are never transformed. The wrapper around them is small: a type, and for live updates an op and the source topic.
Four frame types are documented. snapshot arrives once per stream and sport right after you subscribe and carries the full current events list. live carries one Pinnacle record in rec with op set to add, upd or del. prematch_matchups and prematch_markets carry prematch payloads in data. The shape inside rec and data is Pinnacle's, so read the frame semantics section of the docs rather than reverse-engineering it from samples.
Because it is a passthrough, the feed does not compute anything for you. There are no drop percentages, no no-vig prices and no freshness headers. You get the wire, at wire speed.
WebSocket vs SSE vs REST
Pick the transport by what you need to know, not by what sounds fastest. Most projects do fine with REST plus the SSE drop stream; the WebSocket is for people who need every tick.
Scroll for more
| Need | Use | Why |
|---|---|---|
| Current prices on a schedule | REST with since | Simple, cacheable, works on a trial key, freshness headers included. |
| Be told when a price falls | SSE drop stream | Server does the detection; alerts carry the no-vig price; SDKs reconnect for you. |
| Every change, your own logic | WebSocket | Full-fidelity frames, add/upd/del semantics, one socket for many sports. |
| Backfill after downtime | REST /api/drops or a fresh snapshot | Streams do not replay; the drops buffer keeps about three hours. |
The WebSocket is also the most work. You must maintain state, handle pings, respect the one-connection rule and write your own drop detection if you want it. pinnodds wrote up how fast an odds feed can realistically be, which is worth reading before you assume you need the socket.
Connect and subscribe
Authenticate with ?key= on the URL. Within five seconds of the socket opening you must send a subscribe frame, or the server closes it. Subscribe by sport for a firehose, by event id for a filtered stream, or both.
// send within 5 s of connecting, or the server closes the socket
{ "type": "subscribe", "streams": ["live", "prematch"], "sport_ids": [1, 2] }
// narrow to specific Pinnacle matchup ids once you know them
{ "type": "subscribe", "streams": ["live"], "event_ids": [1631005165] }
{ "type": "unsubscribe", "streams": ["live"], "sport_ids": [2] }
// the server pings; answer or it closes you as stale after 75 s
{ "type": "pong" } sport_ids are the same integers as the REST API (1 soccer, 2 tennis, 3 basketball, 4 hockey and so on). event_ids are Pinnacle matchup ids, the id field on every record and the event_id you see on the REST board. The usual pattern is to subscribe to a sport, pick the events you care about from the snapshot, then unsubscribe from the sport and resubscribe to those ids.
The server sends application-level ping frames as JSON. Reply with a pong frame; a connection that has not answered for 75 seconds is closed with code 1001 and reason stale. Library-level ping/pong is not a substitute.
Python client with reconnect
This client uses the websockets package, subscribes on open, answers pings, and reconnects with capped exponential backoff and jitter. It treats eviction differently from other closes: if another process on your account took the slot, reconnecting immediately would just evict them back.
import asyncio, json, os, random
import websockets # pip install websockets
URL = f"wss://pinnodds.com/ws/feed?key={os.environ['PINNODDS_KEY']}"
SUB = {"type": "subscribe", "streams": ["live", "prematch"], "sport_ids": [1, 2]}
async def session(on_frame):
async with websockets.connect(URL, max_size=8 * 1024 * 1024, compression=None) as ws:
await ws.send(json.dumps(SUB)) # must arrive within 5 s
async for raw in ws:
msg = json.loads(raw)
if msg.get("type") == "ping":
await ws.send(json.dumps({"type": "pong"}))
continue
on_frame(msg)
async def run(on_frame):
backoff = 1
while True:
try:
await session(on_frame)
backoff = 1 # clean close: reconnect at once
except websockets.ConnectionClosed as e:
if e.rcvd and e.rcvd.reason == "evicted by newer connection":
# another process on this account took the slot; do not fight it
print("evicted, sleeping 60 s"); await asyncio.sleep(60); continue
print("closed:", e.rcvd.code if e.rcvd else "?", e.rcvd.reason if e.rcvd else "")
except (OSError, asyncio.TimeoutError) as e:
print("transport error:", e)
await asyncio.sleep(backoff + random.random())
backoff = min(backoff * 2, 30)
def log(msg):
t = msg["type"]
if t == "snapshot":
print(f"snapshot {msg['stream']}/{msg['sport_id']}: {len(msg['events'])} events")
elif t == "live":
print(f"live {msg['op']} event={msg['rec']['id']}")
elif t in ("prematch_matchups", "prematch_markets"):
print(f"{t}: {len(msg['data'])} records")
asyncio.run(run(log)) snapshot live/1: 23 events snapshot prematch/1: 612 events live upd event=1631005165 live upd event=1631005165 live del event=1630998812
compression=None disables per-message deflate. pinnodds measured that deflate, which the protocol applies serially per connection, adds a latency tail of over a second at the 99th percentile during Pinnacle's bulk update bursts, while cutting bandwidth roughly five times. Leave it off unless your link is the bottleneck; the docs have the measurements.
Node client with ws
The same loop with the ws package. perMessageDeflate: false is the same compression choice; maxPayload is raised to 8 MB to stay comfortably above the largest documented frame chunk.
import WebSocket from "ws"; // npm install ws
import { setTimeout as sleep } from "node:timers/promises";
const URL = "wss://pinnodds.com/ws/feed?key=" + process.env.PINNODDS_KEY;
const SUB = { type: "subscribe", streams: ["live", "prematch"], sport_ids: [1, 2] };
function connect(onFrame) {
return new Promise((resolve) => {
const ws = new WebSocket(URL, {
perMessageDeflate: false, // see "compression" note below
maxPayload: 8 * 1024 * 1024, // headroom over the 512 KB chunk ceiling
});
ws.on("open", () => ws.send(JSON.stringify(SUB)));
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
if (msg.type === "ping") return ws.send(JSON.stringify({ type: "pong" }));
onFrame(msg);
});
ws.on("error", (err) => console.error("ws error:", err.message));
ws.on("close", (code, reason) => resolve({ code, reason: reason.toString() }));
});
}
async function run(onFrame) {
let backoff = 1_000;
for (;;) {
const { code, reason } = await connect(onFrame);
console.log("closed", code, reason);
if (reason === "evicted by newer connection") { await sleep(60_000); continue; }
await sleep(backoff + Math.random() * 500);
backoff = Math.min(backoff * 2, 30_000);
}
}
run((msg) => {
switch (msg.type) {
case "snapshot": console.log(`snapshot ${msg.stream}/${msg.sport_id}: ${msg.events.length} events`); break;
case "live": console.log(`live ${msg.op} event=${msg.rec.id}`); break;
case "prematch_matchups":
case "prematch_markets": console.log(`${msg.type}: ${msg.data.length} records`); break;
}
}); Note that a close during a deploy is normal: your new instance connecting evicts the old one, which then sees evicted by newer connection and must not reconnect. Make the old process exit on that reason instead of sleeping if you run under a supervisor.
Keeping your own board
A snapshot gives you full records; live updates may carry only what changed. The documented merge rules are: on add or an unknown id, store the record; on del, remove it; on upd, merge rec.markets by composite key (the market's key if present, else type, period, side and points), and treat any market whose status is not open as a close.
// Minimal live board: event id -> record, markets merged by composite key
const board = new Map();
const marketKey = (m) => m.key ?? [m.type, m.period, m.side, m.points].join("|");
function apply(msg) {
if (msg.type === "snapshot" && msg.stream === "live") {
for (const ev of msg.events) board.set(ev.id, ev); // full records, replace
return;
}
if (msg.type !== "live") return;
const { op, rec } = msg;
if (op === "del") { board.delete(rec.id); return; }
const cur = board.get(rec.id);
if (!cur || op === "add") { board.set(rec.id, rec); return; }
// op === "upd": rec.markets may be a subset; merge, and treat non-open as a close
const merged = new Map((cur.markets ?? []).map((m) => [marketKey(m), m]));
for (const m of rec.markets ?? []) {
if (m.status && m.status !== "open") merged.delete(marketKey(m));
else merged.set(marketKey(m), m);
}
board.set(rec.id, { ...cur, ...rec, markets: [...merged.values()] });
} Two more rules from the docs that this sketch omits. A period whose status is closed or settled implicitly closes every market in that period, even ones not listed in the frame. A record with a parentId is a child matchup (alternate periods, derived props) and should hang off its parent rather than appear as a separate event. If you also want drop detection, compare each merged price with the previous one and emit your own alert, or run the SSE stream alongside; SSE occupies a separate slot, so both can run at once.
One connection per account
The limit is per account, not per key or IP. Opening a second socket does not fail; it evicts the oldest with close code 1001 and reason evicted by newer connection. Two consumers sharing an account therefore kick each other in a loop and both go quiet while REST keeps working, which is the most common support ticket the docs describe.
Close codes to handle: 1001 evicted by newer connection, 1001 stale (no pong for 75 seconds), plus ordinary network drops with no reason. The docs list the full table.
Plans and the add-on
A new key starts on the free trial tier, which is REST only. The raw WebSocket is an add-on priced at 99 USD a month on eligible plans, and it is included in the 3-day full demo you can request after signup. The pricing breakdown compares every tier; the short version is that plans differ in rate limit and push access, not in data depth. If you only need alerts, a Stream, Pro + SSE or Scale plan gives you the SSE stream without the WebSocket add-on, and the Telegram recipe shows how far that gets you.