API reference · v1

API docs

Live and prematch odds over REST, odds-drop alerts pushed over Server-Sent Events, a raw upstream WebSocket feed and an MCP server for AI assistants. One API key covers all of it.

Overview

Every endpoint sits under one base URL, https://pinnradar.com, and every path on this page is relative to it. Responses are JSON unless stated otherwise.

There are no usernames or passwords: your API key identifies you on every call. A free key takes one click on the dashboard.

Familiar response shapes. The /kit/v1/* endpoints use the same layout as the widely used third-party APIs for Pinnacle prices. In most existing clients, changing the base URL and the key is the whole migration.
Coding with an AI assistant? Give Cursor, Claude or ChatGPT /llms-full.txt: this whole reference as a single plain-text file sized for a context window. The short index at /llms.txt is the file that tools look for at the site root.

Official SDKs

Thin, dependency-free clients for Python and Node.js wrap every REST endpoint, follow the SSE drop streams with automatic reconnects, raise typed errors for bad keys and rate limits, and include no-vig helpers. Both read your key from PINNRADAR_API_KEY (or PINNRADAR_KEY).

# Python 3.8+
pip install pinnradar

# Node.js 18+ (TypeScript types included)
npm install pinnradar
from pinnradar import Client
pr = Client()                                   # key from PINNRADAR_API_KEY
board = pr.markets(sport_id=1)                  # live soccer
for drop in pr.stream_drops(min_drop=5):         # SSE drop alerts
    print(drop["home"], drop["from_price"], "->", drop["to_price"])
import { PinnRadar } from "pinnradar";
const pr = new PinnRadar();
const board = await pr.markets({ sportId: 1 });
for await (const drop of pr.streamDrops({ minDrop: 5 })) console.log(drop.home, drop.to_price);

Source and changelogs: pinnradar-python (PyPI) and pinnradar-js (npm). The SDKs are optional: every example below uses plain HTTP, WebSocket or SSE with the standard tools of its language.

Conventions. Shell examples read your key from the PINNRADAR_KEY environment variable. Code examples use BASE for the base URL and KEY for the key. PinnRadar is an independent data service, not affiliated with Pinnacle.

Authentication

The key can travel in any of three places. When a request carries more than one, the first match in this order is used:

  1. x-portal-apikey header: recommended, and the header older third-party clients already send
  2. x-api-key header
  3. ?key= query parameter: convenient in a browser or for a quick test
# Preferred: key in a request header, parameters added by -G/-d
curl -sG https://pinnradar.com/kit/v1/markets \
  --header "x-portal-apikey: $PINNRADAR_KEY" \
  -d sport_id=3 -d event_type=live

# Quick test from a browser bar or a one-liner: key as a URL parameter
curl -s "https://pinnradar.com/kit/v1/markets?key=$PINNRADAR_KEY&sport_id=3"

Rate limits

Each key is counted in fixed windows. Exceeding any of them returns 429 together with a Retry-After header telling you how long to wait.

PlanMonthlySSE drop streamsPer secondPer minutePer hourPer dayOverall cap
Free$0——20100100None
Drops$99Yes—20100100None
REST$99—10/s———None
Edge$149Yes10/s———None
Scale$229Yes30/s———None

A Free key is limited to 100 requests per day in total. Past that, calls return 429 with window=day until the day rolls over at UTC midnight. Drops ($99/mo) is the push plan for SSE drops only, REST ($99/mo) is REST at 10 req/sec only, and Edge ($149/mo) combines the two for $49 less than buying both.

# Example: the per-minute window of a Free key is used up
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 }

Errors

HTTPerror valueWhen you see it
400—A required parameter is missing or a value is malformed
401missing_keyThe request carried no API key
401invalid_keyThe key is unknown: mistyped, deleted or replaced by a regenerated one
401UnauthorizedSSE token absent or refused (only on the /odds-drop* streams)
403plan_lacks_sseThe key is valid but its plan has no SSE streams (only Drops, Edge and Scale do)
403plan_lacks_wsThe key is valid but the account has no active WebSocket add-on
413payload_too_largeRequest body larger than 64 KB (POST endpoints)
429rate_limitedThe key went over a rate limit; wait as long as Retry-After says. Also returned when SSE or WebSocket connections are opened in a flood
500internalUnexpected server error; retrying with backoff is safe
503prematch_disabledA prematch endpoint was called while the prematch feed is switched off

Live odds

A snapshot of every event in play right now. The store behind these endpoints is updated continuously from the upstream feed, so each call returns the current prices.

GET/kit/v1/marketsAPI key

All events for one sport, each with every market it offers (moneyline, spreads, totals, team totals) for every period it offers (full match, halves, quarters and so on). Live events are the default; add event_type=prematch to read the prematch board instead.

Query parameters

ParameterTypeNotes
sport_id requiredintegerWhich sport; ids are listed under Sport IDs.
event_type"live" | "prematch"live when omitted. prematch returns prematch fixtures in the same envelope and with the same event layout; only the event_type value on each event changes. The result equals /kit/v1/prematch/fixtures.
sinceintegerA last cursor from an earlier response; only events changed after it are returned (incremental polling).
include_specials1 | nestedOpt in to prop and futures rows (player props, team props, exact scores, outrights). With 1 they come as separate flat rows pointing at their match via parent_id, which suits delta polling; with nested each match carries its props in a specials array, one row per match. Not sent by default. Details under Player props & specials.
is_have_odds0 | 1Accepted for compatibility with older clients and ignored: returned events always have odds.
One parser for both feeds. A live call and a prematch call share the envelope and the per-event fields; only event_type on each event differs. Code that walks events[*].periods.num_0 handles both without a branch.
curl -sG https://pinnradar.com/kit/v1/markets \
  --header "x-portal-apikey: $PINNRADAR_KEY" \
  -d sport_id=3 -d event_type=live | jq '.events | length'
// Node 18+ (global fetch). BASE = "https://pinnradar.com", KEY = your API key.
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);
import requests

session = requests.Session()
session.headers["x-portal-apikey"] = KEY      # sent on every call below

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"])
package main

import (
  "encoding/json"
  "net/http"
  "net/url"
)

type Board struct {
  SportID int               `json:"sport_id"`
  Cursor  int64             `json:"last"`
  Events  []json.RawMessage `json:"events"`
}

func liveBoard(base, key string, sport string) (*Board, error) {
  q := url.Values{"sport_id": {sport}, "event_type": {"live"}}
  req, err := http.NewRequest(http.MethodGet, base+"/kit/v1/markets?"+q.Encode(), nil)
  if err != nil { return nil, err }
  req.Header.Add("x-portal-apikey", key)

  res, err := http.DefaultClient.Do(req)
  if err != nil { return nil, err }
  defer res.Body.Close()

  b := &Board{}
  return b, json.NewDecoder(res.Body).Decode(b)
}
<?php
// BASE and KEY are constants defined by your app.
$opts  = ["http" => ["method" => "GET", "header" => "x-portal-apikey: " . KEY . "\r\n"]];
$query = http_build_query(["sport_id" => 3, "event_type" => "live"]);

$raw   = file_get_contents(BASE . "/kit/v1/markets?" . $query, false, stream_context_create($opts));
$board = json_decode($raw, true);
printf("%d in play, cursor %d\n", count($board["events"]), $board["last"]);

Response

Each element of events is an Event object.

{
  "sport_id": 3,
  "sport_name": "Basketball",
  "last": 98112,
  "last_call": 98112,
  "events": [ /* one Event object per game in play */ ]
}

GET/kit/v1/detailsAPI key

A single event, looked up by id. The event has the same fields as in /kit/v1/markets.

ParameterTypeNotes
event_id requiredintegerMatchup id, as returned by an earlier /markets call.
curl -sG https://pinnradar.com/kit/v1/details \
  --header "x-portal-apikey: $PINNRADAR_KEY" -d event_id=1631874402

Prematch odds

Lines for fixtures that have not started. Price changes are pushed through as soon as they are published upstream. In parallel, the complete board is re-read every 25–45 s so that fixtures being added or withdrawn are picked up; the cycle length follows how long the upstream needs to assemble its soccer board (10–45 s). When a fixture starts and gets its in-play listing, it leaves this board; from that point every price for it, later esports maps included, is served by event_type=live. The prematch store is kept apart from the live one, so prematch traffic never slows the live snapshot.

Which endpoint? In-play prices: /kit/v1/markets. Lines for upcoming events: /kit/v1/prematch/fixtures. Both use the same sport ids and the same event layout, so one field is enough to tell them apart.

GET/kit/v1/prematch/fixturesAPI key

Every upcoming fixture of one sport, each carrying all of its current periods and markets. The structure is the one /kit/v1/markets returns, apart from the event_type value.

Query parameters

ParameterTypeNotes
sport_id requiredintegerWhich sport; ids are listed under Sport IDs.
sinceintegerA last cursor from an earlier response; only events changed after it are returned (incremental polling).
include_specials1 | nestedOpt in to prop and futures rows. 1 returns them as flat rows tied to the match by parent_id (best with since); nested folds them into a specials array on the parent, one row per match. Not sent by default. Details under Player props & specials.
# Upcoming soccer; print "home v away" for every fixture
curl -sG https://pinnradar.com/kit/v1/prematch/fixtures \
  --header "x-portal-apikey: $PINNRADAR_KEY" -d sport_id=1 \
  | jq -r '.events[] | "\(.home) v \(.away)"'
async function upcoming(sportId) {
  const resp = await fetch(`${BASE}/kit/v1/prematch/fixtures?sport_id=${sportId}`, {
    headers: { "x-portal-apikey": KEY },
  });
  const body = await resp.json();
  // keep fixtures that already have a full-match price
  return body.events.filter((ev) => Boolean(ev.is_have_odds && ev.periods && ev.periods.num_0));
}

const soccer = await upcoming(1);
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 {})]

soccer = upcoming(1)
type Fixture struct {
  EventID    int64                      `json:"event_id"`
  Home, Away string
  HasOdds    bool                       `json:"is_have_odds"`
  Periods    map[string]json.RawMessage `json:"periods"`  // num_0, num_1, ...
}

func upcoming(base, key string) ([]Fixture, error) {
  req, _ := http.NewRequest(http.MethodGet, base+"/kit/v1/prematch/fixtures?sport_id=1", nil)
  req.Header.Add("x-portal-apikey", key)
  res, err := http.DefaultClient.Do(req)
  if err != nil { return nil, err }
  defer res.Body.Close()

  var body struct{ Events []Fixture `json:"events"` }
  if err := json.NewDecoder(res.Body).Decode(&body); err != nil { return nil, err }
  return body.Events, nil
}
<?php
$ctx  = stream_context_create(["http" => ["header" => "x-portal-apikey: " . KEY . "\r\n"]]);
$body = json_decode(file_get_contents(BASE . "/kit/v1/prematch/fixtures?sport_id=1", false, $ctx), true);

// keep fixtures that already have a full-match price
$priced = [];
foreach ($body["events"] as $ev) {
  if (!empty($ev["is_have_odds"]) && !empty($ev["periods"]["num_0"])) $priced[] = $ev;
}

Response

The envelope is the /kit/v1/markets one; each event is marked event_type: "prematch":

{
  "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",  // duplicate of starts for legacy parsers
    "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 }
      }
      // further periods (num_1 = first half, num_2 = second half) appear when priced
    }
  }]
}

GET/kit/v1/prematch/marketsAPI key

The complete market set of one prematch event: every period with its money line, spreads, totals and team totals. Typically the second call, once a fixture has been picked from /prematch/fixtures.

ParameterTypeNotes
event_id requiredintegerThe fixture's matchup id.
curl -sG https://pinnradar.com/kit/v1/prematch/markets \
  --header "x-portal-apikey: $PINNRADAR_KEY" -d event_id=1630412287

GET/kit/v1/prematch/linesAPI key

The lines of one prematch event, optionally restricted to a single market type. Periods are keyed num_0, num_1, … exactly as on /markets; when market_type is given, each period contains that one market and nothing else, a payload small enough to drive a ticker.

ParameterTypeNotes
event_id requiredintegerThe fixture's matchup id.
market_typestringOne of money_line, spreads, totals, team_total. Omit it for every market.
# only the over/under ladder of one fixture
curl -sG https://pinnradar.com/kit/v1/prematch/lines \
  --header "x-portal-apikey: $PINNRADAR_KEY" \
  -d event_id=1630412287 -d market_type=totals

Player props & specials

Besides the main match markets, Pinnacle's special markets are available too. They fall into four groups: Player Props (who scores anytime or first, whether a named player scores, player cards), Team Props (half-time/full-time, margin of victory, exact goal count, odd or even, three-way handicaps, whether a team scores, and similar), Exact Scores (the correct-score market) and tournament futures (reaching the final, outright winners). Each special is its own event row with its own event_id, and its parent_id points back to the match.

GET/kit/v1/prematch/fixtures?include_specials=1API key

Nothing changes for existing integrations: specials are omitted unless you ask for them. Ask on /kit/v1/prematch/fixtures or /kit/v1/markets with include_specials=1 (separate flat rows, cheapest when you poll with ?since) or include_specials=nested (a specials array inside each parent event, one row per match). A single special is priced by sending its own event_id to /kit/v1/prematch/markets, with no flag required.

Fields on a special row

A special row has every normal event field, plus:

FieldTypeNotes
specialstringThe market's title, for instance "First Goalscorer", "Vinicius Junior To Score" or "Winning Margin".
special_categorystringGroup label such as "Player Props", "Team Props", "Exact Scores" or "Futures".
special_unitsstringThe unit being counted, for example "Regular", "Corners" or "Runs".
parent_idintegerThe main match's event_id; group all props of one fixture by it.
special_marketsobjectNamed-outcome prices keyed by period: {"num_0": [{type, key, side, max_risk, prices: [{name, participant_id, points, price}]}]}. Each price is decimal; name labels the outcome (a player, "Yes", "2-1", "4+"); max_risk is the most Pinnacle will accept on that market.

Example

Props for a Brazil v Japan friendly (main event 1633020114): list them, then price one.

# 1) pull the board with specials as flat rows and keep this match's props
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)]"'
# 1633041870  Anytime Goalscorer        [Player Props]
# 1633041902  Vinicius Junior To Score  [Player Props]
# 1633039655  Winning Margin            [Team Props]
# ... and several dozen more

# 2) price one of them by its own id
curl -sG https://pinnradar.com/kit/v1/prematch/markets \
  --header "x-portal-apikey: $PINNRADAR_KEY" -d event_id=1633041902
{
  "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. Prop markets do not fire the SSE drop streams; of all specials, only BTTS and Draw No Bet alert there. Their price moves are listed by /api/drops, with participant_name filled in. Liquidity on props is low, so read max_risk before deciding a stake. Like any prematch row, a special's id disappears once the match kicks off.

Dropping odds

A drop is recorded when the decimal price of an outcome falls by at least a threshold inside a set time window. Detection runs on the server for both the live and the prematch feed, and every drop comes with its from and to price and the event it belongs to.

GET/api/dropsAPI key

A queryable buffer of recent drops for pull-based clients: filter it and read the whole result. Suited to batch jobs, dashboards and pollers.

Every drop includes nvp, the fair price of the outcome with the bookmaker margin removed ("no-vig"). It is the same number the SSE streams send. One way to measure edge: edge = to / nvp - 1.

Query parameters

ParameterTypeNotes
mode"live" | "prematch"Which feed's drops to search; live by default.
sport_idintegerRestrict to a single sport; omit for every sport.
min_drop_pctnumberSmallest drop to include, in percent (default 5). Decimals are allowed.
max_drop_pctnumberOptional upper bound.
max_age_secintegerOnly drops younger than this. The buffer reaches back about 3 h.
marketscsvComma list drawn from moneyline,spread,total,team_total.
periodscsvComma list of period numbers: 0 for the whole match, 1 for the first half, etc.
live0 | 1Live mode only. 1 leaves out drops on events that have not started yet.
limitintegerCap on how many drops come back; 500 unless set.
# In-play soccer, 7%+ drops, at most 10 minutes old
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

# Prematch, all sports, 2%+ moves within the past hour, totals only
curl -sG https://pinnradar.com/api/drops --header "x-portal-apikey: $PINNRADAR_KEY" \
  -d mode=prematch -d min_drop_pct=2 -d max_age_sec=3600 -d markets=total
const filters = { mode: "live", sport_id: "1", min_drop_pct: "7", max_age_sec: "600" };
const resp = await fetch(`${BASE}/api/drops?` + new URLSearchParams(filters), {
  headers: { "x-portal-apikey": KEY },
});
const { drops } = await resp.json();

// rank by value against the margin-free price; skip rows without nvp (props)
const ranked = drops
  .filter((row) => row.nvp)
  .map((row) => ({ ...row, edge: row.to / row.nvp - 1 }))
  .sort((a, b) => b.edge - a.edge);
for (const row of ranked.slice(0, 5)) {
  console.log(`${(row.edge * 100).toFixed(1)}%`, row.home, "v", row.away, row.market, row.side, row.from, "to", row.to);
}
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"]

# value against the margin-free price; 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']}")
type DropRow struct {
  EventID  int64    `json:"event_id"`
  Home     string   `json:"home"`
  Away     string   `json:"away"`
  Market   string   `json:"market"`
  Side     string   `json:"side"`
  From     float64  `json:"from"`
  To       float64  `json:"to"`
  NVP      *float64 `json:"nvp"`      // nil on prop markets
  DropPct  float64  `json:"drop_pct"`
}

func recentDrops(base, key string) ([]DropRow, error) {
  params := url.Values{
    "mode": {"live"}, "sport_id": {"1"},
    "min_drop_pct": {"7"}, "max_age_sec": {"600"},
  }
  req, _ := http.NewRequest(http.MethodGet, base+"/api/drops?"+params.Encode(), nil)
  req.Header.Add("x-portal-apikey", key)
  res, err := http.DefaultClient.Do(req)
  if err != nil { return nil, err }
  defer res.Body.Close()

  var page struct{ Drops []DropRow `json:"drops"` }
  err = json.NewDecoder(res.Body).Decode(&page)
  return page.Drops, err
}
<?php
$filters = ["mode" => "live", "sport_id" => 1, "min_drop_pct" => 7, "max_age_sec" => 600];
$ctx  = stream_context_create(["http" => ["header" => "x-portal-apikey: " . KEY . "\r\n"]]);
$rows = json_decode(file_get_contents(BASE . "/api/drops?" . http_build_query($filters), false, $ctx), true)["drops"];

foreach ($rows as $r) {
  if (empty($r["nvp"])) continue;               // props have no fair price
  $edge = $r["to"] / $r["nvp"] - 1;
  printf("%+.1f%%  %s v %s  %s  %.2f to %.2f\n", $edge * 100, $r["home"], $r["away"], $r["market"], $r["from"], $r["to"]);
}

Response

{
  "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 outcome that moved in participant_name (a player such as "Vinicius Junior") and sets nvp: null: a ladder with many outcomes has no two-sided margin to strip out.

SSE/odds-dropAPI key or token

A Server-Sent Events stream of live drops, pushed as they happen over a single long-lived connection, with no polling. Included in the Drops, Edge and Scale plans. A Free key or a key on the REST plan is refused. The exception is the 3-day full demo every new account starts with, which opens this stream and the WebSocket feed for its duration.

One SSE connection per account. Opening a second stream with the same key ends the first. A normal EventSource reconnect after a network blip is not affected: the server clears the dead socket before it registers the new one. Several independent streams (say, production plus staging, or one per server) therefore mean several accounts, each on a paid plan, each with its own key. Because this limit is also how key sharing and resale are spotted, extra keys are not given out for free.

Auth and query parameters

Authenticate with either key or token.

ParameterTypeNotes
keystringYour API key, sent as the x-portal-apikey or x-api-key header or as ?key=. The plan must include SSE (Drops, Edge or Scale).
tokenstringAn older SSE-only credential, distinct from API keys, passed as ?token=. It still works for admin clients and for customers who predate accounts.
min_dropnumberOptional per-connection threshold in percent. ?min_drop=7 delivers only drops of 7% or more; ?min_drop=1 delivers every drop, small ones included. Defaults to 5%. Minimum 1%, no maximum.
# -N turns off buffering so frames print as they arrive
curl -N "https://pinnradar.com/odds-drop?key=$PINNRADAR_KEY&min_drop=3"

# Older SSE-only token instead of an API key
curl -N "https://pinnradar.com/odds-drop?token=$SSE_TOKEN"
// Node 18+, no dependencies: read the event stream straight from fetch().
// Run it inside a retry loop with backoff for production use.
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);         // a batch of drops
      else if (frame.type === "error") throw new Error(frame.error); // e.g. plan_lacks_sse
    }
  }
}

await followDrops((d) => {
  const value = d.nvp ? d.to_price / d.nvp - 1 : null;
  console.log(d.sport, d.home, "v", d.away, d.sect, d.outcome, d.from_price, "to", d.to_price, value);
});
import json
import requests

def drop_frames(path, **params):
    """Yield each decoded data: frame of an SSE drop stream."""
    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):     # {"type": "connected"} or an 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["sect"], d["outcome"],
              d["from_price"], "to", d["to_price"], value)
package main

import (
  "bufio"
  "encoding/json"
  "log"
  "net/http"
  "strings"
)

type Alert struct {
  Sport     string  `json:"sport"`
  Home      string  `json:"home"`
  Away      string  `json:"away"`
  Sect      string  `json:"sect"`
  Outcome   string  `json:"outcome"`
  FromPrice float64 `json:"from_price"`
  ToPrice   float64 `json:"to_price"`
  NVP       float64 `json:"nvp"`
}

func follow(streamURL string, handle func(Alert)) error {
  res, err := http.Get(streamURL)
  if err != nil { return err }
  defer res.Body.Close()

  rd := bufio.NewReaderSize(res.Body, 1<<20)
  for {
    line, err := rd.ReadString('\n')
    if err != nil { return err }
    payload, ok := strings.CutPrefix(strings.TrimSpace(line), "data:")
    if !ok { continue }
    var batch []Alert                     // status frames are objects and fail here
    if json.Unmarshal([]byte(payload), &batch) != nil { continue }
    for _, a := range batch { handle(a) }
  }
}

func main() {
  err := follow(BASE+"/odds-drop?key="+KEY, func(a Alert) {
    log.Printf("%s %s v %s %s %s %.3f to %.3f (nvp %.3f)", a.Sport, a.Home, a.Away, a.Sect, a.Outcome, a.FromPrice, a.ToPrice, a.NVP)
  })
  log.Println("stream ended:", err)
}
<?php
// Plain PHP stream: one fgets() per line, no extensions needed.
$ctx = stream_context_create(["http" => [
  "header"  => "Accept: text/event-stream\r\n",
  "timeout" => 3600,                      // read timeout; the stream is long-lived
]]);
$stream = fopen(BASE . "/odds-drop?key=" . urlencode(KEY), "r", false, $ctx);

while (($line = fgets($stream)) !== false) {
  if (strncmp($line, "data:", 5) !== 0) continue;
  $frame = json_decode(trim(substr($line, 5)), true);
  if (!is_array($frame) || array_key_exists("type", $frame)) continue;  // status frame
  foreach ($frame as $a) {
    printf("%s %s v %s %s %.3f to %.3f\n", $a["sport"], $a["home"], $a["away"], $a["outcome"], $a["from_price"], $a["to_price"]);
  }
}
fclose($stream);

On the wire

# frame 1 is a hello; every frame after it is a JSON array of one or more 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, ...}]

Match state on each drop: the live object

The last key of every live drop is live, a snapshot of the match when the price fell: the score, the soccer minute and half, red and yellow cards and corners, the basketball quarter and clock, tennis sets, games and points with the player serving, and the side currently favoured on the moneyline (favorite). The information matches the REST state field, reshaped into a single layout for every sport so a stream consumer can read it directly. It is missing only when no state is published for the event (most esports, for example). Every key is described in the live object reference.

# A soccer drop, trimmed; "live" closes every live drop
data: [{"home": "Hammarby", "away": "Djurgarden", "league": "Sweden - Allsvenskan",
        "from_price": 2.41, "to_price": 2.26, "outcome": "Home", "period": 0,
        "sect": "Moneyline", "id": 1632907731, "sport": "Soccer", "sport_id": 1,
        "nvp": 2.33, "home_score": 1, "away_score": 1, ...,
        "live": {"status": "2nd Half - 22'", "period": null, "clock": null, "minute": 22,
                 "score": {"home": 1, "away": 1}, "red_cards": {"home": 0, "away": 1},
                 "yellow_cards": {"home": 2, "away": 3}, "corners": {"home": 5, "away": 2},
                 "favorite": "home", "updated_ms": 1794213004410, "source_ms": 1794213005127}}]

# Basketball keys of the same object:
#   "status": "3rd Quarter - 4'", "period": "Q3", "clock": "4'", "minute": null,
#   "score": {"home": 61, "away": 58}, "favorite": "home"
# Tennis keys:
#   "status": "Live Now", "current_set": 2, "sets": {"home": 0, "away": 1},
#   "games": {"home": [4, 3], "away": [6, 2]}, "game_points": {"home": "15", "away": "30"},
#   "server": "home"
Free keys receive a single frame, {"type":"error","error":"plan_lacks_sse"}, and nothing else. SSE needs Drops, Edge or Scale.

Reconnect throttling. Clients that reconnect over and over in quick succession receive a single {"type":"error","error":"rate_limited"} frame, sent with Retry-After: 60. Wait before trying again, and space retries out with exponential backoff.

SSE/odds-drop-prematchAPI key or token

Identical in format and authentication to /odds-drop, but limited to drops on the prematch feed. Use it to follow line moves on upcoming fixtures without the volume of in-play alerts.

Optional: ?min_drop=N sets this connection's drop threshold in place of the 5% default, exactly as on /odds-drop. It can be combined with ?recheck=N. Minimum 1%.

Prematch drops have no live key, since a match that has not started has neither score nor clock.

The single-connection rule holds on this stream too (see /odds-drop). The live and prematch streams use different slots, so you can hold one of each at the same time.

curl -N "https://pinnradar.com/odds-drop-prematch?key=$PINNRADAR_KEY"
// Browsers have EventSource built in (it also reconnects on its own).
// In Node, reuse followDrops() from the /odds-drop tab with this path.
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)) return;
  frame.forEach((d) => console.log(`${d.sport}: ${d.home} v ${d.away} ${d.outcome} ${d.from_price} to ${d.to_price}`));
});
# drop_frames() is the generator from the /odds-drop Python tab
for frame in drop_frames("/odds-drop-prematch", min_drop=3):
    for d in (frame if isinstance(frame, list) else []):
        print(f"{d['sport']}: {d['home']} v {d['away']} {d['outcome']} {d['from_price']} to {d['to_price']}")
// follow() and Alert come from the /odds-drop Go tab; only the path changes.
err := follow(BASE+"/odds-drop-prematch?key="+KEY+"&min_drop=3", func(a Alert) {
  log.Printf("prematch %s %s v %s %.3f to %.3f", a.Sport, a.Home, a.Away, a.FromPrice, a.ToPrice)
})
<?php
// Same fgets() loop as the /odds-drop PHP tab; open this URL instead:
$stream = fopen(BASE . "/odds-drop-prematch?key=" . urlencode(KEY), "r", false, $ctx);
Expect low volume. Prematch lines move slowly: usually under one drop per second across a day, against tens per second on the live stream at peak hours. A 90-second test can easily show no drops at all, and that is normal.

Optional stable-price filter: ?recheck=N

Append &recheck=N, with N in seconds. Each drop is then parked for N seconds, the server reads the live price of that same line once more, and the alert goes out only if the lower price is still there (no bounce back). With a 30-second hold:

# Default prematch feed: instant, unchanged
curl -N "https://pinnradar.com/odds-drop-prematch?key=$PINNRADAR_KEY"

# Hold each drop 30 seconds and confirm it before sending
curl -N "https://pinnradar.com/odds-drop-prematch?key=$PINNRADAR_KEY&recheck=30"
// the same option from a browser EventSource
const held = new EventSource(`${BASE}/odds-drop-prematch?recheck=30&key=${KEY}`);
ParameterTypeNotes
recheckinteger (seconds)The hold time before the line's price is read again. A surviving alert still has to meet the detector's 5% threshold, and it carries an extra rechecked_ms field with the real wait. Drops that reverted are discarded silently. Omit it or send 0 for instant delivery. No upper limit; it changes only your own connection.

When it helps. If you bet by hand, or your placement loop takes more than 5 s, prematch prices that snap back within seconds are just noise. recheck=30 gives up 30 s of speed in exchange for knowing the line held long enough to act on.

Prematch only. The parameter applies to /odds-drop-prematch. On the live stream, /odds-drop, alerts go out in real time and a hold window would make no sense, so it is ignored there.

WebSocket feed

WS/ws/feedAPI key · $99/mo add-on

This socket relays raw upstream frames, unchanged: no drop detection, no derived values. Live updates and prematch board and market responses reach you byte for byte as they reach us, inside a thin envelope. It is meant for clients that keep their own complete copy of the book.

Not the same product as the /odds-drop SSE streams. SSE delivers drop alerts after filtering (out of the box, every drop the engine finds above its floor of roughly 0.99%; set a higher bar with ?min_drop=). The socket carries all upstream traffic, markets opening, closing and settling included, which is a much larger volume.

Pricing

  • $99/month on top of any plan with REST access (REST, Edge, Scale).
  • Not available on Free or Drops, which have no REST quota.
  • New or renewal checkout, bundled with your base plan: $99 (30 days), $297 (90 days), $594 (180 days).
  • Adding it mid-cycle is prorated to your renewal date, so you pay only for the days left. The dashboard shows the quote.

Subscribing

Send a subscribe message no later than 5 s after the socket opens. It can name whole sports (everything in them), single event_ids (just those matches), or a mix of the two:

{
  "type": "subscribe",
  "streams": ["prematch", "live"],
  "sport_ids": [3],             // every basketball event
  "event_ids": [1632907731]    // plus this one match, whatever its sport
}

Matching rule. You receive a frame if its sport_id is one of your sports or its event_id is one of your events. So sport_ids:[2], event_ids:[12345] reads as "every tennis event, and also event 12345 in whichever sport it belongs to". You may omit one of the two fields, never both.

Finding event ids. You seldom have matchup ids up front. Start with the sport, look through its snapshot for the fixtures you care about (teams, league, kick-off), then drop the sport subscription and subscribe to those event_ids alone. From then on you only pay bandwidth for the games you follow.

Event cap. At most 200 event_ids per connection per stream. A subscribe that would go past it is refused in full with {type:"error", code:"event_id_cap", ...}. Once the upstream deletes an event (op:"del"), the server drops that id from your subscription for you; there is nothing to clean up.

What the server sends

Every subscribe is followed by a snapshot for each group it created:

// For a sport: one snapshot per (stream, sport_id), listing all events the
// server holds for it right now.
{ "type": "snapshot", "stream": "prematch", "sport_id": 3,
  "events": [ /* upstream records exactly as received */ ], "ts": 1794214000000 }

// For event ids: one snapshot per batch with whichever of those records exist.
// An id the server has not seen yet starts arriving once upstream publishes it.
{ "type": "snapshot", "stream": "live", "event_ids": [1632907731],
  "events": [ /* at most one record per id */ ], "ts": 1794214000000 }

Big snapshots are split. A large baseline (prematch soccer runs to thousands of events) is spread over several snapshot frames so no single message exceeds your client's maximum message size. The pieces are numbered with "seq" starting at 0, and "final" is true on the closing piece only. A baseline that fits in one message comes as a plain snapshot with neither field. When you upsert every frame's events by event id, the same way you treat later updates, splitting needs no extra code. Only a client that swaps its whole book for the snapshot must hold off until final: true to know it has everything.

// Two pieces of one baseline; upsert both, the second one closes it.
{ "type": "snapshot", "stream": "prematch", "sport_id": 1, "seq": 0, "final": false,
  "events": [ /* ... */ ], "ts": 1794214000000 }
{ "type": "snapshot", "stream": "prematch", "sport_id": 1, "seq": 1, "final": true,
  "events": [ /* ... */ ], "ts": 1794214000000 }

From then on, frames follow every upstream push and every re-read of the prematch board:

// In-play change; topic, op and rec are passed on untouched.
{ "type": "live", "sport_id": 3, "op": "upd",
  "topic": "matchups/reg/sp/4/live/ld",
  "rec": { /* as published upstream */ }, "ts": 1794214000412 }

// Board re-read: data holds the sport's upstream matchup list as published.
{ "type": "prematch_matchups", "sport_id": 3, "data": [ /* ... */ ], "ts": 1794214006000 }

// One matchup's markets: data holds its upstream straight-market list as published.
{ "type": "prematch_markets", "sport_id": 3, "matchup_id": 1632950418,
  "data": [ /* ... */ ], "ts": 1794214006180 }

// Set change: matchups that entered or left the prematch board in this cycle.
// Push updates only cover matchups already on the board, so they never say when
// one appears or disappears. Over a 10-minute sample of all 13 sports there were
// 34 such changes and push reported none. This frame is the prompt way to learn
// about them, a matchup moving on to warmup or in-play included.
{ "type": "prematch_membership", "sport_id": 3,
  "added": [1632961007], "removed": [1632871290, 1632871344], "ts": 1794214006020 }

// Heartbeat, every 30 s. Reply {"type":"pong"}; without a reply the socket is
// closed after roughly 75 s. buffered_max_bytes = the peak amount the server had
// queued for this socket since the last ping (reset each time). 0 means you keep
// up; a number rising ping after ping means your reader is lagging, and at 128 MB
// the server closes with 1011 "deregistered: slow_consumer". Use it as an early
// warning rather than waiting for the close.
{ "type": "ping", "buffered_max_bytes": 0, "ts": 1794214030000 }

Nothing inside is altered. events, rec and data pass through untouched. Only envelope fields are added (type, stream, sport_id, topic, op, ts), and prematch frames also carry the upstream's own numeric sport id. A parser written for the upstream format works without changes.

Track the prematch set with prematch_membership.

Prematch push messages update prices of matchups you already hold. They know nothing about the boundaries of the set: no message says a fixture was created or withdrawn. Across all 13 sports, a 10-minute production sample recorded 34 additions and removals, none of them signalled on push.

A client that relies only on prematch_ws frames will therefore never see new fixtures and will keep removed ones as if their odds were live. It also explains why push stops talking about a matchup once it heads into warmup or in-play: leaving the prematch set is exactly such a change.

prematch_membership gives the same answer as reading the whole board for about 1/50,000th of the bytes, and it arrives each cycle (roughly 25 s) rather than every 10 minutes.

Suggested order: build the baseline from the snapshot pieces → take prices from prematch_ws → on prematch_membership, fetch the added ids and drop the removed ones → leave prematch_matchups aside except for an occasional full resync. You stay complete and never pull the ~19 MB board.

Sport subscriptions get every frame type. live, prematch_matchups and prematch_markets frames are delivered for sport_ids subscriptions exactly as for event_ids subscriptions. There is no mode restricted to event ids: the filter is the same, only what it matches on (a whole sport or specific ids) changes.

Live channels: ld and dz. Every live match is updated on two upstream channels, and both arrive on your single socket. You can tell which by the final part of a live frame's topic, as in matchups/reg/sp/4/live/ld:
  • ld: live_delay, the main channel (about 85% of updates). It carries the current price, the same line shown on pinnacle.com. Treat it as the source of truth.
  • dz: danger_zone (about 15%), used only in volatile moments (a goal threat, a break point, a red card) when prices are being reset hard and a suspension is often close. It is the earliest sign of a fast move, but it is jumpy.
  • pre: a prematch matchup update arriving on the live stream.

To read it: const channel = msg.topic.split("/").pop(); gives "ld", "dz" or "pre". Build your prices from ld and treat dz as a volatility or suspension signal, not as a second price. Feed both into one book and prices will seem to jump and fall back; what you are seeing is two channels taking turns, not a real move and not frames out of order (every market's stream arrives in strict order; compare rec.version per event_id and channel). Only live has this split; prematch has no danger_zone.

A fixture can have several matchup ids: key on event_id, never on team names. Upstream, one live match can appear as a parent matchup together with reissued or related child matchups; they share rec.parentId and the team names. So one and the same market (same market key, for instance s;0;s;-1.5) may live under two event_ids simultaneously, one current and one stale or closing, priced differently.
  • Key your book on rec.id (the matchup or event id) plus the market key, never on team names or parentId. If markets from different matchup ids are merged, their prices alternate and the line looks like it "spikes and reverts", while each matchup's own stream is steady and in order. The artefact comes from how the book was built, not from the market; pinnacle.com and the REST /kit endpoints never show it because each fixture is collapsed into a single canonical matchup there.
  • Deduplicate on the market version. Frames are forwarded as they come, and the upstream regularly repeats markets that did not change (identical markets[i].version and price; often over half the traffic). Ignore a market whose version you have already processed.

If you would rather have one merged row per fixture than the raw multi-matchup stream, read the REST snapshot (/kit/v1/markets); that merge is done for you there.

Two version fields: detect changes with markets[i].version, not rec.version. A live record has a version at the top, rec.version, and one on each market, markets[i].version. They do not behave alike:
  • rec.version is a coarse stamp for the record or board. Once a fixture is in play it stays frozen for the rest of the match and does not move when a price changes. Deduplicating or ordering on it makes prices appear to "change under a frozen version" and leads you to discard real updates.
  • markets[i].version is the real counter for each market. It goes up with every genuine price change and never goes backwards for a given (event_id, market key). Use it for deduplication and ordering.

A moneyline that changes while rec.version stands still is therefore not a stale or repeated frame; its markets[i].version has increased. When a spike and revert shows a higher market version on the middle frame, the upstream really did reprice briefly, and you are seeing it as sent, not a replay.

One connection per account. The limit is tied to the account, not to the API key and not to the source IP, so two processes on one account collide even from different machines. (SSE uses a separate slot; SSE and WebSocket can run together.)

A second connection is not rejected. It pushes the older one out, which is closed with close code 1001 and the reason "evicted by newer connection".

That is the typical explanation when "the socket is silent for long stretches but REST works". It is seldom an intentional second consumer. Usually a monitor or logger shares the key, a deploy brings the new instance up before the old one has gone, or a retry routine opens a fresh socket without closing the last. Two such consumers knock each other off in turn, and the one that lost the latest round gets nothing.

Supported setup: keep a single connection and fan frames out to your components inside your own process; every component then sees one and the same book. Need isolation at the transport level? Write to 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 without a reason (abnormal close; in the past caused by a frame larger than the client's maximum message size).

About 1011 "deregistered: …". The server took you out of the fan-out because of a fault on its side: a write failed, or backpressure forced an eviction. The causes in use today: deregistered: send_err, deregistered: send_threw, deregistered: slow_consumer. Test for the deregistered: prefix, not the exact text, so causes added in future are handled too. It sits outside the 1001 group deliberately, separating "closed on purpose" from "something went wrong". Retrying is safe: reconnect at once, no cooldown needed.

Compression is a trade-off; pick it on purpose. Whether permessage-deflate is used is agreed separately for each connection, and the choice is yours.

The cost. Compression uses context takeover, which makes each connection a strictly serial pipeline: every message waits for the zlib work on the one before it. Upstream updates come in mass-update bursts, and during a burst the compression queue backs up; frames that already carry their ts are compressed and flushed together, so a batch can arrive whose timestamps cover more than a second. In a back-to-back test on the same subscription (soccer + tennis live, a client with plenty of bandwidth), measured as local_recv − ts, 2.14 % of frames arrived more than one second late with deflate on and 0.00 % with it off; with deflate off the p95 and p99 tail was several times shorter.

The benefit. Live frames shrink by about 5×. One TCP connection on a long-distance path tops out near 0.4 MB/s, so when your link is the bottleneck a burst overflows it and queues in the kernel, causing lag of several seconds that compression really does remove.

Choosing. Reliable connection with spare bandwidth: switch it off and the tail disappears. Long distance, a narrow link, or a heavy multi-sport subscription: leave it on. Better still, record the local_recv − ts percentiles with and without it on your own route.

Library defaults differ. Python websockets enables it (turn it off with compression=None). Node ws and Go gorilla/websocket leave it off unless you set { perMessageDeflate: true } or EnableCompression: true. The URL and the JSON are the same either way; decompression is invisible to your code.

Maximum message size: set your client to 8 MB. It is an upper bound, not memory reserved, so it costs nothing and leaves room for more sports or specials later. 2 MB is the lowest value we would recommend. Biggest frames seen in a 5-minute soccer + tennis capture, before compression: prematch_matchups and snapshot at 512 KB each (both are split, so they cannot grow past that), prematch_markets at 32 KB and live at 13 KB (never split, as each describes a single event).

A frame larger than your client's limit makes the socket drop with 1006 and no close frame. Left whole, a soccer snapshot would weigh about 19 MB and a bulk prematch_matchups frame roughly 10 MB; that is the reason both are split.

Example clients

# wscat is a handy interactive client: npm install --global wscat
wscat --connect "wss://pinnradar.com/ws/feed?key=$PINNRADAR_KEY"

# once connected, type one subscribe line, e.g. live basketball only:
> {"type":"subscribe","sport_ids":[3],"streams":["live"]}
// npm install ws
import WebSocket from "ws";

const FEED = "wss://pinnradar.com/ws/feed?key=" + encodeURIComponent(process.env.PINNRADAR_KEY);
const MB = 1024 * 1024;

const handlers = {
  ping:              (m, sock) => sock.send('{"type":"pong"}'),
  snapshot:          (m) => console.log("baseline", m.stream, m.sport_id ?? m.event_ids, m.events.length, m.final ?? "single"),
  live:              (m) => console.log("live", m.op, m.rec.id, m.topic.split("/").pop()),
  prematch_markets:  (m) => console.log("markets for", m.matchup_id, m.data.length),
  prematch_membership: (m) => console.log("set change +", m.added.length, "-", m.removed.length),
  error:             (m) => console.error("server error", m.code, m.message),
};

function connect(attempt = 0) {
  // compression off (see the trade-off above); 8 MB ceiling per message
  const sock = new WebSocket(FEED, { perMessageDeflate: false, maxPayload: 8 * MB });
  sock.on("open", () => {
    attempt = 0;
    sock.send(JSON.stringify({ type: "subscribe", streams: ["live"], sport_ids: [3], event_ids: [1632907731] }));
  });
  sock.on("message", (buf) => {
    const m = JSON.parse(buf.toString());
    handlers[m.type]?.(m, sock);
  });
  sock.on("close", (code, reason) => {
    const wait = Math.min(30_000, 1000 * 2 ** attempt);   // 1 s, 2 s, 4 s ... 30 s
    console.warn(`closed ${code} ${reason}; retrying in ${wait} ms`);
    setTimeout(() => connect(attempt + 1), wait);
  });
}
connect();
# pip install websockets
import asyncio, json, os
import websockets

FEED = "wss://pinnradar.com/ws/feed?key=" + os.environ["PINNRADAR_KEY"]
SUBSCRIBE = {"type": "subscribe", "streams": ["live"], "sport_ids": [3], "event_ids": [1632907731]}

async def consume():
    # websockets compresses by default; compression=None switches that off
    # (see the trade-off above). max_size raises the per-message ceiling to 8 MB.
    async with websockets.connect(FEED, compression=None, max_size=8 << 20) as sock:
        await sock.send(json.dumps(SUBSCRIBE))
        async for text in sock:
            frame = json.loads(text)
            kind = frame["type"]
            if kind == "ping":
                await sock.send('{"type":"pong"}')
            elif kind == "live":
                print("live", frame["op"], frame["rec"]["id"], frame["topic"].rsplit("/", 1)[-1])
            elif kind in ("snapshot", "prematch_matchups", "prematch_markets"):
                rows = frame.get("events") or frame.get("data") or []
                print(kind, frame.get("sport_id"), len(rows))
            elif kind == "error":
                print("server error:", frame.get("code"), frame.get("message"))

async def main():
    delay = 1
    while True:
        try:
            await consume()
            delay = 1
        except (OSError, websockets.ConnectionClosed) as exc:
            print("disconnected:", exc)
        await asyncio.sleep(delay)
        delay = min(delay * 2, 30)

asyncio.run(main())
// go get github.com/gorilla/websocket
package main

import (
  "log"
  "os"

  "github.com/gorilla/websocket"
)

type Frame struct {
  Type      string            `json:"type"`
  Stream    string            `json:"stream"`
  SportID   int               `json:"sport_id"`
  Op        string            `json:"op"`
  Topic     string            `json:"topic"`
  MatchupID int64             `json:"matchup_id"`
  Code      string            `json:"code"`
  Events    []map[string]any  `json:"events"`
  Rec       map[string]any    `json:"rec"`
}

func main() {
  // compression stays off (see the trade-off above)
  d := websocket.Dialer{EnableCompression: false}
  conn, _, err := d.Dial("wss://pinnradar.com/ws/feed?key="+os.Getenv("PINNRADAR_KEY"), nil)
  if err != nil { log.Fatal("dial: ", err) }
  defer conn.Close()
  conn.SetReadLimit(8 << 20)

  sub := struct {
    Type     string   `json:"type"`
    Streams  []string `json:"streams"`
    SportIDs []int    `json:"sport_ids"`
  }{"subscribe", []string{"live"}, []int{3}}
  if err := conn.WriteJSON(sub); err != nil { log.Fatal("subscribe: ", err) }

  for {
    var f Frame
    if err := conn.ReadJSON(&f); err != nil { log.Fatal("read: ", err) }
    switch f.Type {
    case "ping":
      conn.WriteJSON(map[string]string{"type": "pong"})
    case "snapshot":
      log.Printf("baseline %s sport=%d events=%d", f.Stream, f.SportID, len(f.Events))
    case "live":
      log.Printf("live %s id=%v topic=%s", f.Op, f.Rec["id"], f.Topic)
    case "error":
      log.Printf("server error %s", f.Code)
    }
  }
}
// composer require textalk/websocket
<?php
require "vendor/autoload.php";

$client = new WebSocket\Client(
  "wss://pinnradar.com/ws/feed?key=" . urlencode(getenv("PINNRADAR_KEY")),
  ["timeout" => 60]                       // longer than the 30 s heartbeat
);
$client->text(json_encode(["type" => "subscribe", "streams" => ["live"], "sport_ids" => [3]]));

for (;;) {
  $frame = json_decode($client->receive(), true);
  $kind  = $frame["type"] ?? "";
  if ($kind === "ping") {
    $client->text('{"type":"pong"}');
  } elseif ($kind === "live") {
    printf("live %s id=%s\n", $frame["op"], $frame["rec"]["id"]);
  } elseif ($kind === "snapshot") {
    printf("baseline %s: %d events\n", $frame["stream"], count($frame["events"]));
  } elseif ($kind === "error") {
    fwrite(STDERR, "server error " . ($frame["code"] ?? "?") . "\n");
  }
}

Reading the frames

Frames are deltas. To fold one into your local copy, look at its op, its topic and what the inner rec or data contains.

Live frames: type:"live"

FieldTypeHow to use it
opstringadd: a new event, or new markets on it. upd: an incremental change, usually only the markets that moved, so merge by composite key instead of replacing the list. del: the event was removed upstream (kicked off, settled, voided); delete your copy.
topicstringUpstream topic. Live: matchups/reg/sp/{upstream sport id}/live/{ld|dz|both}; prematch: matchups/.../pre. The last segment is the channel (ld, dz, or both for a push covering both; see Live channels above). matchups/spc/... in place of reg indicates a special matchup (props or futures).
rec.idintThe matchup id; it stays fixed for as long as the event exists.
rec.marketsarrayOn op:"upd" this may be a SUBSET, because only changed markets are sent. Merge by composite key (m.key when present, else type|period|side|points). A market whose m.status !== "open" means CLOSED: remove it from your list rather than merging it back as active.
rec.periodsarrayStatus per period. periods[n].status === "closed" or "settled" closes EVERY market of that period, so remove them locally even if markets did not list them.
rec.parentIdint?Set on a CHILD matchup (alternate periods, props spun off a parent). Participants, league and other metadata live on the parent's record.
rec.startTime, statusstringEvent metadata. Tennis, golf and fights often get a new startTime with no market change (a reschedule); track it so you can show it to your users.

Prematch frames: prematch_matchups and prematch_markets

FieldTypeHow to use it
typestringprematch_matchups: the upstream matchup list for one sport (/sports/{sid}/matchups), delivered once each board cycle (at present every 25–45 s). Prematch price moves arrive sooner, as prematch_ws frames, the moment upstream pushes them. The list contains live as well as prematch entries; keep only data[i].isLive === false for strict prematch.
prematch_markets: the upstream straight-markets list for one matchup (/matchups/{id}/markets/related/straight). It is sent only after upstream increments matchup.version, which in practice means the events whose markets changed during the latest cycle.
dataarrayFor prematch_matchups: complete matchup records (event metadata). For prematch_markets: the market objects of one matchup.
This is the authoritative current state upstream: any market you hold that is NOT in the list has been closed, so delete it. For a big sport such as soccer the frame is split into parts tagged seq/final (as with the subscribe snapshot). In that case the authoritative set is the union of all parts in the group: collect until final: true before deleting anything.
matchup_idint(prematch_markets only) Which matchup this market list describes.
modestring?Only present while the upstream push channel is down and live data is being polled instead. Value: "rest_fallback". The frame is otherwise identical, so most clients can ignore it.

Merge recipe (pseudocode)

// One BookKeeper per connection: matchup id -> record with a merged market list.
class BookKeeper {
  constructor() { this.records = new Map(); }

  // identity of a market inside one matchup
  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) {
    switch (frame.type) {
      case "snapshot":            // baseline piece: upsert every record
        frame.events.forEach((rec) => this.records.set(rec.id, rec));
        break;
      case "live":
        if (frame.op === "del") this.records.delete(frame.rec.id);
        else this.mergeLive(frame.rec);
        break;
      case "prematch_markets":    // complete list for one matchup: overwrite, never merge
        this.records.set(frame.matchup_id, { ...this.records.get(frame.matchup_id), markets: frame.data });
        break;
    }
  }

  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);   // closed market
      else book.set(id, mk);
    }
    // a period that is no longer open takes all its markets with it
    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);
  }
}

Reconnecting. Subscribe again after every reconnect. Nothing is kept on the server between connections: the snapshot at the start of each connection is your sync point. Use exponential backoff (1 s → 2 s → 4 s, capped at 30 s). One connection per account on this endpoint (a slot separate from SSE, so both can run; a second WebSocket closes the first with code 1001).

Limits and error frames

Code / behaviourCauseFix
sport_id_capA single subscribe naming over 32 sport_idsSplit them over several messages (rarely needed with about 14 real sports)
event_id_capOver 200 event_ids tracked on a streamRemove finished events; op:"del" frees ids by itself
subscribe_rate_limitedOver 30 subscribes within 10 sGroup more sports or events into each subscribe
backpressureSubscribe sent while the connection has more than 32 MB waiting to be sentThe sport was NOT subscribed: catch up on reading, then subscribe again
slow_consumer (close)Unsent buffer above 128 MB because you read too slowlyThe connection is closed; reconnect and read faster or subscribe to less
rate_limited (HTTP 429)Too many connection attempts against the endpointHonour Retry-After; do not hammer reconnects
plan_lacks_ws (HTTP 403)The account has no active WebSocket add-onPurchase it from the dashboard
invalid_json / invalid_message / unknown_message_typeA malformed client messageFix the payload; compare with the subscribe example

To stop a subscription, send {"type":"unsubscribe", "streams":[…], "sport_ids":[…]}. The server confirms each subscribe and unsubscribe with {"type":"subscribed"|"unsubscribed", "stream", "sport_id"}; rely on that reply (and on the snapshot), not on your send succeeding.

MCP server

Through a hosted Model Context Protocol server, AI assistants such as Claude, Cursor, Codex and VS Code can query odds using the API key you already own. Point the client at one URL, then simply ask: "what NBA games are on right now, and where is the total?", "list the largest tennis price drops from the past 10 minutes", "how many requests do I have left today?"

MCPhttps://mcp.pinnradar.com/mcpBearer API key

Transport is Streamable HTTP: stateless, POST requests only. Either sign-in method ends up using your regular API key, which means every call is billed to the same plan limits and shows up in your dashboard next to your REST traffic:

  • OAuth 2.1 (claude.ai and other clients that support it): the client discovers /.well-known/oauth-protected-resource, you confirm a single time on PinnRadar's authorisation page by typing your key, and from then on the client holds a short-lived, self-refreshing token.
  • Bearer API key (Claude Code, Cursor, VS Code, Codex, the Claude API): the client sends the key itself.
Authorization: Bearer <API key, or the access token issued by OAuth>

Connect a client

# the key is read from your shell, never written into the command history
claude mcp add -t http -H "Authorization: Bearer $PINNRADAR_KEY" pinnradar https://mcp.pinnradar.com/mcp

Run /mcp in Claude Code afterwards: pinnradar should show as connected, exposing 8 tools. To share the setup with a team, commit the entry to .mcp.json using "Authorization": "Bearer ${PINNRADAR_KEY}", so each person's key comes from their own environment.

1. Settings → Connectors → Add custom connector
2. Name it pinnradar, URL https://mcp.pinnradar.com/mcp
3. Leave both OAuth client fields blank, press Add, then Connect

You are sent to PinnRadar's authorisation page: type your API key once and confirm. The connector then works with a short-lived token that renews itself, so the key stays on PinnRadar's side. Deleting the connector revokes its access.

{
  "mcpServers": {
    "pinnradar": {
      "headers": { "Authorization": "Bearer PASTE_KEY_HERE" },
      "url": "https://mcp.pinnradar.com/mcp",
      "type": "http"
    }
  }
}

Open it from Claude Desktop via Settings → Developer → Edit Config, add the block, restart the app.

// global: ~/.cursor/mcp.json · per project: .cursor/mcp.json
{
  "mcpServers": {
    "pinnradar": {
      "headers": { "Authorization": "Bearer PASTE_KEY_HERE" },
      "url": "https://mcp.pinnradar.com/mcp"
    }
  }
}
// .vscode/mcp.json: VS Code asks for the key once and stores it securely
{
  "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/config.toml (the key itself stays in the PINNRADAR_KEY variable)
[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
"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

ToolWhat it returnsCost
board_sportsThe sport list with ids.free
find_matchesLive or prematch events of one sport, narrowed with query (team or league words), with a row limit.1 call
match_pricesOne event's moneyline, spreads, totals and team totals for the chosen periods; in concise mode, the 5 lines nearest the main line.1 call
upcoming_linesEvery alternate line of a single market on a prematch event.1 call
price_movesLatest sharp price falls (steam), with filters for sport, market, period, drop size and age.1 call
feed_healthWhether the feeds are healthy and how current the data is.1 call
my_usagePlan, limits, expiry and today's request count.1 call
search_pinnradar_docsSearches this reference.free
Quota. Every tool call fetches live prices and counts as one request on your plan, the same as a REST call; there is no cache in between. Results are trimmed to keep the assistant's context lean. A Free key's 100 daily requests can run out during one long analysis session. After a rate_limited answer the server also holds off upstream calls until the window resets. The SSE and WebSocket streams are not offered over MCP; connect to those directly.

Questions go to @PinnRadarSupport on Telegram or info@pinnradar.com.

Status

GET/healthAPI key

The operating state of both feeds, for dashboards and alerting.

The fields at the root report on the live feed. The prematch object reports on the prematch feed, a separate service; one can be degraded while the other is fine, so watch both.

{
  "status": "healthy",          // "healthy" or "degraded"; reflects the live feed alone
  "degraded_reason": null,
  "source": "ws",              // "ws", "rest" or "none"
  "live_write_age_ms": 212,     // age of the newest live write
  "events_in_store": 208,
  "version": 61120874,        // the same counter /kit returns as `last`
  "ws_messages": 2284017,
  "rest_polls": 171552,
  "uptime_s": 402115,
  "prematch": {
    "enabled": true,
    "status": "healthy",        // "healthy", "degraded" or "unreachable"
    "degraded_reason": null,
    "source": "ws",
    "write_age_ms": 340,         // age of the newest prematch write
    "events_in_store": 17240,
    "version": 61120902,
    "rest_polls": 118377,
    "checked_age_ms": 860        // how long ago this prematch block was read
  }
}

Alerting on it

  • The root status follows the live feed alone; prematch trouble never flips it. Monitor prematch.status separately.
  • Alert on freshness with live_write_age_ms and prematch.write_age_ms. Both are normally well under a second.
  • A prematch.status of "unreachable" means the check itself failed to reach the prematch service. Its data may well be fine; treat the value as "unknown" rather than "down".
  • A deployment without a prematch service reports "prematch": {"enabled": false} and nothing more.

GET/pingPublic

Unauthenticated liveness probe that replies ok in plain text, for load balancers and uptime checkers that have no way to add a key.

$ curl -s https://pinnradar.com/ping
ok

Sport IDs

The API's sport ids are stable integers, so code written against one keeps working. Each one corresponds to one or more Pinnacle sport ids (API 1, Soccer, is Pinnacle 29, for instance).

SportAPI idPinnacle id(s)
Soccer129
Tennis233
Basketball34
Hockey419
Football515
Baseball63
Rugby, union and league together727, 26
MMA: UFC, Bellator, ONE and more822
Boxing96
Other: volleyball plus handball1034, 18
Esports: CS2, Dota 2, LoL and more1112
Golf: PGA, DP World Tour, LIV (head-to-heads)1217
Cricket: Test, ODI, T20138
Volleyball only (subset of 10)1434
Handball only (subset of 10)1518

Volleyball and handball (14 / 15). Asking for sport_id=10 gives both sports in a single board, while 14 and 15 are filtered views of that same data (volleyball without handball, or the reverse). Because they overlap with 10, polling all three returns each volleyball and handball event twice. The narrow ids are accepted by the REST endpoints (/kit/v1/markets, /kit/v1/prematch/*, /api/drops). On the WebSocket feed these events are routed under 10; subscribing to 14 or 15 there returns an error instead of a silent, empty subscription.

Golf. Upstream prices golf as moneyline only (head-to-head matchups and outright winners): no spreads, totals or team totals. On golf events, spreads, totals and team_total are therefore empty objects, and golf drops only ever come from the Moneyline section.

Market types

Each of these lives in an event's periods[<num_N>] object.

KeyStructureContents
money_line{ home, away, draw }Outright result in decimal odds: three-way, or two-way where a sport has no draw.
spreads{ "<hdp>": { hdp, home, away, max } }Handicap lines, one entry per handicap value.
totals{ "<points>": { points, over, under, max } }Over/under lines, one entry per total.
team_total{ home: {...}, away: {...} }The main team-total line for each side. Kept for backward compatibility, so it holds just ONE line per side; for every alternate line use team_totals.
team_totals{ home: { "<points>": {points, over, under, max} }, away: {...} }ALL alternate team-total lines per side, keyed by points; the one to read when a side has several (say home Over 0.5, 1.5 and 2.5).
metaobjectAbout the period: score so far and stake limits.

Event object

{
  "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,      // when this response was built, not when the event changed
  "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": { /* keyed by points, like away */ }
      },
      "meta": { "number": 0, "max_total": 1500, "home_score": 1, "away_score": 1 }
    },
    "num_1": { /* 1st half, same keys */ }
  },
  "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 }]
  }
}

Rows for special markets (sent only with include_specials=1, or when you ask for their event_id directly) add special, special_category, special_units and special_markets, and leave periods empty; see Player props & specials. BTTS and Draw No Bet are the exception: they keep the older layout (special and special_outcomes, prices placed in the money_line fields) and are included with or without the flag.

The two last fields mean different things.
  • At the top level (last / last_call, beside sport_id), last is the store's sequence number. It only ever increases, and it is what you pass to since=.
  • On each event, last is the Unix time in seconds when the server assembled the response. All events in one payload share that one value; it tells you nothing about when an event's prices changed, so never use it as a freshness signal.
  • updated_at (on each event) is the field for freshness: epoch milliseconds of the last genuine change to the event's markets (a new price, a line appearing or disappearing, a status change). A repeat of the same prices from upstream leaves it alone. After a restart it counts from when the event was seen again, so read it as "no change since at least this time".

Detecting stale prices

Each REST response carries three headers describing the age of the data behind it. They answer the question most directly, so look there first.

HeaderValueInterpretation
X-Data-StatusfreshCurrent data, safe to act on
X-Data-StatusstaleThe upstream has been silent for longer than the threshold. The board is held back: you get the normal empty shape rather than an error, so your parser keeps working. This is how you distinguish "no fresh data" from "no matches at the moment"
X-Data-StatusunknownNo data written so far (the service is starting up); it does not indicate an outage
X-Data-Age-MsintegerHow old the latest data on that board is, in ms, truncated to whole seconds. Left out when unknown, never filled with a dummy like -1
X-Data-Boardlive · prematchThe board the other two headers refer to, so a client polling both can tell them apart
# print only the freshness headers of a live board call
curl -s -o /dev/null -D - --header "x-portal-apikey: $PINNRADAR_KEY" \
  "https://pinnradar.com/kit/v1/markets?sport_id=1" | grep -i '^x-data-'
# x-data-board: live
# x-data-status: fresh
# x-data-age-ms: 1000
  • Pick your own tolerance. Under the hold-back threshold you keep getting the last known prices, which is usually what you want, since a market that is quiet is not one that is broken. For a stricter limit, check X-Data-Age-Ms against your own bound and skip that cycle on your side.
  • The body's last does NOT tell you the data's age. It is a serving cursor, identical on every event of a response. Freshness comes from the headers; change tracking comes from last. They are separate tools.
  • REST only. These headers are not yet sent on the SSE and WebSocket streams. There, a lack of messages is what tells you something is wrong: keep polling /health next to the stream; it switches to degraded with a reason such as no live write for 900s.

To fetch only changes and save bandwidth, pass back the top-level last cursor. It is precise, and although a full board is big, a delta is normally just a few events.

# first call: whole board, remember "last"
CURSOR=$(curl -sG https://pinnradar.com/kit/v1/prematch/fixtures \
  --header "x-portal-apikey: $PINNRADAR_KEY" -d sport_id=1 | jq '.last')

# next calls: only the events that changed since then
curl -sG https://pinnradar.com/kit/v1/prematch/fixtures \
  --header "x-portal-apikey: $PINNRADAR_KEY" -d sport_id=1 -d since="$CURSOR" \
  | jq '{last, changed: (.events | length)}'
# { "last": 61121377, "changed": 9 }
  • Feed quiet: last does not move from one poll to the next. X-Data-Age-Ms tells you outright; /health is a second opinion, and its live_write_age_ms and prematch.write_age_ms show which feed has gone quiet.
  • Event unchanged: last advances but the event is absent from the since= reply. Its prices truly are the same; keep what you cached.
  • Event gone: a full response (without since=) no longer contains it. Either it was withdrawn or it kicked off and now belongs to the live feed.
  • Each time, send the last of the reply you just got. Do not reuse an old cursor and do not swap in a timestamp.

Need a true timestamp for each price move? /api/drops gives every drop its own ts (epoch ms) and age_s.

Live state object

In-play events carry a state object: the game state as upstream publishes it, with sport-specific field names left exactly as they are. Prematch events have state: null.

It is made of three pieces:

  • state.match: state of the match as a whole (clock, running period); contents depend on the sport.
  • state.home / state.away: where each side stands right now.
  • state.home_stats / state.away_stats: one entry per period. For now only the period number is populated; no per-period statistics yet.
SportIn state.matchIn state.home / state.away
Soccer (1)state, a period code (1 first half, 2 half-time, 3 second half, 9 full time; a 4 can flash briefly between 3 and 9), and minutes, counted inside the running half: it goes back to 0 when the second half starts, and at half-time it measures the intervalscore, redCards
Basketball (3)quarter (1 to 4, then overtime), timeRemainingInQtr (an "MM:SS" string)score
Tennis (2)set, the set being playedsetsWon, gamesBySet (array), points (a string: "0", "15", "30", "40" or "Adv"), serving (boolean)
Other sportsDiffers; period and state appear oftenscore every time, plus occasional sport-specific extras.

Note. The per-side part holds score and redCards, next to the clock and period at match level. Soccer adds corners and yellowCards; those are taken from the parent fixture and may lag or lead the record's own score by a few seconds, and some matches lack them. Fields with a default value are left out, so code every field as optional.

Where to find it: raw state is part of GET /kit/v1/markets?event_type=live. The prematch endpoints (/kit/v1/prematch/*) do not have it, as none of this exists before kick-off. On the /odds-drop SSE stream every drop carries the same facts in a normalised live object, covered next.

The live object on /odds-drop drops

Every live drop includes a live object describing the match when the price fell. It holds the most recent state known for the event, even when the frame that moved the price did not include it. It is always the final key of the drop and is absent only when the event has no published state (true of most sports other than soccer, tennis, basketball and hockey). Keys keep the order below; a key that does not apply to the sport is null.

KeyTypeMeaning
statusstringHuman-readable state: soccer "1st Half - 16'", "Half Time" or "2nd Half - 22'", basketball "4th Quarter - 7'", every other sport "Live Now".
periodstring | nullBasketball: "Q1" to "Q4"; "Q5" or higher is overtime and "Q-1" means unknown.
clockstring | nullBasketball: minutes remaining in the quarter, rounded down, like "7'".
minuteint | nullSoccer: the minute inside the running half (resets to 0 for the second half, reads 0 during half-time).
score{home, away} | nullThe score for soccer, basketball and hockey; null in tennis, where sets and games apply.
red_cards, yellow_cards, corners{home, away}Soccer, each one only when known. Yellows and corners are taken from the parent fixture and may arrive a few seconds after the score.
current_set, sets, games, game_points, servermixedTennis. current_set is an int; sets is {home, away} sets won; games is {home: [..], away: [..]}, games per set; game_points is {home, away} as strings ("0", "15", "30", "40", "Ad", and tiebreak points as numeric strings); server is "home" or "away".
favorite"home" | "away" | nullWhichever side was shorter on the full-game moneyline at that instant; null if they were level or no moneyline was open.
updated_msintUNIX ms, by our clock, of the latest change to this state.
source_msintUNIX ms of the most recent frame of any kind received 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}

Each drop's own home_score / away_score come from that state as well, for basketball and hockey too. For basketball, the legacy quarter and time_remaining_in_qtr fields are still sent alongside.

Affiliate program

Every account comes with a personal referral link. If a person signs up through it and goes on to pay, you receive 30% of each of their payments while their subscription remains active, whether it is the first charge, a renewal or an upgrade.

ItemHow it works
Your linkFound on your dashboard and shaped like https://pinnradar.com/?ref=<8-char-code>. Post it wherever you like.
Commission30% on each USD payment from the people you referred, renewals and upgrades included, for as long as they pay; it is recurring, not a single bonus.
AttributionA signup counts when the visitor arrives on pinnradar.com with your ?ref= parameter, then signs up and verifies the email address in the same browser session. There is no cross-device tracking; attribution is per session.
TrackingYour dashboard lists every signup credited to you, whether each is on a paid plan, and what you have earned (unpaid and paid out).
PayoutsHandled manually. Once your unpaid balance reaches $50 or more, we contact you to arrange the transfer (crypto, bank transfer or PayPal, as you prefer). For a faster payout, email info@pinnradar.com.
Self-referralNot allowed: you cannot sign up through your own link.
RefundsIf we refund a referred customer's payment (rare), the matching commission is cancelled. Commissions already paid are not reclaimed for ordinary churn, only for actual refunds.

Privacy. The dashboard shows referred users' emails partly hidden (for example m***@outlook.com): enough to recognise your own invitees, without full addresses ending up in screenshots. Full addresses are visible only to admins in internal tools.

Missing something? Historical data, more than 30 requests per second, or a market the API does not expose yet: message @PinnRadarSupport on Telegram or write to info@pinnradar.com with what you need.