# Pinnacle Odds API — Full Reference This document is the canonical, plain-text reference for the Pinnacle Odds API hosted at https://pinnapi.com. Everything an AI assistant or developer needs to integrate is below: auth, endpoints, parameters, response shapes, working code examples, error codes, rate limits, and reference data. Source of truth: this file. The HTML version at https://pinnapi.com/docs renders the same content for humans. If anything here disagrees with the running API, the running API wins — open an issue or contact support. --- ## Overview Real-time live and prematch sports odds, pulled directly from Pinnacle's MQTT WebSocket broker (`wss://api.arcadia.pinnacle.com/ws`) and exposed through four surfaces: 1. **REST endpoints** under `/kit/v1/*` — same response shape as common third-party Pinnacle APIs, drop-in compatible. Use these for snapshots, polling, or one-off lookups. 2. **Queryable drops buffer** at `/api/drops` — recent dropping-odds events with filters. Use this when you want a list of "what just dropped" without keeping a connection open. 3. **Server-Sent Events streams** at `/odds-drop` (live) and `/odds-drop-prematch` — push-based, sub-second latency from a Pinnacle price move to your code seeing it. Use these for arb, EV bots, alert systems. 4. **WebSocket passthrough** at `/ws/feed` ($99/mo add-on, REST-bearing plans only) — every raw Pinnacle MQTT frame and prematch REST snapshot forwarded byte-identical, including market open/close/settle signals. Use this when you want to rebuild Pinnacle's full state machine on your side. NOT the same as `/odds-drop` SSE. Base URL for examples below: `https://pinnapi.com`. Coverage: Soccer, Tennis, Basketball, Hockey, Football, Baseball, Rugby, MMA, Boxing, an "Other" bucket (Volleyball, Handball), Esports, and Golf (head-to-heads / outrights). Markets: moneyline, spreads, totals, team totals — across every period (full match, halves, quarters, sets, etc.). Note: Golf is moneyline-only — Pinnacle does not provide spreads/totals for Golf. --- ## Authentication There are **two separate auth schemes** depending on the surface you're calling. ### REST API key (for /kit/v1/*, /api/drops, /health, /panel/api/*) Sign up at https://pinnapi.com/panel/api/signup with an email — you get back an API key once. That key is your only credential; there is no password. Pass the key in **one of three** ways, in priority order: 1. `x-portal-apikey: YOUR_KEY` header — recommended 2. `x-api-key: YOUR_KEY` header 3. `?key=YOUR_KEY` query parameter — handy for browsers and curl one-liners Failure: HTTP 401 with `{"error":"missing_key"}` or `{"error":"invalid_key"}`. ### SSE token (for /odds-drop, /odds-drop-prematch) Two paths accepted: 1. **API key path** — same header or `?key=` as above; the user's plan must have `features.sse: true` (Stream, Pro+SSE, or Scale tier). 2. **Legacy token path** — `?token=YOUR_SSE_TOKEN` against the `SSE_TOKENS` env on the server. Used by admin/operator clients; ordinary customers use path 1. Failure: the response is HTTP 401 or 403 **but the body is SSE-framed**, not JSON, so EventSource clients can read the reason via `onmessage`. Look for `data: {"type":"error","error":"...","message":"..."}`. Trial users CANNOT connect to SSE — upgrade to Stream ($99/mo), Pro+SSE ($149/mo), or Scale ($229/mo). --- ## REST Endpoints ### GET /kit/v1/markets Returns every event for a sport with all markets across every period. Live by default; pass `event_type=prematch` to switch feeds. Same envelope shape for both — only the per-event `event_type` field differs. **Query parameters:** | Name | Type | Required | Description | |---|---|---|---| | `sport_id` | int | yes | Sport ID, see "Sport IDs" section below | | `event_type` | "live" \| "prematch" | no | Defaults to `live`. Pass `prematch` to receive prematch fixtures with the same envelope. | | `since` | int | no | Return only events with `last > since` (incremental polling) | | `is_have_odds` | 0 \| 1 | no | Legacy compat — accepted and ignored. We always return events with odds. | **Example request:** ``` curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/kit/v1/markets?sport_id=2&event_type=live" ``` **Example response (live, soccer trimmed):** ```json { "sport_id": 2, "sport_name": "Tennis", "last": 297592, "last_call": 297592, "events": [ { "event_id": 1628594960, "sport_id": 2, "league_id": 2037, "league_name": "ATP Madrid", "starts": "2026-04-17T12:00:00Z", "last": 1776430365, "home": "Carlos Alcaraz", "away": "Jannik Sinner", "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.45, "away": 1.55 }, "spreads": { "-1.5": { "hdp": -1.5, "home": 2.06, "away": 1.82, "max": 500 } }, "totals": { "22.5": { "points": 22.5, "over": 1.90, "under": 1.98, "max": 500 } }, "team_total": { "home": { "points": 11.5, "over": 1.86, "under": 2.00 } }, "team_totals": { "home": { "10.5": { "points": 10.5, "over": 1.55, "under": 2.45, "max": 100 }, "11.5": { "points": 11.5, "over": 1.86, "under": 2.00, "max": 100 }, "12.5": { "points": 12.5, "over": 2.30, "under": 1.65, "max": 100 } }, "away": {} }, "meta": { "number": 0, "max_total": 500, "home_score": 5, "away_score": 4 } } }, "state": { "match": { "set": 2 }, "home": { "serving": true, "setsWon": 1, "points": "30", "gamesBySet": [6,2,0,0,0] }, "away": { "serving": false, "setsWon": 0, "points": "15", "gamesBySet": [4,5,0,0,0] }, "home_stats": [{ "period": 0 }, { "period": 1 }], "away_stats": [{ "period": 0 }, { "period": 1 }] } } ] } ``` **Live game-state (`state` field):** Live events include a `state` object passed through from Pinnacle exactly as they publish it on MQTT. Field names are sport-specific and NOT renamed. `state` is `null` on prematch and absent from the `/odds-drop` SSE stream — REST only. - `state.match` — match-level state (clock, current period). Sport-dependent: Soccer = `{state, minutes}`; Basketball = `{quarter, timeRemainingInQtr}`; Tennis = `{set}`. - `state.home`, `state.away` — per-participant state. Common: `score`. Sport-specific extras: - Soccer: `redCards`, `yellowCards`, `corners` - Basketball: `scoreByQuarter` (array) - Tennis: `setsWon`, `gamesBySet` (array), `points` (string: `"0"`/`"15"`/`"30"`/`"40"`/`"Adv"`), `serving` (boolean) - `state.home_stats`, `state.away_stats` — array indexed by period; populated only when Pinnacle has published a stat value for that period. Fields appear in the JSON only when Pinnacle has a non-default value to publish. Treat every field as optional and code defensively. The `state` field is NOT returned by `/kit/v1/prematch/*` or by the `/odds-drop` SSE stream. **Same call for prematch:** ``` curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/kit/v1/markets?sport_id=1&event_type=prematch&is_have_odds=true" ``` Returns same shape, every event has `"event_type": "prematch"`. --- ### GET /kit/v1/details Fetch a single live event by ID. | Name | Type | Required | Description | |---|---|---|---| | `event_id` | int | yes | Event matchup ID from a previous /markets response | ``` curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/kit/v1/details?event_id=1628594960" ``` Returns `{ events: [] }`. --- ### GET /kit/v1/prematch/fixtures Every prematch fixture for a sport with full markets/periods. Equivalent to `/kit/v1/markets?event_type=prematch&sport_id=X`. | Name | Type | Required | Description | |---|---|---|---| | `sport_id` | int | yes | Sport ID | | `since` | int | no | Incremental — only changed events | Each event includes a `start_ts` field as a backwards-compat alias of `starts`. ``` curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/kit/v1/prematch/fixtures?sport_id=1" ``` --- ### GET /kit/v1/prematch/markets Single prematch event by ID. Same shape as /kit/v1/details, but reads from the prematch store. ``` curl -H "x-portal-apikey: $KEY" \ "https://pinnapi.com/kit/v1/prematch/markets?event_id=1629513753" ``` --- ### GET /kit/v1/prematch/lines Compact line view for one prematch event — only the active prices, no period structure. Lighter payload than /markets; ideal for tickers. | Name | Type | Required | Description | |---|---|---|---| | `event_id` | int | yes | Prematch event ID | | `market_type` | string | no | Filter to one of `money_line`, `spreads`, `totals`, `team_total` | --- ### GET /api/drops Pull-style queryable buffer of recent dropping-odds events. Use for batch jobs, dashboards, or polling clients that don't want a long-lived SSE connection. | Name | Type | Description | |---|---|---| | `mode` | "live" \| "prematch" | Which feed. Defaults to `live`. | | `sport_id` | int | Filter to a single sport. Omit for all. | | `min_drop_pct` | number | Minimum drop percent (default 5). Fractional allowed. | | `max_drop_pct` | number | Optional cap. | | `max_age_sec` | int | Only drops fresher than this (max ~3 hours). | | `markets` | csv | Subset of `moneyline,spread,total,team_total` | | `periods` | csv | Period numbers (`0`=match, `1`=1st half, etc.) | | `live` | 0 \| 1 | Live-mode only — exclude drops on events not yet started | | `limit` | int | 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" ``` Response: ```json { "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, "nvp": 2.21, "age_s": 12, "is_live": false } ], "meta": { "mode": "prematch", "events_in_store": 2552, "tracked_outcomes": 122035 } } ``` The `nvp` field is the no-vig fair price computed via the power method (each leg's implied probability is raised to a power C such that the sum equals 1 — Newton-iterated, ~2 iterations to converge). Compare `to` vs `nvp` to estimate edge: `(to / nvp) - 1`. --- ### GET /health Operational status of both feeds. Useful for dashboards and uptime monitors. ```json { "status": "healthy", "source": "ws", "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" } } ``` --- ### GET /ping Public liveness probe. Returns `ok` in plain text. No auth required — useful for load balancers. ``` curl https://pinnapi.com/ping # ok ``` --- ## SSE Streams ### GET /odds-drop (live) Server-Sent Events stream of live odds drops as they happen. One TCP connection, push-based, ~200ms latency from Pinnacle price move to your code seeing it. **Auth:** API key (`?key=` or header) on a plan with `features.sse: true`, OR legacy `?token=`. ``` curl -N "https://pinnapi.com/odds-drop?key=$API_KEY" ``` **First frame** is always a connection handshake: ``` data: {"type": "connected", "id": "uuid-of-this-subscription"} ``` **Subsequent frames** are JSON arrays of one or more drop objects: ``` 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, "dispatched_ms": 1777625196234, "flushed_ms": 1777625196201}] ``` Drop fields: - `id` — Pinnacle event ID - `sport`, `sport_id`, `league` — event context - `home`, `away` — teams/players - `sect` — market section (`Moneyline`, `Spread`, `Total`, `TeamTotal`) - `outcome` — which side moved (`Home`, `Away`, `Over`, `Under`, etc.) - `period` — period number (0=match, 1=1st half, etc.) - `point` — handicap or total line if applicable - `from_price`, `to_price` — old and new decimal odds - `nvp` — no-vig fair price for the new odds - `interval` — seconds of price history when the drop fired - `alerted` — UNIX timestamp (seconds) of the drop event - `starts` — UNIX timestamp of the event start time - `dispatched_ms`, `flushed_ms` — server-side latency markers **Node.js example:** ```js import EventSource from "eventsource"; const es = new EventSource( `https://pinnapi.com/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("SSE error:", payload.error, payload.message); return; } for (const drop of payload) { console.log(drop.sport, drop.home, "→", drop.from_price, "→", drop.to_price); } }; ``` **Python example (with sseclient-py):** ```python import json from sseclient import SSEClient URL = f"https://pinnapi.com/odds-drop?key={API_KEY}" for ev in SSEClient(URL): 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: print(drop["sport"], drop["home"], "→", drop["from_price"], "→", drop["to_price"]) ``` **Per-user cap:** 1 connection per paid customer per endpoint. Opening a 2nd connection from the same key on the same endpoint evicts the previous one (reconnect-friendly — EventSource auto-reconnect after a network blip works cleanly because the dying old socket is reaped before the new one registers). `/odds-drop` (live) and `/odds-drop-prematch` are counted independently, so you can run one live + one prematch in parallel under the default cap. Need more independent connections (prod + staging, multi-server fan-out, etc.)? Email info@pinnapi.com to raise your account cap — same plan, no extra cost. **Optional `min_drop` parameter** (both `/odds-drop` and `/odds-drop-prematch`): pass `?min_drop=N` to override the default 5% drop threshold for that connection. Accepts integer or decimal percent. Floor is **1%** — values below are clamped to 1. No upper cap. Per-connection — other customers are unaffected. ``` # Default — 5% drops and above (same as today) curl -N "https://pinnapi.com/odds-drop?key=$API_KEY" # Receive every drop ≥ 1% (sub-5% included; ~2-3× more volume) curl -N "https://pinnapi.com/odds-drop?key=$API_KEY&min_drop=1" # Only big moves — ≥ 10% curl -N "https://pinnapi.com/odds-drop?key=$API_KEY&min_drop=10" ``` The lower threshold composes with `recheck` on prematch: `?recheck=30&min_drop=7` holds each alert 30 s, re-checks the current Pinnacle price, and only emits if the drop is still ≥ 7% (not the default 5%). --- ### GET /odds-drop-prematch Identical to `/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. **Volume note:** prematch lines move slowly. Expect <1 drop/sec on a typical day, vs. tens per second on the live stream during prime hours. A 90-second sample may surface zero drops — that's normal. **Optional `min_drop` parameter:** same as on `/odds-drop` (above) — `?min_drop=N` overrides the default 5%. Floor 1%, no upper cap. **Optional `recheck` parameter** (prematch only): pass `?recheck=N` (seconds) to enable the opt-in stable-price filter. The server holds each drop for N seconds, re-fetches the current Pinnacle price, and only emits the alert if the drop still passes the threshold (your `min_drop` value, or default 5%) against the original `from_price`. Bounced-back prices are silently suppressed. Emitted alerts include a new `rechecked_ms` field reflecting the actual wait. Omit (or pass `0`) for the default instant feed. No upper cap — the parameter only affects your own connection. ``` # Default instant feed curl -N "https://pinnapi.com/odds-drop-prematch?key=$KEY" # Stable-price feed: wait 30s then re-verify before emitting curl -N "https://pinnapi.com/odds-drop-prematch?key=$KEY&recheck=30" # Combined: only emit if a ≥7% drop is still ≥7% after 30s curl -N "https://pinnapi.com/odds-drop-prematch?key=$KEY&recheck=30&min_drop=7" ``` The `recheck` parameter is **not** supported on `/odds-drop` (live drops are sub-second; recheck windows are meaningless). If passed, it's silently ignored on the live endpoint. `min_drop` is supported on both. --- ## WebSocket Feed (Add-on) A paid add-on that forwards **raw Pinnacle frames** to your client — not drops, not derived metrics. Live MQTT messages and prematch REST responses are delivered byte-identical to what we receive ourselves, wrapped in a thin envelope so your code can tell what it's looking at. Power-user feature: meant 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 drop alerts (≥5% by default). This WS delivers every Pinnacle frame, including market opens/closes/settlements, with no filtering. Output volume is **much higher**. ### Pricing - **$99/month** as an 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; the WS feed is a paired product. - Bundled with new purchases / renewals: $99 monthly, $297 quarterly, $594 semi-annual (matches your base period). - Mid-cycle upgrade: **prorated to your renewal date** — pay only for the days remaining on your current subscription. Quote endpoint: `GET /panel/api/addon/ws/quote`. ### Auth + entitlement Use your existing API key. The server checks `ws_addon_until > now()` on the upgrade request and rejects with HTTP 403 + `{"error":"plan_lacks_ws"}` if the add-on isn't active. ### Endpoint ``` wss://pinnapi.com/ws/feed?key= ``` API key can also be sent in an `x-api-key` header during the HTTP upgrade. One WebSocket connection per account (separate slot from SSE — you can run both). ### Wire protocol Both directions exchange JSON-encoded text frames. **Client → server:** ```jsonc // Send this within 5s of connecting or the server closes the socket. // You can subscribe by SPORT (firehose) or by specific EVENT IDs (filtered), // or both in the same message. At least one of sport_ids / event_ids is required. { "type": "subscribe", "streams": ["live", "prematch"], "sport_ids": [1, 2], // pinnapi sport IDs — firehose for that sport "event_ids": [1631005165] } // Pinnacle matchup IDs — only these events // Drop subscriptions later. Same shape — either field can be omitted. { "type": "unsubscribe", "streams": ["live"], "sport_ids": [2] } { "type": "unsubscribe", "streams": ["prematch"], "event_ids": [1631005165] } // Application-level pong reply to the server's ping. { "type": "pong" } ``` `streams` values: `"live"`, `"prematch"`, or both. `sport_ids` use the same integer IDs as the rest of the API (1 = Soccer, 2 = Tennis, …). `event_ids` are Pinnacle's matchup IDs (9-10 digit integers, the same `id` field that appears on every record). **Discovery pattern for event_ids**: customers don't usually know matchup IDs upfront. Standard pattern: subscribe to a sport, grep the snapshot for the events you care about (by team name, league, start time, etc.), then unsubscribe from the sport and subscribe to those specific event IDs. This narrows your bandwidth to exactly the matches you're trading. **Filter rule**: a frame is delivered to a sub if **either** its sport_id matches a sport-level subscription **or** its event_id matches an event-level subscription. So `sport_ids: [2], event_ids: [12345]` means "all of Tennis plus this one specific event in any sport". **Per-stream cap**: 200 event_ids per connection per stream (live and prematch counted independently). Subscribing past the cap returns `{type:"error", code:"event_id_cap", ...}` and the request is rejected wholesale. Auto-cleanup: when an event is removed from our store (`op: "del"`), the hub silently drops it from all subs' event_id sets — you don't need to send unsubscribe yourself. **Server → client (immediately on subscribe):** ```jsonc // Sport-level subscribe → one snapshot per (stream, sport_id) tuple with // every event in our store for that sport. { "type": "snapshot", "stream": "live", "sport_id": 1, "ts": 1779200000000, "events": [ /* full Pinnacle records, untouched */ ] } // Event-level subscribe → one snapshot per (stream, batch of event_ids) // with just the records we currently have for those IDs. Missing IDs are // simply absent — they'll appear in the stream once Pinnacle pushes them. { "type": "snapshot", "stream": "live", "event_ids": [1631005165], "ts": 1779200000000, "events": [ /* full Pinnacle records, untouched (one per matched id) */ ] } ``` **Server → client (continuous afterwards):** ```jsonc // Every live MQTT frame — `topic`, `op`, `rec` are EXACTLY what we received. { "type": "live", "sport_id": 1, "topic": "matchups/reg/sp/29/live/ld", "op": "upd", "rec": { /* Pinnacle record verbatim */ }, "ts": 1779200000123 } // Every prematch REST poll response — `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 the server will close // the socket after ~75s of silence. { "type": "ping", "ts": 1779200030000 } // Lifecycle / errors. Acks include whichever field you used in subscribe // — `sport_id` for sport-firehose subs, `event_ids` array for event-level. { "type": "subscribed", "stream": "live", "sport_id": 1 } { "type": "subscribed", "stream": "live", "event_ids": [1631005165] } { "type": "unsubscribed", "stream": "prematch", "sport_id": 3 } { "type": "unsubscribed", "stream": "prematch", "event_ids": [1631005165] } { "type": "error", "code": "unauthorized" | "plan_lacks_ws" | "invalid_subscribe" | "event_id_cap" } ``` **Fidelity contract:** `events`, `rec`, and `data` are never transformed. We only add the envelope fields (`type`, `stream`, `sport_id`, `topic`, `op`, `ts`). Your code can reuse a Pinnacle parser unchanged. ### Frame semantics — how to read what we forward Pinnacle pushes incremental updates over MQTT. The `op` field, the `topic`, and the inner `rec` / `data` shape determines how to merge into your local state. **Live frames — `type:"live"`:** | Field | Meaning | |---|---| | `op` | `"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` | 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. `matchups/spc/...` (instead of `reg`) = "special" matchup (props, futures). | | `rec.id` | Pinnacle matchup ID. Stable across the event's lifetime. | | `rec.markets` | 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` | 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` | 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`, `rec.status` | Event-level metadata. Tennis / Golf / Fights frequently see `startTime` updates without any market change (reschedules) — track these. | **Prematch frames — `prematch_matchups` + `prematch_markets`:** | Field | Meaning | |---|---| | `type` | `prematch_matchups` = response from `/sports/{sid}/matchups`, fired every 5s per sport. 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`. | | `data` | For `prematch_matchups`: array of full matchup records. 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` | (prematch_markets only) The matchup this markets array belongs to. | | `mode` | Present only when MQTT is down and we fell back to live REST polling. Value: `"rest_fallback"`. Frame shape is otherwise identical. | **Merge recipe (pseudocode):** ```js // Maintain a Map. 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 ?? ""}`; } ``` ### Example client — Node.js ```js import WebSocket from "ws"; const ws = new WebSocket("wss://pinnapi.com/ws/feed?key=" + process.env.API_KEY); ws.on("open", () => { ws.send(JSON.stringify({ type: "subscribe", streams: ["live", "prematch"], sport_ids: [1, 2], // Soccer + Tennis })); }); ws.on("message", (raw) => { const msg = JSON.parse(raw); if (msg.type === "ping") { ws.send(JSON.stringify({ type: "pong" })); return; } if (msg.type === "snapshot") console.log(`snapshot ${msg.stream}/${msg.sport_id}: ${msg.events.length} events`); else if (msg.type === "live") console.log(`live update event=${msg.rec.id} op=${msg.op}`); else if (msg.type === "prematch_matchups") console.log(`prematch matchups sport=${msg.sport_id} count=${msg.data.length}`); else if (msg.type === "prematch_markets") console.log(`prematch markets event=${msg.matchup_id} markets=${msg.data.length}`); }); ``` ### Example client — Python ```python 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], })) async for raw in ws: msg = json.loads(raw) if msg["type"] == "ping": await ws.send(json.dumps({"type": "pong"})) elif msg["type"] == "snapshot": print(f"snapshot {msg['stream']}/{msg['sport_id']}: {len(msg['events'])} events") elif msg["type"] == "live": print(f"live update event={msg['rec']['id']} op={msg['op']}") asyncio.run(main()) ``` ### 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. The 1-connection cap means opening a new socket will close any previous one for the same key. --- ## Sport IDs Stable integer mapping. Use these in `sport_id` query params throughout the API. | ID | Sport | |---|---| | 1 | Soccer | | 2 | Tennis | | 3 | Basketball | | 4 | Hockey | | 5 | Football | | 6 | Baseball | | 7 | Rugby (Union + League) | | 8 | MMA (UFC, Bellator, ONE, etc.) | | 9 | Boxing | | 10 | Other (Volleyball, Handball) | | 11 | Esports (Dota 2, CS2, League of Legends, etc.) | | 12 | Golf (PGA, DP World Tour, LIV — head-to-heads) | Golf (id 12) is moneyline-only: Pinnacle does not provide spreads, totals, or team totals for Golf. On a Golf event, expect `spreads`, `totals`, and `team_total` to be empty objects. Golf drops fire from the Moneyline section only. --- ## Market Types Returned inside each event's `periods[]` object: | Field | Shape | Description | |---|---|---| | `money_line` | `{ home, away, draw? }` | 3-way (or 2-way) match result. Decimal odds. | | `spreads` | `{ "": { hdp, home, away, max } }` | Handicap markets keyed by point spread | | `totals` | `{ "": { points, over, under, max } }` | Over/under markets keyed by total | | `team_total` | `{ home: {...}, away: {...} }` | Primary per-team total line per side (one line per side, last-write-wins). Backward-compatible. Use `team_totals` for ALL alt lines. | | `team_totals` | `{ home: { "": {points, over, under, max} }, away: {...} }` | ALL alternate team-total lines, keyed by points per side. Mirrors the `totals` shape. Use when Pinnacle exposes multiple lines (e.g. home Over 0.5 / 1.5 / 2.5). | | `meta` | object | Per-period metadata: scores, max stakes | --- ## Periods `periods` is an object keyed by `num_`: - `num_0` — full match - `num_1` — 1st half (or 1st set in tennis) - `num_2` — 2nd half / 2nd set - `num_3` — 3rd quarter / 3rd set - `num_4` — 4th quarter / 4th set - `num_5` — extra time - `num_6`, `num_7`, `num_8` — sets 1, 2, 3 (alternative tennis indexing) Not all periods are present for every event. Iterate keys defensively. --- ## Plans & Pricing | Plan | Price | REST | SSE | Notes | |---|---|---|---|---| | Trial | $0 | 100 reqs/day | ❌ | Free, no card, no expiry. Test endpoints, can't use SSE. | | Stream | $99/mo | 100 reqs/day | ✅ | SSE-only product. REST limited to trial-class. | | Pro | $99/mo | 10 req/sec | ❌ | REST only. No SSE. | | Pro+SSE | $149/mo | 10 req/sec | ✅ | REST + SSE bundle. Best value if you want both. | | Scale | $229/mo | 30 req/sec | ✅ | High-frequency arbitrage and multi-account. | Quarterly and semi-annual variants exist with 12–24% discounts. Get current SKUs and prices live at https://pinnapi.com/panel/api/plans (returns JSON). Billing: crypto only (USDT-TRC20, BTC, ETH, TRX, etc.) via NowPayments. No card, no auto-renewal — when your plan expires you re-subscribe manually. --- ## Rate Limits Enforced per-key. When exceeded, HTTP 429 with `Retry-After` header and a JSON body: ```json { "error": "rate_limited", "window": "minute", "limit": 20, "retry_after_ms": 41052 } ``` Limits per plan: | Plan | Per second | Per minute | Per hour | Per day | |---|---|---|---|---| | Trial | — | 20 | 100 | 100 | | Stream | — | 20 | 100 | 100 | | Pro | 10 req/sec | — | — | — | | Pro+SSE | 10 req/sec | — | — | — | | Scale | 30 req/sec | — | — | — | The day cap survives process restarts (the in-memory bucket is seeded from the `usage_daily` SQLite table on first request after a restart). --- ## Errors | Status | Error code | When | |---|---|---| | 401 | `missing_key` | No API key sent | | 401 | `invalid_key` | Key not in our DB | | 401 | `unauthorized` | (SSE) Neither valid token nor valid key | | 403 | `plan_lacks_sse` | (SSE) Authenticated but plan doesn't include SSE — upgrade to Stream, Pro+SSE, or Scale | | 429 | `rate_limited` | Per-key rate limit exceeded | | 503 | `prematch_disabled` | Prematch endpoint hit when prematch ingester is off | SSE failures are framed as `data: {"type":"error","error":"...","message":"..."}` so EventSource clients can read the reason from `onmessage` instead of getting an opaque connection failure. --- ## Common integration patterns ### Pattern 1: Polling for new live drops every 5 seconds ```js const KEY = process.env.PINNACLE_KEY; let lastSeen = 0; async function poll() { const r = await fetch( `https://pinnapi.com/api/drops?mode=live&min_drop_pct=5&max_age_sec=10`, { headers: { "x-portal-apikey": KEY } } ); const { drops } = await r.json(); for (const d of drops) { if (d.alerted_ms <= lastSeen) continue; lastSeen = d.alerted_ms; console.log("DROP:", d.sport, d.home, "vs", d.away, d.from_price, "→", d.to_price); } } setInterval(poll, 5_000); ``` ### Pattern 2: SSE stream into an arbitrage finder (Node.js) ```js import EventSource from "eventsource"; const es = new EventSource(`https://pinnapi.com/odds-drop?key=${process.env.PINNACLE_KEY}`); es.onmessage = (e) => { const data = JSON.parse(e.data); if (!Array.isArray(data)) return; // skip handshake/error frames for (const drop of data) { // EV check: bookmaker price vs no-vig fair price const ev = (drop.to_price / drop.nvp) - 1; if (ev > 0.02) { // 2%+ edge console.log(`+EV ${(ev*100).toFixed(2)}%:`, drop.home, drop.outcome, "@", drop.to_price); // ... place bet via your bookmaker integration } } }; ``` ### Pattern 3: Fetching all upcoming soccer fixtures with their full markets ```js const r = await fetch( "https://pinnapi.com/kit/v1/markets?sport_id=1&event_type=prematch", { headers: { "x-portal-apikey": process.env.PINNACLE_KEY } } ); const { events, last } = await r.json(); const matchEvents = events.filter(e => e.is_have_odds && e.periods?.num_0?.money_line ); console.log(`${matchEvents.length} prematch soccer events with money_line`); ``` ### Pattern 4: Incremental polling with `since` The `last` field returned at the top level monotonically increases. Pass it back as `since=` to get only changed events: ```js let lastVersion = 0; async function fetchChanges() { const r = await fetch( `https://pinnapi.com/kit/v1/markets?sport_id=1&since=${lastVersion}`, { headers: { "x-portal-apikey": KEY } } ); const { events, last } = await r.json(); lastVersion = last; return events; // only events that changed since the previous call } ``` --- ## Affiliate Program Every account ships with a unique referral link visible in the dashboard. Anyone who lands on pinnapi.com with `?ref=`, signs up, verifies email in the same session, and pays for a plan earns the referrer **30% of every payment** they make — lifetime, including renewals and upgrades. The referee pays the normal price; there is no signup discount on their side. | Topic | Detail | |---|---| | Link format | `https://pinnapi.com/?ref=<8-char-code>` | | Commission | 30% of every USD payment from referred users, recurring for the lifetime of the subscription | | Attribution | Session-based: `?ref=` must be present when the visitor lands on pinnapi.com AND they must complete signup + email verification before clearing sessionStorage | | Tracking | `GET /panel/api/me` returns a `referral` object containing the code, link, masked referral list, and commission stats (paying / unpaid / paid_out) | | Self-referral | Blocked at the verify-confirm step | | Payouts | Manual. We contact the referrer when unpaid balance reaches $50+. Crypto / bank / PayPal — referrer's choice | | Privacy | Referee emails are masked in the panel (`j***@gmail.com`); admins see unmasked | | Refunds | A refunded payment voids its commission. We don't claw back paid-out commissions for routine churn | The `/panel/api/me` response now includes: ``` "referral": { "code": "aBc12XyZ", "link": "https://pinnapi.com/?ref=aBc12XyZ", "rate_pct": 30, "stats": { "total_referrals": 4, "paying_referrals": 2, "unpaid_commission_usd": 49.50, "paid_commission_usd": 0 }, "referrals": [ { "id": 25, "email": "j***@gmail.com", "plan_id": "stream_30d", "is_paying": true, "joined_at": 1779800000, "commission_earned_usd": 29.70 } ] } ``` If you want to suppress the affiliate UI in your own integration (e.g. building a custom dashboard), simply ignore the `referral` block in the response — it's purely additive. --- ## Support - Documentation issues / feature requests: open an issue at the contact email shown on https://pinnapi.com - Technical questions / integration help: Telegram @ArbitrageXpro - API status & uptime: https://pinnapi.com/health (requires API key) or https://pinnapi.com/ping (public, returns `ok`)