# PinnRadar API reference (plain text) Base URL: https://pinnradar.com Human-readable version: https://pinnradar.com/docs Short index: https://pinnradar.com/llms.txt Contact: Telegram @PinnRadarSupport (https://t.me/PinnRadarSupport) or info@pinnradar.com PinnRadar is an independent odds data service (not affiliated with Pinnacle). It serves Pinnacle live and prematch prices through: - REST snapshots under `/kit/v1/*` (live board, prematch board, single events, line views) - a queryable buffer of recent price drops at `/api/drops` - Server-Sent Events streams of drop alerts: `/odds-drop` (live) and `/odds-drop-prematch` - a raw WebSocket relay of the upstream feed at `/ws/feed` (paid add-on) - a remote MCP server at `https://mcp.pinnradar.com/mcp` for AI assistants All paths below are relative to the base URL. Responses are JSON unless noted. Examples read the key from the `PINNRADAR_KEY` environment variable; code uses `BASE` for the base URL and `KEY` for the key. Official SDKs (optional, no dependencies): `pip install pinnradar` (Python 3.8+, https://pypi.org/project/pinnradar/) and `npm install pinnradar` (Node 18+, TypeScript types, https://www.npmjs.com/package/pinnradar). Both read the key from `PINNRADAR_API_KEY` or `PINNRADAR_KEY` and expose markets / details / prematch fixtures, markets, lines / drops / a reconnecting SSE drop stream / no-vig helpers. Python: `Client().markets(sport_id=1)`, `Client().stream_drops(min_drop=5)`. Node: `new PinnRadar().markets({ sportId: 1 })`, `pr.streamDrops({ minDrop: 5 })`. Every example below uses only the standard HTTP, SSE and WebSocket tools of each language, so the SDKs are never required. The `/kit/v1/*` responses follow the layout of the common third-party Pinnacle price APIs, so most existing clients migrate by swapping the base URL and the key. --- ## 1. Authentication There are no accounts with passwords: the API key is the credential. Get a free key from the dashboard at https://pinnradar.com/panel. The key may be sent in three places. If a request has more than one, the first in this list wins: 1. `x-portal-apikey` header (recommended; older third-party clients already send it) 2. `x-api-key` header 3. `?key=` query parameter (handy for a browser or a quick test) ``` # header, parameters added with -G/-d curl -sG https://pinnradar.com/kit/v1/markets \ --header "x-portal-apikey: $PINNRADAR_KEY" \ -d sport_id=3 -d event_type=live # key as a URL parameter curl -s "https://pinnradar.com/kit/v1/markets?key=$PINNRADAR_KEY&sport_id=3" ``` The SSE streams also accept an older SSE-only credential, `?token=`, kept for admin clients and for customers who predate accounts. --- ## 2. Plans and rate limits Limits are counted per key in fixed windows. Going over any window returns HTTP 429 plus a `Retry-After` header. - Free, $0: 20 per minute, 100 per hour, 100 per day. No SSE. - Drops, $99/mo: 20 per minute, 100 per hour, 100 per day. SSE drop streams included. - REST, $99/mo: 10 requests per second. No SSE. - Edge, $149/mo: 10 requests per second, SSE included ($49 cheaper than Drops plus REST bought separately). - Scale, $229/mo: 30 requests per second, SSE included. - No plan has an overall lifetime cap. A Free key stops at 100 requests per day; after that, calls get 429 with `window=day` until UTC midnight. Longer billing periods exist: Drops $249 / 90 days; REST $249 / 90 days and $449 / 180 days; Edge $379 / 90 days and $679 / 180 days; Scale $599 / 90 days. Example 429: ``` HTTP/1.1 429 Too Many Requests Content-Type: application/json Retry-After: 17 { "error": "rate_limited", "limit": 20, "window": "minute", "retry_after_ms": 16480 } ``` --- ## 3. Errors - 400, no code: a required parameter is missing or a value is malformed. - 401 `missing_key`: no API key on the request. - 401 `invalid_key`: unknown key (typo, deleted, or replaced by a regenerated key). - 401 `Unauthorized`: SSE token missing or refused (only on the `/odds-drop*` streams). - 403 `plan_lacks_sse`: valid key, but the plan has no SSE (only Drops, Edge and Scale do). - 403 `plan_lacks_ws`: valid key, but the account has no active WebSocket add-on. - 413 `payload_too_large`: request body over 64 KB (POST endpoints). - 429 `rate_limited`: a rate limit was exceeded; wait for `Retry-After`. Also sent when SSE or WebSocket connections are opened in a flood. - 500 `internal`: unexpected server error; retrying with backoff is safe. - 503 `prematch_disabled`: a prematch endpoint was called while the prematch feed is switched off. --- ## 4. Live odds The live store is updated continuously from the upstream feed; every call returns current prices. ### GET /kit/v1/markets Every event of one sport, each with all of its markets (moneyline, spreads, totals, team totals) for all of its periods (full match, halves, quarters, and so on). Live by default. Parameters: - `sport_id` (integer, required): see Sport IDs. - `event_type` (`live` or `prematch`, default `live`): `prematch` returns the prematch board in the same envelope and event layout; only each event's `event_type` value differs. The result equals `/kit/v1/prematch/fixtures`. - `since` (integer): a `last` cursor from an earlier response; only events changed after it come back. - `include_specials` (`1` or `nested`): adds prop and futures rows. `1` = separate flat rows linked to the match by `parent_id` (best for delta polling); `nested` = a `specials` array inside each parent event, one row per match. Off unless set. - `is_have_odds` (0 or 1): accepted for older clients and ignored; returned events always have odds. A live call and a prematch call share the envelope and the per-event fields, so code that walks `events[*].periods.num_0` handles both. Node 18+: ```js const url = new URL("/kit/v1/markets", BASE); url.searchParams.set("sport_id", "3"); url.searchParams.set("event_type", "live"); const resp = await fetch(url, { headers: { "x-portal-apikey": KEY } }); if (!resp.ok) throw new Error(`markets: HTTP ${resp.status}`); const board = await resp.json(); console.log(board.events.length, "in play, cursor", board.last); ``` Python: ```python import requests session = requests.Session() session.headers["x-portal-apikey"] = KEY resp = session.get(BASE + "/kit/v1/markets", params={"event_type": "live", "sport_id": 3}, timeout=10) resp.raise_for_status() board = resp.json() print(len(board["events"]), "in play, cursor", board["last"]) ``` Response envelope (each element of `events` is an Event object, section 12): ```json { "sport_id": 3, "sport_name": "Basketball", "last": 98112, "last_call": 98112, "events": [ ] } ``` ### GET /kit/v1/details One event by id, with the same fields as in `/kit/v1/markets`. - `event_id` (integer, required): matchup id from an earlier `/markets` call. ``` curl -sG https://pinnradar.com/kit/v1/details \ --header "x-portal-apikey: $PINNRADAR_KEY" -d event_id=1631874402 ``` --- ## 5. Prematch odds Lines for fixtures that have not started. Price changes are passed on as soon as upstream publishes them. Separately, the full board is re-read every 25-45 s to catch fixtures being added or withdrawn; the cycle follows how long upstream takes to build its soccer board (10-45 s). When a fixture starts and its in-play listing exists, it leaves the prematch board, and every later price for it (later esports maps included) comes from `event_type=live`. The prematch store is separate from the live store, so prematch load never slows live snapshots. Rule of thumb: in-play prices from `/kit/v1/markets`; upcoming lines from `/kit/v1/prematch/fixtures`. Same sport ids and event layout; `event_type` tells them apart. ### GET /kit/v1/prematch/fixtures Every upcoming fixture of one sport with all current periods and markets. Same structure as `/kit/v1/markets`, each event marked `event_type: "prematch"`. - `sport_id` (integer, required) - `since` (integer): delta cursor, as above - `include_specials` (`1` or `nested`): as above ```python import requests def upcoming(sport_id): resp = requests.get(BASE + "/kit/v1/prematch/fixtures", headers={"x-portal-apikey": KEY}, params={"sport_id": sport_id}, timeout=30) resp.raise_for_status() # keep fixtures that already have a full-match price return [ev for ev in resp.json()["events"] if ev.get("is_have_odds") and "num_0" in (ev.get("periods") or {})] ``` Example (trimmed): ```json { "sport_id": 1, "sport_name": "Soccer", "last": 604117, "last_call": 604117, "events": [{ "event_id": 1630412287, "sport_id": 1, "league_id": 2196, "league_name": "Spain - La Liga", "home": "Real Betis", "away": "Villarreal", "starts": "2026-11-22T20:00:00Z", "start_ts": "2026-11-22T20:00:00Z", "event_type": "prematch", "status": "pending", "is_have_periods": true, "is_have_odds": true, "periods": { "num_0": { "number": 0, "description": "Game", "money_line": { "home": 2.21, "draw": 3.48, "away": 3.12 }, "spreads": { "-0.25": { "hdp": -0.25, "home": 1.95, "away": 1.93, "max": 750 } }, "totals": { "2.75": { "points": 2.75, "over": 2.04, "under": 1.84, "max": 750 } }, "team_total": { "away": { "points": 1.25, "over": 2.11, "under": 1.76 } }, "meta": { "number": 0, "max_total": 750, "home_score": 0, "away_score": 0 } } } }] } ``` `start_ts` repeats `starts` for legacy parsers. Further periods (`num_1` first half, `num_2` second half, ...) appear when priced. ### GET /kit/v1/prematch/markets The full market set of one prematch event: every period with money line, spreads, totals and team totals. Usually called after choosing a fixture from `/prematch/fixtures`. Also prices a single special when given the special's own id. - `event_id` (integer, required): the fixture's matchup id. ### GET /kit/v1/prematch/lines Lines of one prematch event, optionally limited to one market type. Periods are keyed `num_0`, `num_1`, ... as on `/markets`; with `market_type` set, each period holds only that market (small enough for a ticker). - `event_id` (integer, required) - `market_type` (string): `money_line`, `spreads`, `totals` or `team_total`. Omit for all markets. ``` curl -sG https://pinnradar.com/kit/v1/prematch/lines \ --header "x-portal-apikey: $PINNRADAR_KEY" \ -d event_id=1630412287 -d market_type=totals ``` --- ## 6. Player props and specials Besides main markets, Pinnacle special markets are available in four groups: Player Props (anytime/first scorer, a named player to score, player cards), Team Props (half-time/full-time, winning margin, exact goals, odd/even, three-way handicaps, team to score and more), Exact Scores (correct score) and tournament futures (reach the final, outrights). Every special is a separate event row with its own `event_id`; `parent_id` points to the match. Specials are omitted unless requested. Request them on `/kit/v1/prematch/fixtures` or `/kit/v1/markets` with `include_specials=1` (flat rows; cheapest when polling with `since`) or `include_specials=nested` (a `specials` array on each parent, one row per match). To price one special, send its `event_id` to `/kit/v1/prematch/markets`; no flag needed. Extra fields on a special row (on top of the normal event fields): - `special` (string): the market title, e.g. "First Goalscorer", "Vinicius Junior To Score", "Winning Margin". - `special_category` (string): "Player Props", "Team Props", "Exact Scores", "Futures", ... - `special_units` (string): what is counted, e.g. "Regular", "Corners", "Runs". - `parent_id` (integer): the main match's `event_id`; group a fixture's props by it. - `special_markets` (object): prices per period, `{"num_0": [{type, key, side, max_risk, prices: [{name, participant_id, points, price}]}]}`. `price` is decimal, `name` labels the outcome (a player, "Yes", "2-1", "4+"), `max_risk` is the most Pinnacle accepts on that market. ``` curl -sG https://pinnradar.com/kit/v1/prematch/fixtures \ --header "x-portal-apikey: $PINNRADAR_KEY" \ -d sport_id=1 -d include_specials=1 \ | jq -r '.events[] | select(.parent_id == 1633020114) | "\(.event_id) \(.special) [\(.special_category)]"' ``` ```json { "events": [{ "event_id": 1633041902, "parent_id": 1633020114, "home": "Brazil", "away": "Japan", "special_category": "Player Props", "special": "Vinicius Junior To Score", "special_markets": { "num_0": [{ "type": "moneyline", "max_risk": 750, "prices": [{ "name": "Yes", "price": 2.62 }, { "name": "No", "price": 1.49 }] }] } }] } ``` Scope: props never trigger the SSE drop streams; among specials only BTTS and Draw No Bet alert there. Their moves do appear in `/api/drops`, with `participant_name` set. Props are thin markets, so check `max_risk` before sizing a stake. A special's id stops existing at kickoff, like any prematch row. BTTS and Draw No Bet rows keep an older layout (`special` plus `special_outcomes`, prices in the `money_line` slots) and are returned whether or not the flag is set. Other special rows have an empty `periods` object. --- ## 7. Dropping odds A drop is recorded when an outcome's decimal price falls by at least a threshold within a set time window. Detection runs server-side on both the live and the prematch feed. Every drop has its `from` and `to` price and its event. Every drop includes `nvp`, the no-vig fair price of the outcome (bookmaker margin removed); the SSE streams send the same number. A simple edge measure: `edge = to / nvp - 1`. ### GET /api/drops Queryable buffer of recent drops, for pollers, batch jobs and dashboards. - `mode` (`live` or `prematch`, default `live`) - `sport_id` (integer): one sport; omit for all - `min_drop_pct` (number, default 5): smallest drop in percent; decimals allowed - `max_drop_pct` (number): optional upper bound - `max_age_sec` (integer): only drops younger than this; the buffer reaches back about 3 h - `markets` (csv): from `moneyline,spread,total,team_total` - `periods` (csv): period numbers, `0` whole match, `1` first half, etc. - `live` (0 or 1): live mode only; `1` excludes drops on events not yet started - `limit` (integer, default 500): maximum drops returned ``` curl -sG https://pinnradar.com/api/drops --header "x-portal-apikey: $PINNRADAR_KEY" \ -d mode=live -d sport_id=1 -d min_drop_pct=7 -d max_age_sec=600 ``` ```python import requests filters = {"mode": "live", "sport_id": 1, "min_drop_pct": 7, "max_age_sec": 600} rows = requests.get(BASE + "/api/drops", params=filters, headers={"x-portal-apikey": KEY}, timeout=10).json()["drops"] # props carry nvp = None, so skip them scored = sorted(((r["to"] / r["nvp"] - 1, r) for r in rows if r.get("nvp")), key=lambda pair: pair[0], reverse=True) for edge, r in scored[:5]: print(f"{edge:+.1%} {r['home']} v {r['away']} {r['market']}/{r['side']} {r['from']} to {r['to']}") ``` Response: ```json { "total": 17, "drops": [{ "event_id": 1631552906, "sport_name": "Soccer", "league": "Netherlands - Eerste Divisie", "home": "Cambuur", "away": "De Graafschap", "market": "total", "period": 0, "side": "over", "points": 2.75, "from": 2.08, "to": 1.93, "nvp": 1.99, "drop_pct": 7.21, "age_s": 48, "is_live": true }], "meta": { "mode": "live", "source": "ws", "ts": 1794211873904, "events_in_store": 1874, "tracked_outcomes": 90412, "drops_buffered": 611 } } ``` A drop on a prop market names the moved outcome in `participant_name` and has `nvp: null`, since a many-outcome ladder has no two-sided margin to remove. Each drop also has its own `ts` (epoch ms) and `age_s`, the best per-price-change timestamp in the API. ### SSE /odds-drop Live drops pushed over one long-lived Server-Sent Events connection. Included in Drops, Edge and Scale. Free keys and keys on the REST plan are refused, except during the 3-day full demo every new account starts with, which also opens the WebSocket feed. Auth: API key (header `x-portal-apikey` / `x-api-key`, or `?key=`) on a plan with SSE, or a legacy `?token=`. - `min_drop` (number, optional): per-connection threshold in percent. Default 5, minimum 1, no maximum. `?min_drop=1` delivers every drop. One SSE connection per account: a second stream on the same key closes the first. Automatic reconnects after a network blip are fine (the dead socket is removed before the new one registers). Independent streams for several environments or servers need separate paid accounts; extra keys are not issued free because this limit is how key sharing is detected. Frames: the first is `{"type": "connected", "id": "..."}`; every later frame is a JSON array of drops. ``` data: {"type": "connected", "id": "5f0c1a92-7e3d-..."} data: [{"home": "Perth Wildcats", "away": "Brisbane Bullets", "league": "Australia - NBL", "from_price": 1.95, "to_price": 1.83, "outcome": "Away", "period": 0, "sect": "Moneyline", "id": 1632488015, "sport": "Basketball", "interval": 30, "alerted": 1794212210, "sport_id": 3, "nvp": 1.88, "starts": 1794209400, ...}] ``` Drop fields seen above: `home`, `away`, `league`, `from_price`, `to_price`, `outcome`, `period`, `sect` (market section, e.g. Moneyline, Total), `point` (line, where relevant), `id` (event id), `sport`, `sport_id`, `interval`, `alerted`, `nvp`, `starts`, `home_score`, `away_score`, and on live drops a final `live` object (section 14). Error and control frames: a Free key gets a single `{"type":"error","error":"plan_lacks_sse"}` and nothing else. A client that reconnects rapidly gets one `{"type":"error","error":"rate_limited"}` frame with `Retry-After: 60`; back off exponentially. Node 18+ reader with no dependencies: ```js async function followDrops(onDrop) { const resp = await fetch(`${BASE}/odds-drop?key=${KEY}`, { headers: { accept: "text/event-stream" }, }); const decoder = new TextDecoder(); let pending = ""; for await (const chunk of resp.body) { pending += decoder.decode(chunk, { stream: true }); let cut; while ((cut = pending.indexOf("\n\n")) !== -1) { const block = pending.slice(0, cut); pending = pending.slice(cut + 2); const data = block.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join(""); if (!data) continue; const frame = JSON.parse(data); if (Array.isArray(frame)) frame.forEach(onDrop); else if (frame.type === "error") throw new Error(frame.error); } } } ``` Python reader: ```python import json import requests def drop_frames(path, **params): params["key"] = KEY with requests.get(BASE + path, params=params, stream=True, timeout=(5, None)) as r: r.raise_for_status() for line in r.iter_lines(decode_unicode=True): if line and line.startswith("data:"): yield json.loads(line[5:]) for frame in drop_frames("/odds-drop", min_drop=5): if not isinstance(frame, list): # connected / error frame continue for d in frame: value = d["to_price"] / d["nvp"] - 1 if d.get("nvp") else None print(d["sport"], d["home"], "v", d["away"], d["outcome"], d["from_price"], "to", d["to_price"], value) ``` ### SSE /odds-drop-prematch Same format and auth as `/odds-drop`, limited to prematch drops. - `min_drop` (number): as on `/odds-drop`, default 5, minimum 1. - `recheck` (integer seconds): hold each drop N seconds, read the line's current price again, and send the alert only if the drop held. Survivors must still meet the detector's 5% threshold and gain a `rechecked_ms` field with the actual wait. Reverted drops are discarded silently. Omit or `0` for instant delivery. No upper limit; affects only your connection. Ignored on the live stream. Prematch drops have no `live` object. The one-connection rule applies here too, but live and prematch are separate slots, so one of each can run together. Volume is low: usually under one drop per second over a day (the live stream reaches tens per second at peak), so a short test may show nothing. ``` curl -N "https://pinnradar.com/odds-drop-prematch?key=$PINNRADAR_KEY&recheck=30" ``` Browser: ```js const source = new EventSource(`${BASE}/odds-drop-prematch?key=${KEY}&min_drop=3`); source.addEventListener("message", ({ data }) => { const frame = JSON.parse(data); if (Array.isArray(frame)) frame.forEach((d) => console.log(d.home, d.from_price, "to", d.to_price)); }); ``` `recheck=30` suits manual betting or slow placement (over 5 s): it trades 30 s of speed for knowing the line held. --- ## 8. WebSocket feed: WSS /ws/feed A relay of raw upstream frames, unchanged: no drop detection, nothing derived. Live updates and prematch board and market responses arrive byte for byte inside a thin envelope. Meant for clients that keep their own full copy of the book. It is a different product from the SSE drop streams (which send filtered alerts, by default every drop above the engine floor of about 0.99%, adjustable with `?min_drop=`); the socket carries everything, including markets opening, closing and settling. URL: `wss://pinnradar.com/ws/feed?key=YOUR_KEY` Pricing: $99/month added to a plan with REST access (REST, Edge, Scale). Not available on Free or Drops. Bundled checkout: $99 / 30 days, $297 / 90 days, $594 / 180 days. Adding it mid-cycle is prorated to the renewal date (quote shown in the dashboard). Without it the socket returns HTTP 403 `plan_lacks_ws`. ### Subscribing Within 5 s of connecting, send: ```json { "type": "subscribe", "streams": ["prematch", "live"], "sport_ids": [3], "event_ids": [1632907731] } ``` A frame is delivered if its sport_id is subscribed or its event_id is subscribed. Either field may be omitted, not both. Typical flow: subscribe to a sport, find your fixtures in its snapshot, unsubscribe the sport, subscribe to those event_ids. Up to 200 event_ids per connection per stream (`event_id_cap` refuses the whole subscribe beyond that); ids are removed automatically when upstream sends `op:"del"`. Sport subscriptions receive every frame type, exactly like event subscriptions. Unsubscribe with `{"type":"unsubscribe", "streams":[...], "sport_ids":[...]}`. The server answers each with `{"type":"subscribed"|"unsubscribed", "stream", "sport_id"}`; treat that reply and the snapshot as confirmation. ### Frames from the server - `snapshot`: sent after each subscribe; one per (stream, sport_id) for sports, one per batch for event_ids. `events` holds the upstream records as received. Large baselines (prematch soccer runs to thousands of events) are split: pieces carry `seq` (0, 1, 2, ...) and `final` (true on the last). A baseline that fits one message has neither field. If you upsert each piece by event id nothing special is needed; if you replace your book wholesale, wait for `final: true`. - `live`: an in-play update with `sport_id`, `op`, `topic`, `rec` (upstream record, untouched) and `ts`. - `prematch_matchups`: the upstream matchup list for one sport (`/sports/{sid}/matchups`), once per board cycle (now every 25-45 s). It includes live as well as prematch entries; keep `data[i].isLive === false` for strict prematch. - `prematch_markets`: the upstream straight-markets list for one matchup (`/matchups/{id}/markets/related/straight`), with `matchup_id`. Sent only when upstream raised `matchup.version`, i.e. for events whose markets changed in the last cycle. - `prematch_ws`: prematch price changes, forwarded as soon as upstream pushes them. - `prematch_membership`: `added` and `removed` matchup ids for one sport this cycle. - `ping`: every 30 s with `buffered_max_bytes`; answer `{"type":"pong"}` or the socket closes after about 75 s. - `error`: `code` and `message`. Only envelope fields are added (`type`, `stream`, `sport_id`, `topic`, `op`, `ts`); prematch frames also carry upstream's numeric sport id. `events`, `rec` and `data` are never modified, so a parser for the upstream format works unchanged. While upstream push is down and live data is polled instead, frames carry `mode: "rest_fallback"`; otherwise identical. `data` in prematch frames is authoritative: a market you hold that is missing from the list has closed. When the frame is split (`seq`/`final`), the authoritative set is the union of all parts; collect until `final: true` before deleting. Prematch membership: push updates prices of matchups already in the set but never reports fixtures being created or withdrawn. In a 10-minute production sample across all 13 sports, 34 membership changes happened and push reported none. A client using only `prematch_ws` misses new fixtures and keeps removed ones. `prematch_membership` gives that answer each cycle (about 25 s) for roughly 1/50,000th of the bytes of the full board. Suggested order: snapshot pieces as baseline, `prematch_ws` for prices, `prematch_membership` to fetch `added` and drop `removed`, and `prematch_matchups` only for an occasional full resync. That avoids ever pulling the ~19 MB board. `buffered_max_bytes` is the peak send backlog for your socket since the previous ping. Steady 0 means you keep up; growth means your reader lags, and at 128 MB the socket is closed with 1011 "deregistered: slow_consumer". ### Live frame fields - `op`: `add` (new event or new markets), `upd` (incremental; usually only changed markets, so merge by key), `del` (event removed upstream: kicked off, settled, voided; delete it). - `topic`: live `matchups/reg/sp/{upstream sport id}/live/{ld|dz|both}`; prematch `matchups/.../pre`. `matchups/spc/...` marks a special matchup (props, futures). - `rec.id`: matchup id, fixed for the event's life. - `rec.markets`: on `upd` may be a subset. Merge by `m.key`, or `type|period|side|points` without a key. `m.status !== "open"` means closed: remove it. - `rec.periods`: a period with status `closed` or `settled` closes all its markets, listed or not. - `rec.parentId`: present on child matchups (alternate periods, derived props); metadata lives on the parent. - `rec.startTime`, `status`: event metadata; tennis, golf and fights are often rescheduled without market changes. Channels: the last topic segment is the channel. `ld` (live_delay, about 85% of updates) carries the current price, the line shown on pinnacle.com; build prices from it. `dz` (danger_zone, about 15%) appears in volatile moments (goal threat, break point, red card) when prices are reset hard and suspension is near; use it as a volatility signal, not a second price. `pre` is a prematch matchup update on the live stream. Mixing `ld` and `dz` in one book makes prices look as if they spike and revert. Prematch has no danger_zone. Several matchup ids per fixture: one live match can appear as a parent plus reissued or related child matchups sharing `rec.parentId` and team names, so the same market `key` (e.g. `s;0;s;-1.5`) may exist under two `event_id`s at different prices. Key your book on `rec.id` plus market `key`, never team names or `parentId`. The REST `/kit/v1/markets` snapshot already merges each fixture into one canonical row. Versions: `rec.version` is coarse and stays frozen once a fixture is in play; do not deduplicate or order on it. `markets[i].version` rises with every real price change and is strictly monotonic per (event_id, market key); deduplicate and order on that. Upstream often resends unchanged markets (same version), frequently over half of all updates; skip versions already applied. Merge recipe (JavaScript): ```js class BookKeeper { constructor() { this.records = new Map(); } static idOf(mk) { if (mk.key) return mk.key; const pts = mk.prices && mk.prices.length ? mk.prices[0].points : ""; return [mk.type, mk.period || 0, mk.side || "", pts ?? ""].join("|"); } apply(frame) { if (frame.type === "snapshot") frame.events.forEach((rec) => this.records.set(rec.id, rec)); else if (frame.type === "live" && frame.op === "del") this.records.delete(frame.rec.id); else if (frame.type === "live") this.mergeLive(frame.rec); else if (frame.type === "prematch_markets") // full list: overwrite this.records.set(frame.matchup_id, { ...this.records.get(frame.matchup_id), markets: frame.data }); } mergeLive(rec) { const prev = this.records.get(rec.id) || {}; const next = Object.assign({}, prev, rec); const book = new Map(); for (const mk of prev.markets || []) book.set(BookKeeper.idOf(mk), mk); for (const mk of rec.markets || []) { const id = BookKeeper.idOf(mk); if (mk.status && mk.status !== "open") book.delete(id); else book.set(id, mk); } const shut = (rec.periods || []).filter((p) => p.status !== "open").map((p) => p.period); next.markets = [...book.values()].filter((mk) => !shut.includes(mk.period || 0)); this.records.set(rec.id, next); } } ``` Python client: ```python import asyncio, json, os import websockets FEED = "wss://pinnradar.com/ws/feed?key=" + os.environ["PINNRADAR_KEY"] async def consume(): async with websockets.connect(FEED, compression=None, max_size=8 << 20) as sock: await sock.send(json.dumps({"type": "subscribe", "streams": ["live"], "sport_ids": [3]})) async for text in sock: frame = json.loads(text) if frame["type"] == "ping": await sock.send('{"type":"pong"}') elif frame["type"] == "live": print(frame["op"], frame["rec"]["id"], frame["topic"].rsplit("/", 1)[-1]) asyncio.run(consume()) ``` ### Connections, close codes and limits One WebSocket per account (not per key or IP). A second connection is not refused: it closes the older one with code 1001 "evicted by newer connection". Common causes of a quiet socket while REST works: a monitor sharing the key, a deploy overlapping old and new instances, a retry loop that does not close the previous socket. Open one connection and fan out inside your process; for transport-level separation, email about a second credential. SSE uses a separate slot. Close codes: 1001 "evicted by newer connection"; 1001 "stale" (no pong for 75 s); 1001 "server shutdown"; 1011 "deregistered: " (server-side fault: `send_err`, `send_threw`, `slow_consumer`; match the `deregistered: ` prefix; reconnect immediately); 1006 with no reason (abnormal close, historically a frame above the client's message size limit). Reconnect: no state is kept between connections; subscribe again and use the new snapshot. Back off 1 s, 2 s, 4 s, up to 30 s. Limits and error frames: - `sport_id_cap`: more than 32 sport_ids in one subscribe; split them. - `event_id_cap`: more than 200 tracked event_ids on a stream. - `subscribe_rate_limited`: more than 30 subscribes in 10 s; batch more per message. - `backpressure`: subscribe sent while over 32 MB is waiting to be sent; the sport was NOT subscribed; read, then retry. - `slow_consumer` (close): unsent buffer over 128 MB. - `rate_limited` (HTTP 429): too many connection attempts; honour `Retry-After`. - `plan_lacks_ws` (HTTP 403): no active add-on. - `invalid_json`, `invalid_message`, `unknown_message_type`: malformed client message. Compression: `permessage-deflate` is agreed per connection by your client. With context takeover every message waits for the previous one's compression, so bursts queue: in a side-by-side test (soccer + tennis live), 2.14% of frames arrived over a second late with deflate on and 0.00% with it off. Deflate shrinks live frames about 5x, which helps when your link is the bottleneck (one long-distance TCP connection tops out near 0.4 MB/s). Spare bandwidth: turn it off. Thin or distant link: keep it on. Measure `local_recv - ts` both ways. Defaults: Python `websockets` on (`compression=None` disables); Node `ws` off (`perMessageDeflate: true` enables); Go `gorilla/websocket` off (`EnableCompression: true` enables). Message size: set the client maximum to 8 MB (2 MB minimum recommended). Largest measured frames: `prematch_matchups` and `snapshot` 512 KB (split by design), `prematch_markets` 32 KB, `live` 13 KB. A frame over your limit drops the socket with 1006. Unsplit, a soccer snapshot would be about 19 MB and a bulk `prematch_matchups` about 10 MB. --- ## 9. MCP server Remote Model Context Protocol server: `https://mcp.pinnradar.com/mcp`. Streamable HTTP, stateless, POST only. Calls use your normal API key, count against the same plan limits and show in your dashboard. Sign-in: - OAuth 2.1 (claude.ai and other clients that support it): the client discovers `/.well-known/oauth-protected-resource`; you enter your key once on the PinnRadar authorisation page; the client then holds a short-lived token that refreshes itself. - Bearer API key (Claude Code, Cursor, VS Code, Codex, the Claude API): `Authorization: Bearer `. Claude Code: ``` claude mcp add -t http -H "Authorization: Bearer $PINNRADAR_KEY" pinnradar https://mcp.pinnradar.com/mcp ``` `/mcp` then lists pinnradar with 8 tools. For a team, put the entry in `.mcp.json` with `"Authorization": "Bearer ${PINNRADAR_KEY}"`. claude.ai: Settings, Connectors, Add custom connector; name pinnradar, URL https://mcp.pinnradar.com/mcp, OAuth client fields empty, Add, Connect, then approve with your key. Removing the connector revokes access. Cursor (`~/.cursor/mcp.json` or `.cursor/mcp.json`); Claude Desktop uses the same block plus `"type": "http"`: ```json { "mcpServers": { "pinnradar": { "headers": { "Authorization": "Bearer PASTE_KEY_HERE" }, "url": "https://mcp.pinnradar.com/mcp" } } } ``` VS Code (`.vscode/mcp.json`): ```json { "inputs": [ { "id": "radar-key", "type": "promptString", "description": "Your PinnRadar key", "password": true } ], "servers": { "pinnradar": { "type": "http", "headers": { "Authorization": "Bearer ${input:radar-key}" }, "url": "https://mcp.pinnradar.com/mcp" } } } ``` Codex CLI (`~/.codex/config.toml`): ``` [mcp_servers.pinnradar] bearer_token_env_var = "PINNRADAR_KEY" url = "https://mcp.pinnradar.com/mcp" ``` Claude API (MCP connector): send the header `anthropic-beta: mcp-client-2025-11-20` and add both arrays to the Messages request body: ```json "mcp_servers": [ { "name": "pinnradar", "type": "url", "url": "https://mcp.pinnradar.com/mcp", "authorization_token": "PASTE_KEY_HERE" } ], "tools": [ { "type": "mcp_toolset", "mcp_server_name": "pinnradar" } ] ``` Tools: - `board_sports`: sport ids and names. Free. - `find_matches`: live or prematch events of one sport, filtered by `query` (team or league words), with a row limit. 1 call. - `match_prices`: one event's moneyline, spreads, totals and team totals for chosen periods; concise mode gives the 5 lines around the main line. 1 call. - `upcoming_lines`: all alternate lines of one market on a prematch event. 1 call. - `price_moves`: recent sharp drops (steam), filterable by sport, market, period, size and age. 1 call. - `feed_health`: feed health and data freshness. 1 call. - `my_usage`: plan, limits, expiry, requests used today. 1 call. - `search_pinnradar_docs`: searches this reference. Free. Each tool call fetches live prices and counts as one request (no cache). Responses are trimmed for context size. A Free key's 100 daily requests can run out in one long session; after `rate_limited` the server stops calling upstream until the window resets. SSE and WebSocket streams are not available over MCP. --- ## 10. Status endpoints ### GET /health (API key) Operating state of both feeds. The root fields cover live; the `prematch` block covers prematch, which runs as its own service, so check both: one may be degraded while the other is fine. ```json { "status": "healthy", "degraded_reason": null, "source": "ws", "live_write_age_ms": 212, "events_in_store": 208, "version": 61120874, "ws_messages": 2284017, "rest_polls": 171552, "uptime_s": 402115, "prematch": { "enabled": true, "status": "healthy", "degraded_reason": null, "source": "ws", "write_age_ms": 340, "events_in_store": 17240, "version": 61120902, "rest_polls": 118377, "checked_age_ms": 860 } } ``` - `status`: `healthy` or `degraded`, live feed only; prematch problems never change it. - `source`: `ws`, `rest` or `none`. - `version`: the same counter `/kit` returns as `last`. - `live_write_age_ms` / `prematch.write_age_ms`: age of the newest write; normally well under a second. Alert on these. - `prematch.status`: `healthy`, `degraded` or `unreachable`. `unreachable` means the check could not reach the prematch service; treat as unknown, not down. - `prematch.checked_age_ms`: age of this prematch reading. - Without a prematch service: `"prematch": {"enabled": false}` only. - When the live feed is silent, `status` turns `degraded` with a reason such as `no live write for 900s`. ### GET /ping (public) Returns `ok` as plain text, no auth; for load balancers and uptime monitors. --- ## 11. Sport IDs Stable integers; each maps to one or more Pinnacle sport ids. - 1 Soccer (Pinnacle 29) - 2 Tennis (33) - 3 Basketball (4) - 4 Hockey (19) - 5 Football (15) - 6 Baseball (3) - 7 Rugby, union and league (27, 26) - 8 MMA: UFC, Bellator, ONE and others (22) - 9 Boxing (6) - 10 Other: volleyball plus handball (34, 18) - 11 Esports: CS2, Dota 2, LoL and others (12) - 12 Golf: PGA, DP World Tour, LIV head-to-heads (17) - 13 Cricket: Test, ODI, T20 (8) - 14 Volleyball only, subset of 10 (34) - 15 Handball only, subset of 10 (18) 14 and 15 are filtered views of 10; polling 10, 14 and 15 returns those events twice. They work on REST (`/kit/v1/markets`, `/kit/v1/prematch/*`, `/api/drops`). The WebSocket feed routes these events under 10 and rejects a subscribe for 14 or 15. Golf is moneyline only upstream (head-to-heads, outright winners): `spreads`, `totals` and `team_total` are empty objects, and golf drops come only from the Moneyline section. --- ## 12. Market types and the Event object Markets inside `periods["num_N"]`: - `money_line`: `{ home, away, draw }`, decimal odds; three-way, or two-way without a draw. - `spreads`: `{ "": { hdp, home, away, max } }`, keyed by handicap. - `totals`: `{ "": { points, over, under, max } }`, keyed by total. - `team_total`: `{ home: {...}, away: {...} }`, the main team-total line per side only (backward compatible). - `team_totals`: `{ home: { "": {points, over, under, max} }, away: {...} }`, every alternate team-total line. - `meta`: period metadata, score and maximum stakes. Periods: `num_0` full match, `num_1` first half, `num_2` second half, and so on per sport. Event object (live soccer example): ```json { "event_id": 1632907731, "parent_id": 1632907502, "sport_id": 1, "league_id": 1728, "league_name": "Sweden - Allsvenskan", "home": "Hammarby", "away": "Djurgarden", "starts": "2026-11-08T16:00:00Z", "last": 1794213010, "event_type": "live", "live_status_id": 1, "is_have_periods": true, "is_have_odds": true, "periods": { "num_0": { "number": 0, "description": "Game", "money_line": { "home": 2.26, "draw": 2.95, "away": 4.10 }, "spreads": { "0.0": { "hdp": 0.0, "home": 1.62, "away": 2.38, "max": 1500 } }, "totals": { "3.5": { "points": 3.5, "over": 2.21, "under": 1.69, "max": 1500 } }, "team_total": { "away": { "points": 1.5, "over": 2.70, "under": 1.45 } }, "team_totals": { "away": { "1.5": { "points": 1.5, "over": 2.70, "under": 1.45, "max": 250 }, "2.5": { "points": 2.5, "over": 7.40, "under": 1.08, "max": 250 } }, "home": { } }, "meta": { "number": 0, "max_total": 1500, "home_score": 1, "away_score": 1 } } }, "state": { "match": { "minutes": 22, "state": 3 }, "home": { "redCards": 0, "score": 1 }, "away": { "redCards": 1, "score": 1 }, "home_stats": [{ "period": 0 }, { "period": 1 }, { "period": 2 }], "away_stats": [{ "period": 0 }, { "period": 1 }, { "period": 2 }] } } ``` The three time fields: - Top-level `last` (with `last_call`, beside `sport_id`): the store's sequence number. Only increases. Pass it to `since=`. - Per-event `last`: Unix seconds when the response was built; identical on every event; says nothing about freshness. - Per-event `updated_at`: epoch milliseconds of the last real change to the event's markets (price, line added or removed, status). Unchanged resends do not move it. After a restart it counts from when the event was seen again ("unchanged since at least"). --- ## 13. Stale data and delta polling Every REST response has three headers: - `X-Data-Status: fresh`: the data is current. - `X-Data-Status: stale`: no upstream writes for longer than the threshold; the board is held back and you get the normal empty shape (not an error). Distinguishes "no fresh data" from "no matches". - `X-Data-Status: unknown`: nothing written yet (cold start); not an outage. - `X-Data-Age-Ms`: age of the newest data on the board in ms, rounded down to whole seconds; omitted when unknown (never `-1`). - `X-Data-Board`: `live` or `prematch`. Below the threshold you still get the last known prices; apply your own tolerance with `X-Data-Age-Ms` if needed. The body's `last` is a cursor, not an age. These headers exist on REST only; on SSE and WebSocket a stretch without messages is the warning sign, so keep polling `/health` next to the stream. Delta polling: ``` CURSOR=$(curl -sG https://pinnradar.com/kit/v1/prematch/fixtures \ --header "x-portal-apikey: $PINNRADAR_KEY" -d sport_id=1 | jq '.last') curl -sG https://pinnradar.com/kit/v1/prematch/fixtures \ --header "x-portal-apikey: $PINNRADAR_KEY" -d sport_id=1 -d since="$CURSOR" ``` - Feed quiet: `last` does not change between polls (`X-Data-Age-Ms` and `/health` confirm which feed). - Event unchanged: `last` advances but the event is not in the `since=` reply; keep your copy. - Event gone: missing from a full response; withdrawn, or kicked off and moved to live. - Always send the `last` from the latest reply; never an older value or a clock time. --- ## 14. Live state `state` on live events (from `GET /kit/v1/markets?event_type=live`) is the upstream game state with field names unchanged; `null` on prematch. - `state.match`: match-level (clock, current period), sport dependent. - `state.home` / `state.away`: per side. - `state.home_stats` / `state.away_stats`: one entry per period; only `period` is filled at present. By sport: - Soccer (1): match `state` (1 first half, 2 half-time, 3 second half, 9 finished; 4 may appear briefly between 3 and 9) and `minutes` (within the current half; restarts at 0 for the second half; counts the break during half-time). Per side `score`, `redCards`; on soccer also `corners` and `yellowCards` from the parent fixture (timing can differ from the score by a few seconds; not every match has them). - Basketball (3): `quarter` (1-4 plus OT), `timeRemainingInQtr` ("MM:SS"); per side `score`. - Tennis (2): `set`; per side `setsWon`, `gamesBySet` (array), `points` ("0", "15", "30", "40", "Adv"), `serving` (boolean). - Other sports: varies (`period` / `state` common); `score` always. Fields appear only with non-default values; treat all as optional. ### The `live` object on /odds-drop drops Last key of every live drop: the latest known state of the event at the drop, even if the price frame did not repeat it. Absent only when no state is published (most sports other than soccer, tennis, basketball and hockey). Keys in this order; inapplicable values are `null`: - `status` (string): soccer "1st Half - 16'", "Half Time", "2nd Half - 22'"; basketball "4th Quarter - 7'"; others "Live Now". - `period` (string or null): basketball "Q1".."Q4"; "Q5"+ overtime; "Q-1" unknown. - `clock` (string or null): basketball whole minutes left in the quarter, e.g. "7'". - `minute` (int or null): soccer minute within the current half (0 again for the second half; 0 at half-time). - `score` ({home, away} or null): soccer, basketball, hockey; null for tennis. - `red_cards`, `yellow_cards`, `corners` ({home, away}): soccer, when known; yellows and corners can trail the score by seconds. - `current_set` (int), `sets` ({home, away} sets won), `games` ({home: [..], away: [..]} per set), `game_points` ({home, away} strings: "0", "15", "30", "40", "Ad"; tiebreak points as numeric strings), `server` ("home" or "away"): tennis. - `favorite` ("home", "away" or null): shorter full-game moneyline price at that moment; null when level or no moneyline open. - `updated_ms` (int): UNIX ms (our receive time) of the last change to this state. - `source_ms` (int): UNIX ms of the last frame of any kind for the event. ``` "live": {"status": "Half Time", "period": null, "clock": null, "minute": 0, "score": {"home": 0, "away": 2}, "red_cards": {"home": 1, "away": 0}, "yellow_cards": {"home": 3, "away": 1}, "corners": {"home": 6, "away": 2}, "favorite": "away", "updated_ms": 1794215502733, "source_ms": 1794215503018} ``` The drop's `home_score` / `away_score` come from the same state (basketball and hockey too). Basketball drops also still carry the legacy `quarter` and `time_remaining_in_qtr` fields. --- ## 15. Affiliate program Each account has a referral link on the dashboard, `https://pinnradar.com/?ref=<8-char-code>`. Referred customers earn you 30% of every USD payment they make (first payment, renewals, upgrades) while their subscription stays active. Attribution: the visitor lands with your `?ref=`, then signs up and verifies the email in the same browser session (no cross-device tracking). The dashboard lists credited signups, whether each pays, and earnings (unpaid and paid). Payouts are manual once the unpaid balance reaches $50 (crypto, bank transfer or PayPal); email info@pinnradar.com for a faster payout. Self-referral is not allowed. Refunded payments cancel the matching commission; paid commissions are not reclaimed for ordinary churn. Referred emails are masked on the dashboard. Need historical data, more than 30 requests per second, or a market not yet exposed? Message @PinnRadarSupport on Telegram or email info@pinnradar.com. ## Blog - [REST polling vs WebSocket vs SSE for sports odds: which one fits your use case](https://pinnradar.com/blog/rest-vs-websocket-vs-sse-odds): Trade-offs between REST snapshots, SSE drop alerts and a raw WebSocket odds feed: what each carries, what it costs, and a decision table by use case. - [Building a Pinnacle odds bot with Claude or Cursor using MCP](https://pinnradar.com/blog/pinnacle-odds-mcp-claude-cursor): Connect Claude, Cursor, VS Code or Codex to PinnRadar's MCP server, learn its eight odds tools and their quota cost, and script it with the Claude API. - [How to get Pinnacle odds from an API in Python](https://pinnradar.com/blog/pinnacle-odds-api-python): Get a free key, list sports and events, read one event's odds, and poll Pinnacle prices in Python within rate limits, handling 429 and stale data. - [Pinnacle odds API pricing explained: choosing between Free, Drops, REST, Edge and Scale](https://pinnradar.com/blog/pinnacle-odds-api-pricing-plans): Every PinnRadar plan with exact prices and limits: Free, Drops, REST, Edge, Scale, longer terms, the WebSocket add-on, card and crypto billing. - [Detecting dropping odds in real time with Server-Sent Events](https://pinnradar.com/blog/dropping-odds-sse): How PinnRadar's /odds-drop SSE streams work: drop definition, min_drop and recheck, plus Python and Node.js clients with reconnect, backoff and sport filters. - [Closing line value (CLV): what it is and how to track it with live odds data](https://pinnradar.com/blog/closing-line-value-clv): What closing line value means, how to remove the margin from the closing price, and how to record your own closing lines from prematch odds data.