Contents
Pinnacle API Documentation
Live and prematch odds from Pinnacle, plus a real-time dropping-odds engine — delivered over a REST API that mirrors popular third-party formats, with optional Server-Sent Events streams for push-based clients. Underlying live feed is MQTT WebSocket so updates arrive within ~200 ms.
Introduction
Base URL for this deployment: https://pinnodds.com. Every endpoint below is relative to that host. All responses are JSON.
All requests are authenticated with an API key — your key is your login; there's no username or password. Get a free trial key in one click from the landing page.
/kit/v1/* endpoints mirror the shape of common third-party Pinnacle API formats. Swap the base URL + key in your existing client and it should just work.
/llms-full.txt — full API reference in one plain-text fetch, optimized for LLM context windows. The shorter /llms.txt index is auto-discovered by tools that probe the root.
Authentication
Pass your API key in one of three ways, in priority 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://pinnodds.com/kit/v1/markets?sport_id=2&event_type=live" # Query string curl "https://pinnodds.com/kit/v1/markets?sport_id=2&event_type=live&key=YOUR_KEY"
Rate limits
Limits are enforced per-key across fixed windows. When you exceed one, you get 429 with a Retry-After header.
| 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 |
|---|---|---|
| 400 | — | Malformed or missing required parameter |
| 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) |
| 403 | plan_lacks_sse | Key is valid but the plan doesn't include SSE streams (Stream, Pro + SSE, Scale only) |
| 403 | plan_lacks_ws | Key is valid but the WebSocket add-on isn't active on the account |
| 413 | payload_too_large | Request body over 64 KB (POST endpoints) |
| 429 | rate_limited | Key exceeded its rate limit — honor the Retry-After header. Also sent on connection floods to SSE/WS endpoints |
| 500 | internal | Unexpected server error — safe to retry with backoff |
| 503 | prematch_disabled | Prematch endpoint hit while the prematch ingester is off |
Live odds
Snapshot of every event currently in-play. Updates arrive via the upstream MQTT WebSocket so each call returns fresh prices (typical lag <200 ms).
Returns every event for a sport, with all markets (moneyline, spreads, totals, team totals) across every period (match, 1st half, 2nd half, etc.). Live by default; pass event_type=prematch to switch feeds.
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). |
| include_specials | 1 | nested | Include special-market events (Player Props, Team Props, Exact Scores, futures). 1 = flat extra rows linked by parent_id (delta-friendly); nested = grouped under each parent event's specials array (one row per match). Off by default. See Player props & specials. |
| is_have_odds | 0 | 1 | Legacy compat — accepted and ignored. We always return events with odds. |
event_type on each event differs. Existing client code that iterates events[*].periods.num_0 works for both feeds without branching.
curl -H "x-portal-apikey: $KEY" \
"https://pinnodds.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 */ }
]
}
Fetch a single event by ID. Same event shape as /kit/v1/markets.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Arcadia matchup ID from a previous /markets response. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/details?event_id=1628594960"
Prematch odds
Pinnacle's prematch lines for upcoming fixtures. Backed by a separate REST poller (refreshes every 30 s) and stored independently from the live feed — querying it never starves the live snapshot.
/kit/v1/markets for in-play prices, /kit/v1/prematch/fixtures for upcoming-event lines. The two surfaces share the same sport IDs and event-object shape so client code can branch on a single field.
Every prematch fixture for a sport, with its current full markets/periods structure. Mirrors the shape of /kit/v1/markets — only the event_type field differs.
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). |
| include_specials | 1 | nested | Include special-market events (Player Props, Team Props, Exact Scores, futures). 1 = flat extra rows linked by parent_id (delta-friendly); nested = grouped under each parent event's specials array (one row per match). Off by default. See Player props & specials. |
curl -H "x-portal-apikey: $KEY" \
"https://pinnodds.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" } ] }
Full markets payload for a single prematch event — every period, money line, spread, total, team total. Use this once you've picked an event from /prematch/fixtures.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Pinnacle matchup ID. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/prematch/markets?event_id=1629513753"
Line view for a single prematch event, optionally narrowed to one market type. Returns the same periods structure (num_0, num_1, …) as /markets; with market_type set, each period carries only that market — a lighter payload, ideal for tickers.
| Name | Type | Description |
|---|---|---|
| event_id required | integer | Pinnacle matchup ID. |
| market_type | string | Filter to one market: money_line | spreads | totals | team_total. Omit for all markets. |
curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/prematch/lines?event_id=1629513753&market_type=totals"
Player props & specials
Beyond the main match markets, we carry Pinnacle's special markets: Player Props (anytime/first goalscorer, "player X to score", player to be booked), Team Props (HT/FT, winning margin, exact total goals, odd/even, 3-way handicaps, "team to score" …), Exact Scores (correct score), and tournament futures (to reach the final, outrights). Each special is its own event row with its own event_id, linked to the main match by parent_id.
Specials are excluded by default so existing integrations keep their payload size — opt in with include_specials=1 (flat rows, cheapest with ?since polling) or include_specials=nested (specials grouped under each parent event's specials array — one row per match) on /kit/v1/prematch/fixtures or /kit/v1/markets. To fetch a single special's prices, pass its event_id to /kit/v1/prematch/markets — no flag needed there.
Special-row fields (on top of the standard event fields):
| Field | Type | Description |
|---|---|---|
| special | string | Market description, e.g. "Anytime Goalscorer", "Mohamed Salah To Score", "Winning Margin". |
| special_category | string | "Player Props" | "Team Props" | "Exact Scores" | "Futures" | … |
| special_units | string | Units the outcomes are counted in (e.g. "Regular", "Corners", "Runs"). |
| parent_id | integer | The main match's event_id — group a fixture's specials by this. |
| special_markets | object | Named-outcome prices per period: {"num_0": [{type, key, side, max_risk, prices: [{name, participant_id, points, price}]}]}. price is decimal odds; name is the outcome ("Mohamed Salah", "Yes", "2-1", "4+"); max_risk is Pinnacle's max stake for the market. |
Example — player props for Australia vs Egypt (main event 1632164518):
curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/prematch/fixtures?sport_id=1&include_specials=1" # → rows with parent_id 1632164518 include: # 1632190502 "Anytime Goalscorer" (Player Props) # 1632190468 "First Goalscorer" (Player Props) # 1632278803 "Mohamed Salah To Score" (Player Props) # 1632181240 "Winning Margin" (Team Props) … ~50 more curl -H "x-portal-apikey: $KEY" \ "https://pinnodds.com/kit/v1/prematch/markets?event_id=1632278803"
{
"events": [{
"event_id": 1632278803,
"home": "Australia", "away": "Egypt",
"parent_id": 1632164518,
"special": "Mohamed Salah To Score",
"special_category": "Player Props",
"special_markets": {
"num_0": [{
"type": "moneyline", "max_risk": 1000,
"prices": [
{ "name": "No", "price": 1.33 },
{ "name": "Yes", "price": 3.43 }
]
}]
}
}]
}
/api/drops with participant_name set. Specials are thin markets — check max_risk before sizing. Like all prematch rows, a special's ID dies at kickoff.
Dropping odds
Detect price movements ("drops") in real time across both live and prematch feeds. A drop fires when an outcome's decimal odds fall by ≥ a threshold in a configured window. Each drop carries the from and to price plus full event context.
Pull-style queryable buffer of recent drops. Apply filters and read the full result set — useful for batch jobs, dashboards, or polling clients.
Each drop carries nvp — the vig-removed ("no-vig") fair price for that outcome, the same value emitted on the SSE streams. Use it to gauge edge, e.g. edge = to / nvp - 1.
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://pinnodds.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://pinnodds.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, "nvp": 2.34, "drop_pct": 5.06, "age_s": 12, "is_live": false } ], "meta": { "mode": "live", "events_in_store": 2552, "tracked_outcomes": 122035, "drops_buffered": 968, "source": "ws", "ts": 1783079852368 } }
Note. Drops on special markets (player/team props) carry participant_name — the named outcome that moved (e.g. "Mohamed Salah") — with nvp: null (no two-way devig exists for multi-outcome ladders).
Server-Sent Events stream of live drops as they happen. One TCP connection, push-based — no polling. Available on the Stream, Pro + SSE, and Scale plans. During your first 3 days every new key can connect (the signup demo includes the WebSocket feed). After that, trial keys and Pro (REST-only) cannot 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://pinnodds.com/odds-drop?key=$API_KEY" # Or with a legacy SSE token curl -N "https://pinnodds.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.
Connect throttling. Rapid reconnect loops get a single {"type":"error","error":"rate_limited"} frame with a Retry-After: 60 header — back off instead of hammering the endpoint; a healthy client reconnects with exponential backoff.
Same shape and auth as /odds-drop, but emits drops on the prematch feed only. Useful when you want to trade upcoming-fixture line moves and not be flooded with in-play noise.
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://pinnodds.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://pinnodds.com/odds-drop-prematch?key=$API_KEY" # Wait 30 seconds, then re-verify before emitting curl -N "https://pinnodds.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)
Forwards raw Pinnacle frames to your client — not drops, not derived metrics. Live MQTT messages and prematch REST responses delivered byte-identical to what we receive ourselves, wrapped in a thin envelope. Power-user feature for customers who want to rebuild Pinnacle's full state machine on their side.
This is NOT the same as /odds-drop SSE. SSE delivers our filtered drop alerts (every detected drop by default — the engine floor is ~0.99%; narrow it with ?min_drop=). This WS delivers every Pinnacle frame including market open/close/settle signals. Output volume is much higher.
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
Client must send a subscribe message within 5s of connecting. You can subscribe by sport (firehose) or by specific event_ids (narrow filter), or 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 }
Large snapshots arrive in multiple frames. A big baseline (e.g. prematch soccer, thousands of events) is split across several snapshot frames so no single message overruns your client's max-message limit. The split frames carry "seq" (0, 1, 2, …) and "final" (true only on the last); a small baseline that fits in one frame is sent as a single snapshot with no seq/final. Merge each frame's events into your book by event id (the same way you apply the deltas that follow) and you need no special handling. If instead you rebuild your book from the snapshot wholesale, wait for final: true before treating the baseline as complete.
// A split (chunked) snapshot — merge events from every frame until final:true. { "type": "snapshot", "stream": "prematch", "sport_id": 1, "seq": 0, "final": false, "events": [ /* first chunk */ ], "ts": 1779200000000 } { "type": "snapshot", "stream": "prematch", "sport_id": 1, "seq": 1, "final": true, "events": [ /* last chunk */ ], "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 } // Membership delta — what JOINED or LEFT the prematch set this cycle. // MQTT never announces this: /pre carries prices for matchups you already // track, and is blind to the set itself. Measured over 10 min across all 13 // sports: 34 membership changes, 0 announced by MQTT. This frame is the only // timely signal — including when a matchup leaves prematch for warmup/live. { "type": "prematch_membership", "sport_id": 1, "arcadia_sid": 29, "added": [ 1634753718, 1634753879 ], "removed": [ 1634713508 ], "ts": 1779200005010 } // Heartbeat every 30s — reply with {"type":"pong"} or socket closes after ~75s. // buffered_max_bytes = the highest server-side send backlog for YOUR socket // since the previous ping, then reset. Steady-state 0 means you are draining // as fast as we send; a number that climbs across pings means your read loop // is falling behind, and at 128 MB you are evicted with // 1011 "deregistered: slow_consumer". Track it to see headroom, not just breaches. { "type": "ping", "ts": 1779200030000, "buffered_max_bytes": 0 }
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.
prematch_membership — use this to track the set.
MQTT /pre is a price channel. It carries updates for matchups already in your set and is
structurally blind to the set’s boundaries — it never says “this fixture now exists” or
“this one is gone”. Measured on production over 10 minutes across all 13 sports:
34 membership changes, 0 announced by MQTT.
A client relying on prematch_ws alone therefore misses new fixtures entirely and
holds removed ones as live odds forever. It is also why push goes quiet for a matchup that has moved to
warmup/live: that transition is a membership change, and MQTT does not report it.
prematch_membership answers the same question as the full board at roughly
1/50,000th the bytes, every cycle (~25 s) instead of every 10 minutes.
Recommended consumption: take the snapshot chunks as your baseline → apply
prematch_ws for prices → apply prematch_membership (fetch details for
added, drop removed) → ignore prematch_matchups unless you
specifically want the periodic full resync. Complete, and never transfers a ~19 MB board.
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.
ld vs dz. Pinnacle runs two update channels for every live match, and both arrive on your one WebSocket. Which channel a live frame came from is the last segment of its topic — e.g. matchups/reg/sp/29/live/ld:
ld— live_delay, the primary channel (~85% of updates). This is the current price — the same line pinnacle.com shows. Treat it as your source of truth.dz— danger_zone (~15%), fired only in volatile moments (goal threat, break point, red card) when Pinnacle is repricing hard and often about to suspend. Earliest signal of a fast move, but jumpy.pre— a prematch matchup update carried on the live stream.
const channel = msg.topic.split("/").pop(); ("ld" | "dz" | "pre").
Key your price/book on ld; use dz as a volatility/suspension signal, not a second price. Merging both channels into one book makes the price appear to “spike and revert” — that’s the two channels interleaving, not a line move and not out-of-order delivery (each single market’s stream is strictly ordered; verify with rec.version per event_id+channel). This split is live only — prematch has no danger_zone.
event_id, not by teams. Pinnacle issues a live match as a parent matchup plus re-issued/related child matchups, all sharing the same rec.parentId and the same team names. The same market (identical market key, e.g. s;0;s;-1.5) can therefore appear under two different event_ids at the same time — one live, one stale/closing — with different prices.
- Key your book by
rec.id(the matchup/event id) + marketkey— never by team names orparentId. If you merge markets across matchup ids, the two ids’ prices interleave and the market appears to “spike and revert” even though each individual matchup’s stream is perfectly stable and strictly ordered. That’s an assembly artifact, not a line move — pinnacle.com and our REST/kitendpoints don’t show it because they collapse to one canonical matchup per fixture. - Dedupe on the market
version. We forward every upstream frame verbatim, and Pinnacle re-sends unchanged markets frequently (samemarkets[i].version, same price — often >50% of updates). Skip a market whoseversionyou’ve already applied to drop that noise.
/kit/v1/markets) which already do this.
markets[i].version for change-detection, NOT rec.version. A live record carries a top-level rec.version AND a per-market markets[i].version, and they behave differently:
rec.versionis a coarse record/board stamp. Pinnacle freezes it for the whole match once a fixture is in-play — it does not increment when a price changes. Keying dedup or ordering on it will make live prices look like they “change under a frozen version” and cause you to drop real price updates.markets[i].versionis the real per-market counter. It increments on every genuine price change and is strictly monotonic per(event_id, market key). This is the field to dedupe and order on.
rec.version stays constant, that is not a re-published/stale frame — check markets[i].version, which will have advanced. A spike-then-revert whose intermediate frame has a higher market version is a genuine transient Pinnacle reprice (relayed verbatim), not a replay.
Opening a second connection does not fail the new connection — it evicts the oldest one with
close code 1001, reason "evicted by newer connection".
This is the most common cause of “the socket goes silent for long stretches while REST stays healthy”. The trigger is usually not a deliberate second consumer, but a monitoring or logging process sharing the key, a deploy where the new instance connects before the old one closes, or a retry loop that reconnects without closing the previous socket. Two consumers on one account kick each other continuously, and whichever socket lost the race receives nothing.
Supported pattern: open one connection and fan out to your components inside your own process — this also guarantees your components share an identical view of the book. If you need transport-level separation, contact us about a second credential.
Close codes:
1001 "evicted by newer connection" ·
1001 "stale" (no pong for 75 s) ·
1001 "server shutdown" ·
1011 "deregistered: <cause>" ·
1006 with no reason (abnormal close — historically an oversized frame exceeding your
client's max-message limit).
On
1011 "deregistered: …". This means a server-side fault dropped you out of the
fan-out — a failed write, or backpressure eviction. Causes seen today are
deregistered: send_err, deregistered: send_threw and
deregistered: slow_consumer. Match on the deregistered: prefix, not on
the full string, so any cause added later is still caught. It is deliberately outside the 1001
lifecycle family so you can distinguish "we closed you on purpose" from "something broke". Treat it
as retryable: reconnect immediately, no cooldown needed.
permessage-deflate is negotiated per connection, so your client decides.
What it costs. Compression uses context takeover, which makes it a strictly serial per-connection pipeline: every message waits for the previous message's zlib job. Pinnacle pushes in mass-update cycles, and during those bursts the compression queue backs up — frames already carrying their
ts get compressed and flushed together, so you receive a burst of frames
whose timestamps span more than a second. Measured back-to-back on the same subscription
(soccer + tennis live, ~62 frames/s, from a client with ample bandwidth),
local_recv − ts:
| deflate on | deflate off | |
|---|---|---|
| p50 | 43 ms | −46 ms |
| p95 | 748 ms | 136 ms |
| p99 | 1241 ms | 187 ms |
| max | 1527 ms | 228 ms |
| frames > 1 s | 2.14 % | 0.00 % |
How to choose. Stable connection, bandwidth not your limit → turn it off, the tail disappears. Far from the server, thin link, or many sports at high volume → leave it on. Don't guess — measure
local_recv − ts percentiles both ways on your own path.
Defaults differ by language: Python's
websockets is on by default
(compression=None to disable); Node's ws ({ perMessageDeflate: true })
and Go's gorilla/websocket (EnableCompression: true) are off by default.
Same URL, same JSON either way — decompression is transparent.
prematch_matchups 512 KB and snapshot 512 KB (both chunked, bounded
by construction); prematch_markets 32 KB and live 13 KB (not
chunked, scoped to a single event).
Before 2026-07-22 19:48 UTC these frames were not chunked — the soccer snapshot reached ~19 MB and the bulk
prematch_matchups frame ~10 MB, so any client whose
limit sat below that had the socket dropped with 1006 and no close frame.
Example clients
# npm i -g wscat — quick CLI client for ad-hoc inspection wscat -c "wss://pinnodds.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"; // Compression adds a ~1.2s p99 tail during mass-update bursts — leave it OFF // unless bandwidth is your bottleneck. maxPayload 8 MB clears the 512 KB chunk ceiling. const ws = new WebSocket(`wss://pinnodds.com/ws/feed?key=${API_KEY}`, { perMessageDeflate: false, maxPayload: 8 * 1024 * 1024 }); 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://pinnodds.com/ws/feed?key={os.environ['API_KEY']}" # websockets enables compression BY DEFAULT — it serializes sends and adds a # ~1.2s p99 tail during mass-update bursts. Pass compression=None unless your # link (not our server) is the bottleneck. max_size clears the 512 KB chunk ceiling. async with websockets.connect(url, compression=None, max_size=8 * 1024 * 1024) 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://pinnodds.com/ws/feed?key=" + os.Getenv("API_KEY") // EnableCompression stays false: it serializes sends and adds a ~1.2s p99 tail. // Turn it on only if your link (not our server) is the bottleneck. dialer := websocket.Dialer{EnableCompression: false} ws, _, err := dialer.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://pinnodds.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. For a large sport (e.g. soccer) this frame is split into several chunks tagged seq/final (same as the subscribe snapshot); when chunked, the authoritative set is the union across the whole chunk group — accumulate until final: true before dropping anything. |
| 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; opening a second WS connection evicts the first with close code 1001).
Limits & error frames
| Error / behavior | Trigger | What to do |
|---|---|---|
| sport_id_cap | More than 32 sport_ids in one subscribe | Split into batches (there are ~14 real sports — you'll rarely hit this) |
| event_id_cap | More than 200 tracked event_ids per stream | Unsubscribe finished events; slots auto-free on op:"del" |
| subscribe_rate_limited | More than 30 subscribe messages per 10 s | Batch sports/events into fewer subscribe messages |
| backpressure | Subscribe while your connection is backlogged (>32 MB unsent) | The sport was NOT subscribed — drain, then resubscribe |
| slow_consumer (close) | Unsent buffer exceeds 128 MB — you're reading too slowly | Connection is closed; reconnect and read faster / subscribe narrower |
| rate_limited (HTTP 429) | Connection-open flood on the endpoint | Back off per Retry-After; don't reconnect in a tight loop |
| plan_lacks_ws (HTTP 403) | WS add-on not active on the account | Purchase the add-on in the dashboard |
| invalid_json / invalid_message / unknown_message_type | Malformed client message | Fix the message shape — see subscribe example above |
You can also send {"type":"unsubscribe", "streams":[…], "sport_ids":[…]}; every subscribe/unsubscribe is acknowledged with a {"type":"subscribed"|"unsubscribed", "stream", "sport_id"} frame — treat the ack (plus the snapshot) as confirmation, not the send itself.
SportsMeld — team & event matching Free 30-day trial
SportsMeld is our separate entity-resolution service: it decides whether two team/player names refer to the same real-world entity ("Man Utd" = "Manchester United" ≠ "Man City"), and clusters the same fixture across bookmaker feeds. Use it to join this API's Pinnacle names to any other book you consume — no hand-maintained alias tables. 8 sports, high-precision hybrid ML pipeline, cached lookups in sub-millisecond time.
It runs on its own base URL with its own key (not your pinnodds key):
Authorization: Bearer sportsmeld_... against
https://sportsmeld.arbitragex.pro. Full reference:
sportsmeld.arbitragex.pro/docs.
Name matching — one reference vs candidate names:
POST https://sportsmeld.arbitragex.pro/api/match { "reference": "Manchester United", "names": ["Man Utd", "Man City", "MUFC"], "sport": "soccer" } // → details[]: { name, match: true|false, confidence, ambiguous, method }
Fixture matching — cluster the same game across 2+ books:
POST https://sportsmeld.arbitragex.pro/api/match-events { "sport": "soccer", "sources": [ { "name": "pinnodds", "events": [{ "id": "1632373638", "left": "Manchester United", "right": "Liverpool FC", "timestamp": 1720008000 }] }, { "name": "bet365", "events": [{ "id": "b77", "left": "Man Utd", "right": "Liverpool", "timestamp": 1720008000 }] } ] } // → matched[] clusters (ids echoed back), partial[], unmatched{}, stats // timestamps optional; when both sides have one, matches only within ±12h
Launch offer: every pinnodds user gets a free 30-day trial on the scout tier —
15 req/s, 25 names per /api/match call, 5 sources & 500 events/source on
/api/match-events. Free during launch; paid plans later.
Claim it yourself in your panel (Subscription card → "Get your free key")
— the key is shown once and emailed to your account address, and you can rotate it from the
panel any time (rotation never changes your expiry).
Questions? Telegram @ArbitrageXpro.
Status
Operational status of both feeds. Useful for dashboards and alerts.
{
"status": "healthy",
"source": "ws", // ws | rest | none
"events_in_store": 1554,
"version": 297592,
"ws_messages": 595184,
"rest_polls": 0,
"uptime_s": 11938,
"connected_clients": 21, // live SSE subscribers
"drops_pipeline": "on",
"drops_mode": "event-driven",
"drops_pg": "off",
"prematch": {
"enabled": true,
"events_in_store": 2552,
"version": 51375,
"rest_polls": 132,
"source": "rest",
"connected_clients": 14,
"drops": "on"
}
}
Public liveness probe. Returns ok in plain text. Useful for load balancers and uptime monitors that can't send an auth header.
curl https://pinnodds.com/ping
# ok
Sport IDs
Sport IDs are stable integers — once you've coded against an ID it stays valid. Mapped to Pinnacle's internal sport IDs (e.g. dropping-odds 1 = Pinnacle 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 |
| 13 | Cricket (T20, ODI, Test — added July 2026) | 8 |
Golf note: Pinnacle exposes Golf as moneyline-only (head-to-head matchups, outright winners). Spreads, totals, and team totals are not provided for Golf — your client should expect spreads, totals, and team_total to be empty objects on Golf events. Drops on Golf will only fire from the Moneyline section.
Market types
Returned inside each 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 },
"away": { "score": 0, "redCards": 0 },
"home_stats": [{ "period": 0 }, { "period": 1 }],
"away_stats": [{ "period": 0 }, { "period": 1 }]
}
}
Special-market rows (returned only with include_specials=1, or when fetched by their own event_id) additionally carry special, special_category, special_units and special_markets, with an empty periods object — see Player props & specials for the shape. BTTS / Draw No Bet rows keep their historical shape (special + special_outcomes with prices in the money_line slots) and appear regardless of the flag.
Live state object
Live events include a state object with in-game state passed through from Pinnacle. Field names are sport-specific and are NOT renamed by us. state is null on prematch events.
Three top-level fields make up the object:
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. Currently only theperiodindex is present; per-period stat values are not populated.
| Sport | Match-level fields | Per-participant fields |
|---|---|---|
| Soccer (1) | state (period state code, 1 = 1H, 2 = 2H, etc.), minutes (game clock minute) |
score, redCards |
| Basketball (3) | quarter (1-4 + OT), timeRemainingInQtr (string "MM:SS") |
score |
| 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. |
Note: Per-participant live state currently covers score and redCards (plus the match-level clock/period). Yellow cards and corners are not populated on the main event. Fields appear only when there is a non-default value to publish, so treat every field as optional and code defensively.
Where it lives: the state field is returned by GET /kit/v1/markets?event_type=live only. Prematch endpoints (/kit/v1/prematch/*) do not return state — none of these fields exist before kickoff. The /odds-drop SSE stream does NOT include state; use the REST endpoint when you need live game state.
Affiliate program
Every account gets a unique referral link. Anyone who signs up via your link and becomes a paying customer earns you 30% of every payment they make, for as long as their subscription stays active — first payment, renewals, upgrades, all of it.
| Topic | Detail |
|---|---|
| Your link | Shown in your dashboard. Format: https://pinnodds.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 pinnodds.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.