PinnWire Pinnacle WebSocket API

PinnWire is a Pinnacle-only sports-odds API for current live and prematch markets: REST returns JSON snapshots, SSE pushes detected price drops, and the raw WebSocket — this page — streams every subscribed price change to your code the moment it happens. No polling loop, no 10-second staleness. Independent; not affiliated with Pinnacle.

Verify PinnWire before signing up

The public REST demo key needs no account, email, or card — prove the feed is live right now:

curl "https://pinnwire.com/kit/v1/markets?sport_id=1&key=demo"
# current live soccer odds, decimal, with a generated_at freshness timestamp

Demo limits: 10 requests/min, 50/day, REST only. SSE needs a Stream, Pro + Drops or Scale plan; the raw WebSocket needs the +$89/mo add-on on one of those plans.

How it works

Connect, subscribe, receive. Subscribe by sport (every event in that sport) or by specific event IDs (up to 200 per stream). You get a snapshot of matching events first, then continuous update frames — adds, updates, deletes — only for what you subscribed to.

// connect
wss://pinnwire.com/ws?key=YOUR_KEY        // /ws/feed also works

// subscribe (within 10s of connecting)
{ "type": "subscribe",
  "streams":   ["live", "prematch"],
  "sport_ids": [1, 2],          // all Soccer + Tennis events
  "event_ids": [1631005165] }   // plus one specific match

// server acks each sub, sends a snapshot, then live frames:
{ "type": "live", "topic": "...", "op": "upd", "rec": { /* full record */ } }

Heartbeat every 30s (reply {"type":"pong"}), chunked snapshots for large baselines, per-market version numbers for clean deduping, and permessage-deflate compression if your client wants it. Full protocol in the docs.

A client you can paste

The whole client, no SDK: connect, subscribe, handle frames, reconnect. Node.js first, then the same thing in Python.

// npm i ws      (Node 18+)
import WebSocket from "ws";

const SUB = { type: "subscribe", streams: ["live", "prematch"], sport_ids: [1, 2] };
const book = new Map();          // key: rec.id + "|" + market key
let backoff = 1000;

function connect() {
  const ws = new WebSocket("wss://pinnwire.com/ws?key=YOUR_KEY");

  ws.on("open", () => { backoff = 1000; ws.send(JSON.stringify(SUB)); });  // subscribe within 10s
  ws.on("message", (buf) => {
    const m = JSON.parse(buf);
    switch (m.type) {
      case "connected":   console.log("events in store:", m.events_in_store); break;
      case "subscribed":  console.log("sub:", m.stream, m.sport_id ?? m.event_ids); break;
      case "ping":        ws.send('{"type":"pong"}'); break;   // or you are closed at ~75s
      case "snapshot":    m.events.forEach((rec) => apply(rec)); break;  // split frames carry seq/final
      case "live":
      case "prematch_ws": apply(m.rec, m.op); break;           // op: add / upd / del
      case "error":       console.error("feed error:", m.code); break;
    }
  });
  ws.on("close", (code, reason) => {
    console.log("closed", code, reason.toString());           // 1001 evicted/stale · 1008 key or plan · 1011 slow
    setTimeout(connect, backoff + Math.random() * 500);       // re-subscribe: the new snapshot is your sync point
    backoff = Math.min(30000, backoff * 2);
  });
  ws.on("error", () => {});                                   // close follows; logged above
}

function apply(rec) {
  for (const m of rec.markets ?? []) {
    const key = rec.id + "|" + (m.key ?? (m.type + "|" + m.period + "|" + m.side + "|" + (m.points ?? "")));
    if (m.status != null && m.status !== "open") { book.delete(key); continue; }  // close first
    const prev = book.get(key);
    if (!prev || prev.v !== m.version) book.set(key, { v: m.version, m });        // then version dedupe
  }
}

connect();
# pip install websockets
import asyncio, json, websockets

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

async def main():
    async for ws in websockets.connect("wss://pinnwire.com/ws?key=YOUR_KEY"):   # auto-reconnects
        try:
            await ws.send(json.dumps(SUB))          # within 10 s of connecting
            async for raw in ws:
                m = json.loads(raw)
                if m["type"] == "ping":
                    await ws.send('{"type":"pong"}')
                elif m["type"] == "snapshot":
                    print(len(m["events"]), "events, chunk", m.get("seq", "-"))
                elif m["type"] in ("live", "prematch_ws"):
                    print(m["op"], m["rec"]["id"])  # add / upd / del
                elif m["type"] == "error":
                    print("feed error:", m["code"])
        except websockets.ConnectionClosed as e:
            print("closed", e.code, e.reason)       # 1001 / 1008 / 1011
            continue                                # snapshot on reconnect is your sync point

asyncio.run(main())

Sport IDs, market fields and the full frame reference: docs. Field-by-field merge rules — close before dedupe, parent/child matchup IDs, period closes — are written out in /llms-full.txt.

If the socket drops

The server keeps no session state. Every connect gets {"type":"connected"}, you subscribe again, and the fresh snapshot is your sync point — that is why reconnecting is always safe and never needs a REST backfill.

Close codeReasonWhat to do
1001Evicted by a newer connection, or stale (no pong for ~75s)Reconnect — one connection per key, a second connect evicts the first
1008No subscribe within 10s, invalid key, or the plan lacks the WebSocket add-onFix the cause, then reconnect (a reconnect alone will not help)
1011deregistered: slow_consumer — you fell more than 32MB behindReconnect immediately; drain faster or narrow your subscription

Recommended loop: exponential backoff 1s → 2s → 4s → 30s cap with a little jitter, re-subscribe on open. Reply {"type":"pong"} to each 30s ping — the ping carries buffered_max_bytes, your send-backlog high-water mark, if you want a headroom number to alert on.

Compression is negotiated per connection: permessage-deflate shrinks frames ~5×, which helps on thin or long-haul links but adds a small latency tail during mass-update bursts. Enable it when bandwidth is the bottleneck; leave it off on a stable pipe. Node ws defaults to off, Python websockets to on.

Push vs poll, in one table

REST pollingWebSocket
When a price change reaches youOn your next request — up to a full poll interval laterThe moment it lands on the feed
What happens between callsNothing is delivered; you see it only in the next snapshotEvery add / update / delete frame, in order
Suspensions and closesInvisible until you ask again — the old price still reads openDelivered as they happen
Rate limitsEvery poll counts against your quotaOne persistent connection per key
Best forBackfill, dashboards, slow-moving prematch marketsLive pricing, drop detection, arbitrage reference legs

Measured latency numbers for the same feed over HTTP are published as-is on the latency page — push removes the polling interval; the rest is your network path.

What's in the stream

Everything the REST API serves, pushed: moneylines / match winner, spreads and handicaps, totals and team totals, every period, and — when published — props & specials. All 13 sports (soccer, tennis, basketball, hockey, football, baseball, rugby, MMA, boxing, volleyball/handball, esports, golf, cricket), live and prematch. REST and the SSE drop streams deliver decimal prices; the raw WebSocket forwards every feed frame verbatim, so its price fields arrive in the feed's native odds format instead — convert them if your application works in decimal.

Built for the same jobs as the rest of PinnWire: dropping-odds / steam detection, arbitrage reference legs, +EV and value-betting screens with no-vig fair prices, and betting-model pipelines.

Reliability details, stated plainly

One WebSocket connection per key (a second connect cleanly evicts the first with close code 1001, so a stuck client never blocks a reconnect). Subscribe acks confirm every request. If your consumer falls too far behind, the server closes with a slow_consumer reason instead of silently dropping frames. Push delivery has no client polling interval; end-to-end latency depends on network path and client region — measure under your own conditions.

For AI agents

The same data is exposed as read-only tools on the PinnWire MCP server (https://pinnwire.com/mcp?key=YOUR_KEY), and the complete plain-text API reference lives at /llms-full.txt.

Not ready for the full stream? Two lighter options

SSE drop alerts — a one-line stream that pings you the instant a price falls, live or prematch, with the no-vig fair price attached. The threshold defaults to 5% and is configurable down to 1% per connection. Included on Stream ($89/mo), Pro + Drops ($139/mo) and Scale ($219/mo) — the Pro plan ($89/mo) is REST-only and does not include SSE.

REST — ask anytime, get JSON. Works right now with the public demo key:

curl "https://pinnwire.com/kit/v1/markets?sport_id=1&key=demo"
# live soccer odds this second — demo key is public, no signup

Pricing

The raw WebSocket feed is a +$89/mo add-on on Pro, Pro + Drops and Scale. Plans from $89/mo, free trial key with no card. Full pricing · compare with alternatives.

Questions

Does Pinnacle have a WebSocket API?

Not publicly. The public API closed in July 2025; the native push feed is a manual-review B2B contract. PinnWire delivers the same real-time prices over a WebSocket you can have running in minutes.

WebSocket vs polling — does it matter?

Polling APIs refresh on a loop measured in seconds. Push delivers each change as it happens. For live markets and drop detection, that gap is the whole game — the table above spells out what each approach can and cannot deliver.

How fast is the feed?

Measured, not claimed: 1.5 ms median response on a warm in-region connection, 1.3 ms of it server-side — the raw numbers are published on the latency page. Push removes the polling interval entirely; what remains is your network path, so measure under your own conditions with the demo key.

Which sports and markets are streamed?

All 13 sports — soccer, tennis, basketball, hockey, football, baseball, rugby, MMA, boxing, volleyball/handball, esports, golf, cricket — live and prematch, covering moneylines / match winner, spreads and handicaps, totals and team totals on every period, plus props and specials when the feed publishes them. REST and the SSE drop streams deliver decimal prices; the raw WebSocket forwards every feed frame verbatim, so its price fields arrive in the feed's native odds format instead — convert them if your application works in decimal.

Do I need to rewrite my client to switch from polling?

No. The REST and SSE surfaces are drop-in compatible with common Pinnacle client formats, and the WebSocket carries the same market fields as REST — subscribe, then reuse your existing parser. The migration checklist is on the Pinnacle API migration page.

Can AI agents use this?

Yes — REST works instantly with key=demo, and the complete plain-text API reference lives at /llms-full.txt.