API Documentation
Sharp Pinnacle prices — in-play and pre-game — paired with a live steam-move detector, served over a clean REST API (drop-in compatible with the formats you already use) plus optional push streams over Server-Sent Events. The live feed rides Pinnacle's MQTT WebSocket, so a price change reaches you in roughly 200 ms.
Introduction
Everything hangs off one host: https://pinnapi.com. Read every path below as relative to it, and expect JSON back on every call.
Auth is a single API key — no username, no password; the key is your identity. Mint one free in a click from the landing page.
/kit/v1/* responses follow the same shape as the common third-party formats — point your existing client at this base URL with your key and it should run as-is.
/llms-full.txt dump — the whole reference as one plain-text file sized for model context. Crawlers that probe the root also pick up the slimmer /llms.txt index.
Authentication
Send your key any of three ways — we check them in this order:
x-portal-apikeyheader (recommended — matches legacy third-party APIs)x-api-keyheader?key=query parameter (useful for browsers / one-offs)
# Header (recommended) curl -H "x-portal-apikey: YOUR_KEY" \ "https://pinnapi.com/kit/v1/markets?sport_id=2&event_type=live" # Query string curl "https://pinnapi.com/kit/v1/markets?sport_id=2&event_type=live&key=YOUR_KEY"
Rate limits
Quotas are tracked per key over fixed time windows. Cross one and the next call comes back 429 with a Retry-After header telling you when to try again.
| Plan | Price | Second | Minute | Hour | Day | Total | SSE drops |
|---|---|---|---|---|---|---|---|
| Trial | $0 | — | 20 | 100 | 100 | Unlimited | — |
| Stream | $99/mo | — | 20 | 100 | 100 | Unlimited | ✓ Included |
| Pro | $99/mo | 10 req/sec | — | — | — | Unlimited | — |
| Pro + SSE | $149/mo | 10 req/sec | — | — | — | Unlimited | ✓ Included |
| Scale | $229/mo | 30 req/sec | — | — | — | Unlimited | ✓ Included |
Trial is capped at 100 requests per day total — once you exceed it you'll get 429 with window=day until UTC midnight rolls over. Stream ($99/mo) gives you push SSE drops only. Pro ($99/mo) gives you REST 10 req/sec only. Pro + SSE ($149/mo) bundles both — saves $49 vs buying separately.
# 429 response when you hit a limit HTTP/1.1 429 Too Many Requests Retry-After: 41 Content-Type: application/json { "error": "rate_limited", "window": "minute", "limit": 20, "retry_after_ms": 41052 }
Errors
| Status | Error code | When it happens |
|---|---|---|
| 401 | missing_key | No API key was sent |
| 401 | invalid_key | Key not in our DB (typo, deleted, or regenerated) |
| 401 | Unauthorized | SSE token missing or rejected (only on /odds-drop* streams) |
| 429 | rate_limited | Key exceeded its rate limit |
| 503 | prematch_disabled | Prematch endpoint hit while the prematch ingester is off |
Live odds
A complete snapshot of everything live right now. The store is fed by Pinnacle's MQTT WebSocket, so each request hands back current prices — usually under 200 ms behind the source.
Hands back every event for a sport, each carrying its full market set — moneyline, spreads, totals, team totals — across all periods (full match, 1st half, 2nd half, and so on). Defaults to live; add event_type=prematch to read the pre-game feed instead.
Query parameters
| Name | Type | Description |
|---|---|---|
| sport_id required | integer | Dropping-odds sport id. See Sport IDs. |
| event_type | "live" | "prematch" | Defaults to live. Pass prematch to receive prematch fixtures with the same response envelope. Both flavors return identical event shape — only event_type on each event differs. Equivalent to /kit/v1/prematch/fixtures with that param set. |
| since | integer | Return only events that changed since this last value (incremental polling). |
| is_have_odds | 0 | 1 | Legacy compat — accepted and ignored. We always return events with odds. |
event_type on each event. Code that walks events[*].periods.num_0 handles both feeds with zero branching.
curl -H "x-portal-apikey: $KEY" \
"https://pinnapi.com/kit/v1/markets?sport_id=2&event_type=live"
const res = await fetch( `${BASE}/kit/v1/markets?sport_id=2&event_type=live`, { headers: { "x-portal-apikey": KEY } } ); const { events, last } = await res.json();
import requests r = requests.get( f"{BASE}/kit/v1/markets", params={"sport_id": 2, "event_type": "live"}, headers={"x-portal-apikey": KEY}, ) data = r.json()
package main import ( "encoding/json" "net/http" ) req, _ := http.NewRequest("GET", BASE+"/kit/v1/markets?sport_id=2&event_type=live", nil) req.Header.Set("x-portal-apikey", KEY) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() var out struct { Events []map[string]interface{} `json:"events"`; Last int64 `json:"last"` } json.NewDecoder(resp.Body).Decode(&out)
<?php $ch = curl_init(BASE . "/kit/v1/markets?sport_id=2&event_type=live"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-portal-apikey: $KEY"], ]); $body = curl_exec($ch); curl_close($ch); $data = json_decode($body, true);
Response — see Event object
{
"sport_id": 2,
"sport_name": "Tennis",
"last": 141,
"last_call": 141,
"events": [
{ /* see Event object below */ }
]
}
Pull one event by its ID. The payload matches the per-event shape from /kit/v1/markets exactly.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Arcadia matchup ID from a previous /markets response. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/kit/v1/details?event_id=1628594960"
Prematch odds
Pre-game lines for fixtures that haven't kicked off yet. A dedicated REST poller keeps them fresh (every few seconds), held in its own store apart from the live feed — so hitting prematch never steals cycles from the in-play snapshot.
/kit/v1/markets when you want in-play prices and /kit/v1/prematch/fixtures for upcoming-fixture lines. Both speak the same sport IDs and the same event object, so a single field is all your code needs to branch on.
Lists all pre-game fixtures for a sport with their complete markets-and-periods tree. Identical in shape to /kit/v1/markets — the lone difference is the event_type field.
Query parameters
| Name | Type | Description |
|---|---|---|
| sport_id required | integer | Dropping-odds sport id. See Sport IDs. |
| since | integer | Return only events that changed since this last value (incremental polling). |
curl -H "x-portal-apikey: $KEY" \
"https://pinnapi.com/kit/v1/prematch/fixtures?sport_id=1"
const r = await fetch( `${BASE}/kit/v1/prematch/fixtures?sport_id=1`, { headers: { "x-portal-apikey": KEY } } ); const { events } = await r.json(); const withOdds = events.filter(e => e.is_have_odds && e.periods?.num_0);
import requests r = requests.get( f"{BASE}/kit/v1/prematch/fixtures", params={"sport_id": 1}, headers={"x-portal-apikey": KEY}, ) events = r.json()["events"] with_odds = [e for e in events if e.get("is_have_odds") and e.get("periods", {}).get("num_0")]
req, _ := http.NewRequest("GET", BASE+"/kit/v1/prematch/fixtures?sport_id=1", nil) req.Header.Set("x-portal-apikey", KEY) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() var out struct { Events []map[string]interface{} `json:"events"` } json.NewDecoder(resp.Body).Decode(&out) // out.Events[i] has periods, money_line, spreads, totals, team_total, etc.
<?php $ch = curl_init(BASE . "/kit/v1/prematch/fixtures?sport_id=1"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-portal-apikey: $KEY"], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); $with_odds = array_filter($data["events"], fn($e) => ($e["is_have_odds"] ?? false) && isset($e["periods"]["num_0"]));
Response — same shape as /kit/v1/markets with event_type: "prematch" on each event:
# → 200 OK — same envelope as /kit/v1/markets, only event_type differs { "sport_id": 1, "sport_name": "Soccer", "last": 523864, "last_call": 523864, "events": [ { "event_id": 1629513753, "sport_id": 1, "league_id": 5488, "league_name": "Italy - Serie A", "home": "Cremonese", "away": "Lazio", "starts": "2026-05-04T16:30:00Z", "start_ts": "2026-05-04T16:30:00Z", // alias of starts (back-compat) "event_type": "prematch", "is_have_odds": true, "is_have_periods": true, "periods": { "num_0": { "number": 0, "description": "Game", "money_line": { "home": 2.45, "draw": 3.30, "away": 2.90 }, "spreads": { "-0.5": { "hdp": -0.5, "home": 2.06, "away": 1.82, "max": 500 } }, "totals": { "2.5": { "points": 2.5, "over": 1.90, "under": 1.98, "max": 500 } }, "team_total": { "home": { "points": 1.5, "over": 1.86, "under": 2.00 } }, "meta": { "number": 0, "max_total": 500, "home_score": 0, "away_score": 0 } } // num_1 (1st half), num_2 (2nd half), etc. when offered }, "status": "pending" } ] }
The complete markets payload for one prematch event — every period, plus money line, spread, total and team total. Call it after you've picked a fixture out of /prematch/fixtures.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Pinnacle matchup ID. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/kit/v1/prematch/markets?event_id=1629513753"
A stripped-down view of one prematch event — just the active prices, with the period tree omitted. Much smaller than /markets, which makes it a good fit for tickers and quick reads.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Pinnacle matchup ID. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/kit/v1/prematch/lines?event_id=1629513753"
Dropping odds
Catch price moves ("drops") as they happen on both the live and prematch feeds. A drop is emitted when an outcome's decimal odds fall by at least your threshold inside the configured window, and every drop ships with its from and to price alongside the full event context.
A queryable, pull-style buffer of recent drops. Filter it however you like and read back the whole result set — handy for batch jobs, dashboards, or any polling client.
Query parameters
| Name | Type | Description |
|---|---|---|
| mode | "live" | "prematch" | Which feed to query. Defaults to live. |
| sport_id | integer | Filter to a single sport. Omit for all. |
| min_drop_pct | number | Minimum drop percentage (default 5). Fractional allowed. |
| max_drop_pct | number | Optional cap. |
| max_age_sec | integer | Only return drops fresher than this. Up to ~3 h. |
| markets | csv | Subset of moneyline,spread,total,team_total. |
| periods | csv | Period numbers (0=full match, 1=1st half, etc.). |
| live | 0 | 1 | Live-mode only. 1 = exclude any drops on events not yet started. |
| limit | integer | Max drops to return (default 500). |
# Live drops, soccer only, ≥7%, last 10 min curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/api/drops?mode=live&sport_id=1&min_drop_pct=7&max_age_sec=600" # Prematch drops, all sports, last hour curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/api/drops?mode=prematch&min_drop_pct=2&max_age_sec=3600"
const qs = new URLSearchParams({ mode: "live", sport_id: 1, min_drop_pct: 7, max_age_sec: 600, }); const r = await fetch(`${BASE}/api/drops?${qs}`, { headers: { "x-portal-apikey": KEY }, }); const { total, drops } = await r.json(); for (const d of drops) { const ev = (d.to / d.nvp) - 1; // edge vs no-vig price if (ev > 0.02) console.log("+EV", d.home, d.market, d.from, "→", d.to); }
import requests r = requests.get( f"{BASE}/api/drops", params={ "mode": "live", "sport_id": 1, "min_drop_pct": 7, "max_age_sec": 600, }, headers={"x-portal-apikey": KEY}, ) for d in r.json()["drops"]: ev = (d["to"] / d["nvp"]) - 1 if ev > 0.02: print("+EV", d["home"], d["market"], d["from"], "→", d["to"])
q := url.Values{}
q.Set("mode", "live"); q.Set("sport_id", "1")
q.Set("min_drop_pct", "7"); q.Set("max_age_sec", "600")
req, _ := http.NewRequest("GET", BASE+"/api/drops?"+q.Encode(), nil)
req.Header.Set("x-portal-apikey", KEY)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var out struct {
Total int `json:"total"`
Drops []struct {
Home, Market string; From, To, NVP float64
} `json:"drops"`
}
json.NewDecoder(resp.Body).Decode(&out)
<?php $qs = http_build_query([ "mode" => "live", "sport_id" => 1, "min_drop_pct" => 7, "max_age_sec" => 600, ]); $ch = curl_init(BASE . "/api/drops?$qs"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["x-portal-apikey: $KEY"], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($data["drops"] as $d) { $ev = ($d["to"] / $d["nvp"]) - 1; if ($ev > 0.02) echo "+EV {$d['home']} {$d['from']} → {$d['to']}\n"; }
# Response { "total": 42, "drops": [ { "event_id": 1629725918, "sport_name": "Soccer", "league": "Australia - NPL Victoria", "home": "Bentleigh Greens", "away": "St Albans Saints", "market": "spread", "period": 0, "side": "home", "points": -0.5, "from": 2.37, "to": 2.25, "drop_pct": 5.06, "age_s": 12, "is_live": false } ], "meta": { "mode": "prematch", "events_in_store": 2552, "tracked_outcomes": 122035 } }
A Server-Sent Events feed that pushes live drops the instant they fire — one long-lived TCP connection, no polling loop. Open to the Stream, Pro + SSE, and Scale plans. Trial keys and REST-only Pro keys can't connect.
Two auth methods accepted (use either):
| Name | Type | Description |
|---|---|---|
| key | string | Your SaaS API key, in the x-portal-apikey / x-api-key header or as ?key=. Plan must include SSE (Stream / Pro + SSE / Scale). |
| token | string | Legacy SSE token (separate from the API key) as ?token=. Kept for admin / pre-account customers. |
| min_drop | number | Optional. Drop-percent threshold for THIS subscriber. ?min_drop=7 = only alerts where price fell ≥7%. ?min_drop=1 = receive every drop, including small ones. Default if omitted: 5%. Floor: 1%. No upper cap. |
# With your API key (paid plan) curl -N "https://pinnapi.com/odds-drop?key=$API_KEY" # Or with a legacy SSE token curl -N "https://pinnapi.com/odds-drop?token=$SSE_TOKEN"
// npm i eventsource import EventSource from "eventsource"; const es = new EventSource(`${BASE}/odds-drop?key=${API_KEY}`); es.onmessage = (e) => { const payload = JSON.parse(e.data); if (payload.type === "connected") return; if (payload.type === "error") { console.error(payload.error, payload.message); return; } for (const drop of payload) { const ev = (drop.to_price / drop.nvp) - 1; if (ev > 0.02) console.log("+EV", drop.sport, drop.home, drop.from_price, "→", drop.to_price); } }; es.onerror = (err) => console.error("sse closed", err);
# pip install sseclient-py requests import json, requests from sseclient import SSEClient resp = requests.get(f"{BASE}/odds-drop?key={API_KEY}", stream=True) for ev in SSEClient(resp).events(): if not ev.data: continue payload = json.loads(ev.data) if isinstance(payload, dict) and payload.get("type") in ("connected", "error"): continue for drop in payload: ev_pct = (drop["to_price"] / drop["nvp"]) - 1 if ev_pct > 0.02: print("+EV", drop["sport"], drop["home"], drop["from_price"], "→", drop["to_price"])
package main import ( "bufio"; "encoding/json"; "net/http"; "strings"; "fmt" ) type Drop struct { Home, Sport string; FromPrice, ToPrice, NVP float64 } req, _ := http.NewRequest("GET", BASE+"/odds-drop?key="+API_KEY, nil) req.Header.Set("Accept", "text/event-stream") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } body := strings.TrimPrefix(line, "data: ") var drops []Drop if err := json.Unmarshal([]byte(body), &drops); err != nil { continue } for _, d := range drops { if ev := d.ToPrice/d.NVP - 1; ev > 0.02 { fmt.Println("+EV", d.Sport, d.Home, d.FromPrice, "→", d.ToPrice) } } }
<?php $ch = curl_init(BASE . "/odds-drop?key=$API_KEY"); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ["Accept: text/event-stream"], CURLOPT_TIMEOUT => 0, CURLOPT_WRITEFUNCTION => function($ch, $chunk) { static $buf = ""; $buf .= $chunk; while (($pos = strpos($buf, "\n\n")) !== false) { $line = substr($buf, 0, $pos); $buf = substr($buf, $pos + 2); if (strpos($line, "data: ") !== 0) continue; $payload = json_decode(substr($line, 6), true); if (!is_array($payload) || isset($payload["type"])) continue; foreach ($payload as $d) { $ev = ($d["to_price"] / $d["nvp"]) - 1; if ($ev > 0.02) echo "+EV {$d['sport']} {$d['home']} → {$d['to_price']}\n"; } } return strlen($chunk); }, ]); curl_exec($ch);
# Stream framing (what arrives over the wire)
data: {"type": "connected", "id": "d648beb8-bde8-..."}
data: [{"home": "Sunshine Coast Phoenix", "away": "Cairns Dolphins",
"league": "Australia - NBL1 Women", "from_price": 2.86,
"to_price": 2.7, "outcome": "Home", "period": 4,
"sect": "Moneyline", "id": 1629729400, "sport": "Basketball",
"interval": 25, "alerted": 1777625196, "sport_id": 3,
"nvp": 3.04, "starts": 1777624200, ...}]
{"type":"error","error":"plan_lacks_sse"} as the only frame. Upgrade to Stream, Pro + SSE, or Scale to access SSE.
Identical shape and auth to /odds-drop, except it only emits drops off the prematch feed. Handy when you're trading upcoming-fixture line moves and don't want in-play chatter drowning them out.
Optional query parameter: ?min_drop=N overrides the default 5% drop threshold for this subscriber (same as on /odds-drop). Composes with ?recheck=N. Floor is 1%.
One connection per account — see the same warning on /odds-drop. Live and prematch each count as one SSE slot, so you can run one of each in parallel under the default cap.
curl -N "https://pinnapi.com/odds-drop-prematch?key=$API_KEY"
// Identical to /odds-drop integration — only the URL changes. import EventSource from "eventsource"; const es = new EventSource(`${BASE}/odds-drop-prematch?key=${API_KEY}`); es.onmessage = (e) => { const p = JSON.parse(e.data); if (Array.isArray(p)) for (const d of p) console.log(d.sport, d.home, d.from_price, "→", d.to_price); };
import json, requests from sseclient import SSEClient resp = requests.get(f"{BASE}/odds-drop-prematch?key={API_KEY}", stream=True) for ev in SSEClient(resp).events(): p = json.loads(ev.data) if ev.data else None if isinstance(p, list): for d in p: print(d["sport"], d["home"], d["from_price"], "→", d["to_price"])
req, _ := http.NewRequest("GET", BASE+"/odds-drop-prematch?key="+API_KEY, nil) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024) for scanner.Scan() { line := scanner.Text() if !strings.HasPrefix(line, "data: ") { continue } // parse JSON array of drops, same shape as /odds-drop }
<?php // Same WRITEFUNCTION pattern as /odds-drop, just change the URL: $ch = curl_init(BASE . "/odds-drop-prematch?key=$API_KEY"); // ... see the /odds-drop PHP example for the full streaming reader ...
Optional: ?recheck=N stable-price filter
How to use it. Append &recheck=N to the URL where N is the number of seconds you want the server to wait before emitting each drop. The server holds the alert for N seconds, re-checks the current Pinnacle price for that exact line, and only sends it if the drop is still real (price hasn't bounced back). If you want a 30-second stability check:
# Standard prematch feed (instant, default — unchanged) curl -N "https://pinnapi.com/odds-drop-prematch?key=$API_KEY" # Wait 30 seconds, then re-verify before emitting curl -N "https://pinnapi.com/odds-drop-prematch?key=$API_KEY&recheck=30"
// EventSource (Node.js / browser) const es = new EventSource(`${BASE}/odds-drop-prematch?key=${API_KEY}&recheck=30`);
| Name | Type | Description |
|---|---|---|
| recheck | integer (seconds) |
How long to hold each drop before re-checking the current Pinnacle price. Surviving alerts pass the same 5% threshold the scanner used and gain a new rechecked_ms field with the actual wait time. Bounced prices are silently suppressed. Omit (or pass 0) for the default instant feed. No upper cap — the parameter only affects your own connection. Other customers connected without it are unaffected.
|
When to use this. If you place bets manually or your placement loop is >5s, prematch prices that bounce back within seconds create noise. Asking for recheck=30 trades 30s of latency for the confidence that the line was stable enough to be actionable.
Note. Available only on /odds-drop-prematch. The live stream /odds-drop is push-based and sub-second; a recheck window is semantically meaningless there and the parameter is silently ignored.
WebSocket Feed (Add-on)
Relays raw Pinnacle frames straight to your client — no drops, no derived metrics. You receive the live MQTT messages and prematch REST payloads byte-for-byte as they reach us, wrapped in a thin envelope. It's a power-user surface for anyone rebuilding Pinnacle's full state machine on their own side.
Don't confuse this with /odds-drop SSE. SSE sends only our filtered drop alerts (≥5% by default); this WebSocket sends every Pinnacle frame — including market open/close/settle signals — so expect a far heavier stream.
Pricing
- $99/month add-on on top of any REST-bearing plan (Pro, Pro + SSE, Scale).
- Stream and Trial plans are not eligible — they have no REST quota.
- New / renewal checkout: $99 (30d), $297 (90d), $594 (180d) bundled with your base plan.
- Mid-cycle upgrade: prorated to your renewal date — pay only for the days remaining. Quote via your dashboard.
Subscribe protocol
Your client has 5 seconds after connecting to send a subscribe message. Subscribe by sport (the full firehose), by specific event_ids (a tight filter), or mix both in one message:
{ "type": "subscribe",
"streams": ["live", "prematch"],
"sport_ids": [1, 2], // 1=Soccer, 2=Tennis — full firehose for the sport
"event_ids": [1631005165] // Pinnacle matchup IDs — narrow filter, just these events
}
Filter rule: a frame is delivered if its sport_id matches a sport-level sub or its event_id matches an event-level sub. So sport_ids:[2], event_ids:[12345] means "all of Tennis plus this one specific event in any sport". Either field can be omitted but at least one is required.
Discovery pattern: customers don't usually know matchup IDs upfront. Subscribe to a sport first → grep the snapshot for the events you care about (by team name, league, start time) → unsubscribe from the sport, subscribe to those specific event_ids. This narrows bandwidth to exactly the matches you're trading.
Per-stream cap: 200 event_ids per connection per stream. Subscribing past the cap returns {type:"error", code:"event_id_cap", ...} and the request is rejected wholesale. The hub auto-clears event_ids from your sub when Pinnacle removes the event (op:"del"), so you don't need to send unsubscribe yourself.
Server-sent messages
Immediately after each subscribe, one snapshot per subscription group:
// Sport-level subscribe → one snapshot per (stream, sport_id) with every // event currently in our store for that sport. { "type": "snapshot", "stream": "live", "sport_id": 1, "events": [ /* full Pinnacle records, untouched */ ], "ts": 1779200000000 } // Event-level subscribe → one snapshot per batch of event_ids, with just // the records we currently have. Missing IDs appear in the stream once // Pinnacle pushes them. { "type": "snapshot", "stream": "live", "event_ids": [1631005165], "events": [ /* full Pinnacle records, untouched (one per matched id) */ ], "ts": 1779200000000 }
Then continuous frames as Pinnacle pushes / we poll:
// Live MQTT — `topic`, `op`, `rec` exactly as we received from Pinnacle. { "type": "live", "sport_id": 1, "topic": "matchups/reg/sp/29/live/ld", "op": "upd", "rec": { /* Pinnacle record verbatim */ }, "ts": 1779200000123 } // Prematch REST poll responses — `data` is the response array verbatim. { "type": "prematch_matchups", "sport_id": 1, "arcadia_sid": 29, "data": [ /* /sports/29/matchups response verbatim */ ], "ts": 1779200005000 } { "type": "prematch_markets", "sport_id": 1, "arcadia_sid": 29, "matchup_id": 1631005165, "data": [ /* /matchups/.../markets/related/straight verbatim */ ], "ts": 1779200005230 } // Heartbeat every 30s — reply with {"type":"pong"} or socket closes after ~75s. { "type": "ping", "ts": 1779200030000 }
Fidelity contract. events, rec, and data are never transformed. We only add envelope fields (type, stream, sport_id, topic, op, ts). Your code can reuse a Pinnacle parser unchanged.
Frame flow on firehose subs. live, prematch_matchups and prematch_markets frames all flow on sport_ids subscriptions exactly the same way they do on event_ids subscriptions. There's no event-id-only mode — the filter is identical, only the criterion (whole sport vs. specific ids) changes.
Example clients
# npm i -g wscat — quick CLI client for ad-hoc inspection wscat -c "wss://pinnapi.com/ws/feed?key=$API_KEY" # then paste a subscribe frame at the prompt: {"type":"subscribe","streams":["live"],"sport_ids":[1,2]}
// npm i ws import WebSocket from "ws"; const ws = new WebSocket(`wss://pinnapi.com/ws/feed?key=${API_KEY}`); ws.on("open", () => { ws.send(JSON.stringify({ type: "subscribe", streams: ["live", "prematch"], sport_ids: [1, 2], // firehose Soccer + Tennis event_ids: [1631005165], // plus this specific matchup })); }); ws.on("message", (raw) => { const msg = JSON.parse(raw); switch (msg.type) { case "ping": ws.send(JSON.stringify({type:"pong"})); break; case "snapshot": console.log(`snapshot ${msg.stream}: ${msg.events.length} events`); break; case "live": console.log(`live event=${msg.rec.id} op=${msg.op}`); break; case "prematch_matchups": console.log(`matchups sport=${msg.sport_id} n=${msg.data.length}`); break; case "prematch_markets": console.log(`markets event=${msg.matchup_id} n=${msg.data.length}`); break; case "error": console.error(msg.code, msg.message); break; } }); ws.on("close", (code) => console.error("closed", code));
# pip install websockets import asyncio, json, os, websockets async def main(): url = f"wss://pinnapi.com/ws/feed?key={os.environ['API_KEY']}" async with websockets.connect(url) as ws: await ws.send(json.dumps({ "type": "subscribe", "streams": ["live", "prematch"], "sport_ids": [1, 2], "event_ids": [1631005165], })) async for raw in ws: msg = json.loads(raw) t = msg["type"] if t == "ping": await ws.send(json.dumps({"type": "pong"})) elif t == "snapshot": print(f"snapshot {msg['stream']}: {len(msg['events'])} events") elif t == "live": print(f"live event={msg['rec']['id']} op={msg['op']}") elif t == "prematch_matchups": print(f"matchups sport={msg['sport_id']} n={len(msg['data'])}") elif t == "prematch_markets": print(f"markets event={msg['matchup_id']} n={len(msg['data'])}") elif t == "error": print("error", msg["code"], msg.get("message")) asyncio.run(main())
// go get github.com/gorilla/websocket package main import ( "encoding/json"; "fmt"; "log"; "os" "github.com/gorilla/websocket" ) func main() { url := "wss://pinnapi.com/ws/feed?key=" + os.Getenv("API_KEY") ws, _, err := websocket.DefaultDialer.Dial(url, nil) if err != nil { log.Fatal(err) } defer ws.Close() sub := map[string]any{ "type": "subscribe", "streams": []string{"live", "prematch"}, "sport_ids": []int{1, 2}, "event_ids": []int64{1631005165}, } if err := ws.WriteJSON(sub); err != nil { log.Fatal(err) } for { _, raw, err := ws.ReadMessage() if err != nil { log.Fatal(err) } var msg map[string]json.RawMessage if err := json.Unmarshal(raw, &msg); err != nil { continue } var t string; json.Unmarshal(msg["type"], &t) switch t { case "ping": ws.WriteJSON(map[string]any{"type":"pong"}) case "snapshot": fmt.Println("snapshot", string(msg["stream"])) case "live": fmt.Println("live", string(msg["rec"])[:60]) case "error": fmt.Println("error", string(msg["code"])) } } }
// composer require textalk/websocket <?php use WebSocket\Client; $ws = new Client("wss://pinnapi.com/ws/feed?key=" . $_ENV["API_KEY"]); $ws->send(json_encode([ "type" => "subscribe", "streams" => ["live", "prematch"], "sport_ids" => [1, 2], "event_ids" => [1631005165], ])); while (true) { $raw = $ws->receive(); $msg = json_decode($raw, true); switch ($msg["type"]) { case "ping": $ws->send(json_encode(["type"=>"pong"])); break; case "snapshot": echo "snapshot {$msg['stream']}: " . count($msg["events"]) . " events\n"; break; case "live": echo "live event={$msg['rec']['id']} op={$msg['op']}\n"; break; case "error": fwrite(STDERR, "error: {$msg['code']} {$msg['message']}\n"); break; } }
Frame semantics — how to read what we forward
Pinnacle pushes incremental updates over MQTT. Once you start receiving frames, the op, topic, and inner rec/data shape determines how to merge into your local state.
Live frames — type:"live"
| Field | Type | Meaning |
|---|---|---|
| op | string | add = new event or new markets appeared. upd = incremental change (typically just changed markets — merge by composite key, don't replace the whole list). del = event removed by Pinnacle (kicked off, settled, voided) — drop your local copy. |
| topic | string | Source MQTT topic. Format: matchups/reg/sp/{arcadia_sid}/live/{ld|dz|both} for live, matchups/.../pre for prematch.ld = "line drawing": main markets (moneyline, 1X2, primary spread/total). dz = "draft zone": alternative spreads and totals. both = combined push covering both. matchups/spc/... instead of reg means a "special" matchup (props, futures). |
| rec.id | int | Pinnacle matchup ID. Stable across the event's lifetime. |
| rec.markets | array | May be a SUBSET on op:"upd" — Pinnacle only sends what changed. Merge by composite key (m.key if present, otherwise type|period|side|points). A market with m.status !== "open" is a CLOSE signal: drop it from your local list, do not merge it back as if still active. |
| rec.periods | array | Per-period status. periods[n].status === "closed" or "settled" implicitly closes ALL markets for that period — drop those locally too, even if they weren't listed in markets. |
| rec.parentId | int? | If present, this is a CHILD matchup (alt periods, props derived from a parent event). Its full metadata (participants, league) lives on the parent record. |
| rec.startTime, status | string | Event-level metadata. Tennis / Golf / Fights frequently see startTime updates without any market change (reschedules) — track these to surface to your users. |
Prematch frames — prematch_matchups + prematch_markets
| Field | Type | Meaning |
|---|---|---|
| type | string | prematch_matchups = response from /sports/{sid}/matchups. The full per-sport matchup list, fired every 5 s. Includes both prematch and live entries — filter by data[i].isLive === false if you want strict prematch.prematch_markets = response from /matchups/{id}/markets/related/straight. Fired only when Pinnacle bumped matchup.version, so roughly the events that had market activity in the last cycle. |
| data | array | For prematch_matchups: array of full matchup records (event metadata). For prematch_markets: array of market objects scoped to one matchup.This is the authoritative current snapshot from Pinnacle — any market in your local store that's NOT in this list has been closed by Pinnacle. Drop it. |
| matchup_id | int | (prematch_markets only) The matchup this markets array belongs to. |
| mode | string? | Present only when MQTT is down and we fell back to live REST polling. Value: "rest_fallback". Frame shape is otherwise identical, so customers usually ignore this field. |
Merge recipe (pseudocode)
// Maintain a Map<matchupId, MatchupState>. For each incoming frame: if (msg.type === "snapshot") { for (const ev of msg.events) state.set(ev.id, ev); } if (msg.type === "live") { if (msg.op === "del") { state.delete(msg.rec.id); return; } const existing = state.get(msg.rec.id) ?? {}; const merged = { ...existing, ...msg.rec }; if (msg.rec.markets) { const byKey = new Map((existing.markets ?? []).map(m => [marketKey(m), m])); for (const m of msg.rec.markets) { if (m.status && m.status !== "open") byKey.delete(marketKey(m)); // close signal else byKey.set(marketKey(m), m); } merged.markets = [...byKey.values()]; } // Honour period-level closes — wipe all markets for closed periods. if (msg.rec.periods) { const closed = new Set(msg.rec.periods.filter(p => p.status !== "open").map(p => p.period)); if (closed.size) merged.markets = merged.markets.filter(m => !closed.has(m.period ?? 0)); } state.set(msg.rec.id, merged); } if (msg.type === "prematch_markets") { // AUTHORITATIVE snapshot for one matchup — replace, don't merge. const existing = state.get(msg.matchup_id) ?? {}; state.set(msg.matchup_id, { ...existing, markets: msg.data }); } function marketKey(m) { return m.key ?? `${m.type}|${m.period ?? 0}|${m.side ?? ""}|${m.prices?.[0]?.points ?? ""}`; }
Reconnect strategy. Re-subscribe on reconnect. Server-side state is not preserved — the snapshot at the start of every connection is your sync point. Exponential backoff (1s → 2s → 4s → 30s cap) recommended. One connection per account on this endpoint (separate slot from SSE — you can run both).
Status
A live health read on both feeds — handy to wire into dashboards or alerting.
{
"status": "healthy",
"source": "ws", // ws | rest | none
"events_in_store": 1554,
"version": 297592,
"ws_messages": 595184,
"rest_polls": 0,
"uptime_s": 11938,
"prematch": {
"enabled": true,
"events_in_store": 2552,
"rest_polls": 132,
"source": "rest",
"drops": "on"
}
}
An unauthenticated liveness check that simply replies ok in plain text — built for load balancers and uptime monitors that can't attach an auth header.
curl https://pinnapi.com/ping
# ok
Sport IDs
These IDs are stable integers — code against one and it stays put. Each maps to Pinnacle's own internal sport ID (for example, our 1 = Pinnacle's 29 = Soccer).
| ID | Sport | Pinnacle internal ID |
|---|---|---|
| 1 | Soccer | 29 |
| 2 | Tennis | 33 |
| 3 | Basketball | 4 |
| 4 | Hockey | 19 |
| 5 | Football | 15 |
| 6 | Baseball | 3 |
| 7 | Rugby (Union + League) | 27, 26 |
| 8 | MMA (UFC, Bellator, ONE, etc.) | 22 |
| 9 | Boxing | 6 |
| 10 | Other (Volleyball, Handball) | 34, 18 |
| 11 | Esports (Dota 2, CS2, LoL, etc.) | 12 |
| 12 | Golf (PGA, DP World Tour, LIV — head-to-heads) | 17 |
About Golf: Pinnacle runs Golf as moneyline-only — head-to-head matchups and outright winners. There are no spreads, totals, or team totals, so treat spreads, totals, and team_total as empty objects on Golf events, and expect Golf drops to come only from the Moneyline section.
Market types
Each of these lives inside an event's periods[<num_N>] object.
| Field | Shape | Description |
|---|---|---|
| money_line | { home, away, draw } | 3-way (or 2-way for no-draw sports) match result. Decimal odds. |
| spreads | { "<hdp>": { hdp, home, away, max } } | Handicap markets keyed by the point spread. |
| totals | { "<points>": { points, over, under, max } } | Over/under markets keyed by the total. |
| team_total | { home: {...}, away: {...} } | Primary per-team total line per side. Backward-compatible — only ONE line per side. For all alt lines, use team_totals below. |
| team_totals | { home: { "<points>": {points, over, under, max} }, away: {...} } | ALL alternate team-total lines, keyed by points per side. Use this when Pinnacle exposes multiple lines (e.g. home Over 0.5 / 1.5 / 2.5). |
| meta | object | Per-period meta: current scores, max stakes. |
Event object
{
"event_id": 1628594960,
"sport_id": 1,
"league_id": 2037,
"league_name": "China - Super League",
"starts": "2026-04-17T12:00:00Z",
"last": 1776430365,
"home": "Yunnan Yukun",
"away": "Tianjin Jinmen Tiger",
"event_type": "live",
"live_status_id": 1,
"parent_id": 1628594613,
"is_have_odds": true,
"is_have_periods": true,
"periods": {
"num_0": {
"number": 0,
"description": "Game",
"money_line": { "home": 2.450, "draw": 3.300, "away": 2.900 },
"spreads": { "-0.5": { "hdp": -0.5, "home": 2.060, "away": 1.820, "max": 500 } },
"totals": { "2.5": { "points": 2.5, "over": 1.900, "under": 1.980, "max": 500 } },
"team_total": { "home": { "points": 1.5, "over": 1.860, "under": 2.000 } },
"team_totals": {
"home": {
"0.5": { "points": 0.5, "over": 1.200, "under": 4.500, "max": 100 },
"1.5": { "points": 1.5, "over": 1.860, "under": 2.000, "max": 100 },
"2.5": { "points": 2.5, "over": 3.200, "under": 1.400, "max": 100 }
},
"away": { /* same shape */ }
},
"meta": { "number": 0, "max_total": 500, "home_score": 0, "away_score": 1 }
},
"num_1": { /* first half */ }
},
"state": {
"match": { "state": 1, "minutes": 23 },
"home": { "score": 1, "redCards": 0, "yellowCards": 1, "corners": 3 },
"away": { "score": 0, "redCards": 0, "yellowCards": 2, "corners": 5 },
"home_stats": [{ "period": 0, "score": 1, "redCards": 0 }, { "period": 1 }],
"away_stats": [{ "period": 0, "score": 0, "redCards": 0 }, { "period": 1 }]
}
}
Live state object
Live events carry a state object holding in-game data, forwarded from Pinnacle exactly as it lands on their MQTT feed. The field names are sport-specific and we leave them untouched. On prematch events state is null.
The object breaks down into three top-level fields:
state.match— overall match-level state (clock / current period). Sport-dependent.state.home/state.away— current state for each participant.state.home_stats/state.away_stats— array indexed by period, only populated when Pinnacle has published a stat value for that period.
| Sport | Match-level fields | Per-participant fields |
|---|---|---|
| Soccer (1) | state (period state code, 1 = 1H, 2 = 2H, etc.), minutes (game clock minute) |
score, redCards, yellowCards, corners |
| Basketball (3) | quarter (1-4 + OT), timeRemainingInQtr (string "MM:SS") |
score, scoreByQuarter (array of N integers) |
| Tennis (2) | set (current set number) |
setsWon, gamesBySet (array), points (string: "0"/"15"/"30"/"40"/"Adv"), serving (boolean) |
| Other sports | Varies. Common: period / state |
Always score. Sport-specific extras may appear. |
Heads-up: a field only shows up once Pinnacle has a real value for it — corners, for instance, stays absent in soccer until the first corner is taken. Assume every field is optional and code defensively.
Where you'll find it: only GET /kit/v1/markets?event_type=live returns state. The prematch endpoints (/kit/v1/prematch/*) never include it — none of these fields exist before kickoff — and the /odds-drop SSE stream omits it too. Hit the REST endpoint whenever you need live game state.
Affiliate program
Every account comes with its own referral link. Refer someone who signs up through it and turns into a paying customer, and you earn 30% of every payment they make for the life of their subscription — first charge, renewals, upgrades, the lot.
| Topic | Detail |
|---|---|
| Your link | Shown in your dashboard. Format: https://pinnapi.com/?ref=<8-char-code>. Copy + share anywhere. |
| Commission | 30% of every USD payment your referrals make. Compounds across renewals and upgrades — not a one-time bounty. |
| Attribution | A signup is attributed if the visitor lands on pinnapi.com with your ?ref= parameter and then signs up + verifies email in the same browser session. We don't use cross-device cookies — straight session-based attribution. |
| Tracking | Your panel shows a live list of every signup attributed to you, whether they're on a paid plan, and how much you've earned (unpaid + paid out). |
| Payouts | Processed manually. Once your unpaid balance reaches $50+ we'll reach out to arrange transfer (crypto, bank transfer, or PayPal — whichever you prefer). Faster payouts are available on request — email [email protected]. |
| Self-referral | Blocked. You can't sign up via your own link. |
| Refunds | If a referred customer's payment is refunded by us (rare), the corresponding commission is voided. We don't claw back already-paid commissions for routine churn — only for actual refunds. |
Privacy: in your panel, referred users' emails are masked (e.g. j***@gmail.com) so you can recognize friends you invited without exposing full addresses if you screenshot the dashboard. Admins see unmasked emails in our internal tooling.