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.
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.
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.
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.
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 code | Reason | What to do |
|---|---|---|
| 1001 | Evicted by a newer connection, or stale (no pong for ~75s) | Reconnect — one connection per key, a second connect evicts the first |
| 1008 | No subscribe within 10s, invalid key, or the plan lacks the WebSocket add-on | Fix the cause, then reconnect (a reconnect alone will not help) |
| 1011 | deregistered: slow_consumer — you fell more than 32MB behind | Reconnect 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.
| REST polling | WebSocket | |
|---|---|---|
| When a price change reaches you | On your next request — up to a full poll interval later | The moment it lands on the feed |
| What happens between calls | Nothing is delivered; you see it only in the next snapshot | Every add / update / delete frame, in order |
| Suspensions and closes | Invisible until you ask again — the old price still reads open | Delivered as they happen |
| Rate limits | Every poll counts against your quota | One persistent connection per key |
| Best for | Backfill, dashboards, slow-moving prematch markets | Live 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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
Yes — REST works instantly with key=demo, and the complete plain-text API reference lives at /llms-full.txt.