Contents

API Documentation

Live and prematch odds from Pinnacle, plus a real-time dropping-odds engine — delivered over a REST API that mirrors popular third-party formats, with optional Server-Sent Events streams for push-based clients. Underlying live feed is MQTT WebSocket so updates arrive within ~200 ms.

Introduction

Base URL for this deployment: https://pinnodds.com. Every endpoint below is relative to that host. All responses are JSON.

All requests are authenticated with an API key — your key is your login; there's no username or password. Get a free trial key in one click from the landing page.

Drop-in compatibility. The /kit/v1/* endpoints mirror the shape of common third-party Pinnacle API formats. Swap the base URL + key in your existing client and it should just work.
Integrating with an AI assistant? Point Cursor / Claude / ChatGPT at /llms-full.txt — full API reference in one plain-text fetch, optimized for LLM context windows. The shorter /llms.txt index is auto-discovered by tools that probe the root.

Authentication

Pass your API key in one of three ways, in priority order:

  1. x-portal-apikey header (recommended — matches legacy third-party APIs)
  2. x-api-key header
  3. ?key= query parameter (useful for browsers / one-offs)
# Header (recommended)
curl -H "x-portal-apikey: YOUR_KEY" \
  "https://pinnodds.com/kit/v1/markets?sport_id=2&event_type=live"

# Query string
curl "https://pinnodds.com/kit/v1/markets?sport_id=2&event_type=live&key=YOUR_KEY"

Rate limits

Limits are enforced per-key across fixed windows. When you exceed one, you get 429 with a Retry-After header.

PlanPriceSecondMinuteHourDayTotalSSE drops
Trial$020100100Unlimited
Stream$99/mo20100100Unlimited✓ Included
Pro$99/mo10 req/secUnlimited
Pro + SSE$149/mo10 req/secUnlimited✓ Included
Scale$229/mo30 req/secUnlimited✓ Included

Trial is capped at 100 requests per day total — once you exceed it you'll get 429 with window=day until UTC midnight rolls over. Stream ($99/mo) gives you push SSE drops only. Pro ($99/mo) gives you REST 10 req/sec only. Pro + SSE ($149/mo) bundles both — saves $49 vs buying separately.

# 429 response when you hit a limit
HTTP/1.1 429 Too Many Requests
Retry-After: 41
Content-Type: application/json

{
  "error": "rate_limited",
  "window": "minute",
  "limit": 20,
  "retry_after_ms": 41052
}

Errors

StatusError codeWhen it happens
400Malformed or missing required parameter
401missing_keyNo API key was sent
401invalid_keyKey not in our DB (typo, deleted, or regenerated)
401UnauthorizedSSE token missing or rejected (only on /odds-drop* streams)
403plan_lacks_sseKey is valid but the plan doesn't include SSE streams (Stream, Pro + SSE, Scale only)
403plan_lacks_wsKey is valid but the WebSocket add-on isn't active on the account
413payload_too_largeRequest body over 64 KB (POST endpoints)
429rate_limitedKey exceeded its rate limit — honor the Retry-After header. Also sent on connection floods to SSE/WS endpoints
500internalUnexpected server error — safe to retry with backoff
503prematch_disabledPrematch endpoint hit while the prematch ingester is off

Live odds

Snapshot of every event currently in-play. Updates arrive via the upstream MQTT WebSocket so each call returns fresh prices (typical lag <200 ms).

GET /kit/v1/markets API key

Returns every event for a sport, with all markets (moneyline, spreads, totals, team totals) across every period (match, 1st half, 2nd half, etc.). Live by default; pass event_type=prematch to switch feeds.

Query parameters

NameTypeDescription
sport_id requiredintegerDropping-odds sport id. See Sport IDs.
event_type"live" | "prematch"Defaults to live. Pass prematch to receive prematch fixtures with the same response envelope. Both flavors return identical event shape — only event_type on each event differs. Equivalent to /kit/v1/prematch/fixtures with that param set.
sinceintegerReturn only events that changed since this last value (incremental polling).
include_specials1 | nestedInclude special-market events (Player Props, Team Props, Exact Scores, futures). 1 = flat extra rows linked by parent_id (delta-friendly); nested = grouped under each parent event's specials array (one row per match). Off by default. See Player props & specials.
is_have_odds0 | 1Legacy compat — accepted and ignored. We always return events with odds.
Tip. A live and a prematch call return the same envelope and the same per-event field shape — only the event_type on each event differs. Existing client code that iterates events[*].periods.num_0 works for both feeds without branching.
curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/kit/v1/markets?sport_id=2&event_type=live"
const res = await fetch(
  `${BASE}/kit/v1/markets?sport_id=2&event_type=live`,
  { headers: { "x-portal-apikey": KEY } }
);
const { events, last } = await res.json();
import requests
r = requests.get(
  f"{BASE}/kit/v1/markets",
  params={"sport_id": 2, "event_type": "live"},
  headers={"x-portal-apikey": KEY},
)
data = r.json()
package main

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

req, _ := http.NewRequest("GET", BASE+"/kit/v1/markets?sport_id=2&event_type=live", nil)
req.Header.Set("x-portal-apikey", KEY)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var out struct { Events []map[string]interface{} `json:"events"`; Last int64 `json:"last"` }
json.NewDecoder(resp.Body).Decode(&out)
<?php
$ch = curl_init(BASE . "/kit/v1/markets?sport_id=2&event_type=live");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER     => ["x-portal-apikey: $KEY"],
]);
$body = curl_exec($ch);
curl_close($ch);
$data = json_decode($body, true);

Response — see Event object

{
  "sport_id": 2,
  "sport_name": "Tennis",
  "last": 141,
  "last_call": 141,
  "events": [
    { /* see Event object below */ }
  ]
}
GET /kit/v1/details API key

Fetch a single event by ID. Same event shape as /kit/v1/markets.

NameTypeDescription
event_id requiredintegerArcadia matchup ID from a previous /markets response.
curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/kit/v1/details?event_id=1628594960"

Prematch odds

Pinnacle's prematch lines for upcoming fixtures. Backed by a separate REST poller (refreshes every 30 s) and stored independently from the live feed — querying it never starves the live snapshot.

When to use which. Use /kit/v1/markets for in-play prices, /kit/v1/prematch/fixtures for upcoming-event lines. The two surfaces share the same sport IDs and event-object shape so client code can branch on a single field.
GET /kit/v1/prematch/fixtures API key

Every prematch fixture for a sport, with its current full markets/periods structure. Mirrors the shape of /kit/v1/markets — only the event_type field differs.

Query parameters

NameTypeDescription
sport_id requiredintegerDropping-odds sport id. See Sport IDs.
sinceintegerReturn only events that changed since this last value (incremental polling).
include_specials1 | nestedInclude special-market events (Player Props, Team Props, Exact Scores, futures). 1 = flat extra rows linked by parent_id (delta-friendly); nested = grouped under each parent event's specials array (one row per match). Off by default. See Player props & specials.
curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/kit/v1/prematch/fixtures?sport_id=1"
const r = await fetch(
  `${BASE}/kit/v1/prematch/fixtures?sport_id=1`,
  { headers: { "x-portal-apikey": KEY } }
);
const { events } = await r.json();
const withOdds = events.filter(e => e.is_have_odds && e.periods?.num_0);
import requests
r = requests.get(
  f"{BASE}/kit/v1/prematch/fixtures",
  params={"sport_id": 1},
  headers={"x-portal-apikey": KEY},
)
events = r.json()["events"]
with_odds = [e for e in events if e.get("is_have_odds") and e.get("periods", {}).get("num_0")]
req, _ := http.NewRequest("GET", BASE+"/kit/v1/prematch/fixtures?sport_id=1", nil)
req.Header.Set("x-portal-apikey", KEY)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var out struct { Events []map[string]interface{} `json:"events"` }
json.NewDecoder(resp.Body).Decode(&out)
// out.Events[i] has periods, money_line, spreads, totals, team_total, etc.
<?php
$ch = curl_init(BASE . "/kit/v1/prematch/fixtures?sport_id=1");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER     => ["x-portal-apikey: $KEY"],
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
$with_odds = array_filter($data["events"], fn($e) => ($e["is_have_odds"] ?? false) && isset($e["periods"]["num_0"]));

Response — same shape as /kit/v1/markets with event_type: "prematch" on each event:

# → 200 OK — same envelope as /kit/v1/markets, only event_type differs
{
  "sport_id": 1,
  "sport_name": "Soccer",
  "last": 523864,
  "last_call": 523864,
  "events": [
    {
      "event_id": 1629513753,
      "sport_id": 1,
      "league_id": 5488,
      "league_name": "Italy - Serie A",
      "home": "Cremonese",
      "away": "Lazio",
      "starts": "2026-05-04T16:30:00Z",
      "start_ts": "2026-05-04T16:30:00Z",  // alias of starts (back-compat)
      "event_type": "prematch",
      "is_have_odds": true,
      "is_have_periods": true,
      "periods": {
        "num_0": {
          "number": 0,
          "description": "Game",
          "money_line": { "home": 2.45, "draw": 3.30, "away": 2.90 },
          "spreads": { "-0.5": { "hdp": -0.5, "home": 2.06, "away": 1.82, "max": 500 } },
          "totals":  { "2.5":  { "points": 2.5, "over": 1.90, "under": 1.98, "max": 500 } },
          "team_total": { "home": { "points": 1.5, "over": 1.86, "under": 2.00 } },
          "meta": { "number": 0, "max_total": 500, "home_score": 0, "away_score": 0 }
        }
        // num_1 (1st half), num_2 (2nd half), etc. when offered
      },
      "status": "pending"
    }
  ]
}
GET /kit/v1/prematch/markets API key

Full markets payload for a single prematch event — every period, money line, spread, total, team total. Use this once you've picked an event from /prematch/fixtures.

NameTypeDescription
event_id requiredintegerPinnacle matchup ID.
curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/kit/v1/prematch/markets?event_id=1629513753"
GET /kit/v1/prematch/lines API key

Line view for a single prematch event, optionally narrowed to one market type. Returns the same periods structure (num_0, num_1, …) as /markets; with market_type set, each period carries only that market — a lighter payload, ideal for tickers.

NameTypeDescription
event_id requiredintegerPinnacle matchup ID.
market_typestringFilter to one market: money_line | spreads | totals | team_total. Omit for all markets.
curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/kit/v1/prematch/lines?event_id=1629513753&market_type=totals"

Player props & specials

Beyond the main match markets, we carry Pinnacle's special markets: Player Props (anytime/first goalscorer, "player X to score", player to be booked), Team Props (HT/FT, winning margin, exact total goals, odd/even, 3-way handicaps, "team to score" …), Exact Scores (correct score), and tournament futures (to reach the final, outrights). Each special is its own event row with its own event_id, linked to the main match by parent_id.

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

Specials are excluded by default so existing integrations keep their payload size — opt in with include_specials=1 (flat rows, cheapest with ?since polling) or include_specials=nested (specials grouped under each parent event's specials array — one row per match) on /kit/v1/prematch/fixtures or /kit/v1/markets. To fetch a single special's prices, pass its event_id to /kit/v1/prematch/markets — no flag needed there.

Special-row fields (on top of the standard event fields):

FieldTypeDescription
specialstringMarket description, e.g. "Anytime Goalscorer", "Mohamed Salah To Score", "Winning Margin".
special_categorystring"Player Props" | "Team Props" | "Exact Scores" | "Futures" | …
special_unitsstringUnits the outcomes are counted in (e.g. "Regular", "Corners", "Runs").
parent_idintegerThe main match's event_id — group a fixture's specials by this.
special_marketsobjectNamed-outcome prices per period: {"num_0": [{type, key, side, max_risk, prices: [{name, participant_id, points, price}]}]}. price is decimal odds; name is the outcome ("Mohamed Salah", "Yes", "2-1", "4+"); max_risk is Pinnacle's max stake for the market.

Example — player props for Australia vs Egypt (main event 1632164518):

curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/kit/v1/prematch/fixtures?sport_id=1&include_specials=1"
# → rows with parent_id 1632164518 include:
#   1632190502  "Anytime Goalscorer"        (Player Props)
#   1632190468  "First Goalscorer"          (Player Props)
#   1632278803  "Mohamed Salah To Score"    (Player Props)
#   1632181240  "Winning Margin"            (Team Props)  … ~50 more

curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/kit/v1/prematch/markets?event_id=1632278803"
{
  "events": [{
    "event_id": 1632278803,
    "home": "Australia", "away": "Egypt",
    "parent_id": 1632164518,
    "special": "Mohamed Salah To Score",
    "special_category": "Player Props",
    "special_markets": {
      "num_0": [{
        "type": "moneyline", "max_risk": 1000,
        "prices": [
          { "name": "No",  "price": 1.33 },
          { "name": "Yes", "price": 3.43 }
        ]
      }]
    }
  }]
}
Scope notes. Specials do not fire on the SSE drop streams (BTTS and Draw No Bet remain the only alerting specials); their price moves do appear on /api/drops with participant_name set. Specials are thin markets — check max_risk before sizing. Like all prematch rows, a special's ID dies at kickoff.

Dropping odds

Detect price movements ("drops") in real time across both live and prematch feeds. A drop fires when an outcome's decimal odds fall by ≥ a threshold in a configured window. Each drop carries the from and to price plus full event context.

GET /api/drops API key

Pull-style queryable buffer of recent drops. Apply filters and read the full result set — useful for batch jobs, dashboards, or polling clients.

Each drop carries nvp — the vig-removed ("no-vig") fair price for that outcome, the same value emitted on the SSE streams. Use it to gauge edge, e.g. edge = to / nvp - 1.

Query parameters

NameTypeDescription
mode"live" | "prematch"Which feed to query. Defaults to live.
sport_idintegerFilter to a single sport. Omit for all.
min_drop_pctnumberMinimum drop percentage (default 5). Fractional allowed.
max_drop_pctnumberOptional cap.
max_age_secintegerOnly return drops fresher than this. Up to ~3 h.
marketscsvSubset of moneyline,spread,total,team_total.
periodscsvPeriod numbers (0=full match, 1=1st half, etc.).
live0 | 1Live-mode only. 1 = exclude any drops on events not yet started.
limitintegerMax drops to return (default 500).
# Live drops, soccer only, ≥7%, last 10 min
curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/api/drops?mode=live&sport_id=1&min_drop_pct=7&max_age_sec=600"

# Prematch drops, all sports, last hour
curl -H "x-portal-apikey: $KEY" \
  "https://pinnodds.com/api/drops?mode=prematch&min_drop_pct=2&max_age_sec=3600"
const qs = new URLSearchParams({
  mode: "live", sport_id: 1, min_drop_pct: 7, max_age_sec: 600,
});
const r = await fetch(`${BASE}/api/drops?${qs}`, {
  headers: { "x-portal-apikey": KEY },
});
const { total, drops } = await r.json();
for (const d of drops) {
  const ev = (d.to / d.nvp) - 1;       // edge vs no-vig price
  if (ev > 0.02) console.log("+EV", d.home, d.market, d.from, "→", d.to);
}
import requests
r = requests.get(
  f"{BASE}/api/drops",
  params={
    "mode": "live", "sport_id": 1,
    "min_drop_pct": 7, "max_age_sec": 600,
  },
  headers={"x-portal-apikey": KEY},
)
for d in r.json()["drops"]:
    ev = (d["to"] / d["nvp"]) - 1
    if ev > 0.02:
        print("+EV", d["home"], d["market"], d["from"], "→", d["to"])
q := url.Values{}
q.Set("mode", "live"); q.Set("sport_id", "1")
q.Set("min_drop_pct", "7"); q.Set("max_age_sec", "600")

req, _ := http.NewRequest("GET", BASE+"/api/drops?"+q.Encode(), nil)
req.Header.Set("x-portal-apikey", KEY)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var out struct {
  Total int `json:"total"`
  Drops []struct {
    Home, Market string; From, To, NVP float64
  } `json:"drops"`
}
json.NewDecoder(resp.Body).Decode(&out)
<?php
$qs = http_build_query([
  "mode" => "live", "sport_id" => 1,
  "min_drop_pct" => 7, "max_age_sec" => 600,
]);
$ch = curl_init(BASE . "/api/drops?$qs");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER     => ["x-portal-apikey: $KEY"],
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach ($data["drops"] as $d) {
  $ev = ($d["to"] / $d["nvp"]) - 1;
  if ($ev > 0.02) echo "+EV {$d['home']} {$d['from']} → {$d['to']}\n";
}
# Response
{
  "total": 42,
  "drops": [
    {
      "event_id": 1629725918,
      "sport_name": "Soccer",
      "league": "Australia - NPL Victoria",
      "home": "Bentleigh Greens",
      "away": "St Albans Saints",
      "market": "spread",
      "period": 0,
      "side": "home",
      "points": -0.5,
      "from": 2.37,
      "to": 2.25,
      "nvp": 2.34,
      "drop_pct": 5.06,
      "age_s": 12,
      "is_live": false
    }
  ],
  "meta": { "mode": "live", "events_in_store": 2552, "tracked_outcomes": 122035,
            "drops_buffered": 968, "source": "ws", "ts": 1783079852368 }
}

Note. Drops on special markets (player/team props) carry participant_name — the named outcome that moved (e.g. "Mohamed Salah") — with nvp: null (no two-way devig exists for multi-outcome ladders).

SSE /odds-drop API key OR token

Server-Sent Events stream of live drops as they happen. One TCP connection, push-based — no polling. Available on the Stream, Pro + SSE, and Scale plans. Trial keys and Pro (REST-only) cannot connect.

⚠ One SSE connection per account. Opening a second SSE connection with the same key evicts the first. EventSource auto-reconnect after a network blip works fine (the dying old socket gets reaped before the new one registers). If you genuinely need two or more independent connections — e.g. prod + staging, or fan-out across multiple servers — email [email protected] and we'll raise your account cap (same plan, no extra cost).

Two auth methods accepted (use either):

NameTypeDescription
keystringYour SaaS API key, in the x-portal-apikey / x-api-key header or as ?key=. Plan must include SSE (Stream / Pro + SSE / Scale).
tokenstringLegacy SSE token (separate from the API key) as ?token=. Kept for admin / pre-account customers.
min_dropnumberOptional. Drop-percent threshold for THIS subscriber. ?min_drop=7 = only alerts where price fell ≥7%. ?min_drop=1 = receive every drop, including small ones. Default if omitted: 5%. Floor: 1%. No upper cap.
# With your API key (paid plan)
curl -N "https://pinnodds.com/odds-drop?key=$API_KEY"

# Or with a legacy SSE token
curl -N "https://pinnodds.com/odds-drop?token=$SSE_TOKEN"
// npm i eventsource
import EventSource from "eventsource";

const es = new EventSource(`${BASE}/odds-drop?key=${API_KEY}`);

es.onmessage = (e) => {
  const payload = JSON.parse(e.data);
  if (payload.type === "connected") return;
  if (payload.type === "error") { console.error(payload.error, payload.message); return; }
  for (const drop of payload) {
    const ev = (drop.to_price / drop.nvp) - 1;
    if (ev > 0.02) console.log("+EV", drop.sport, drop.home, drop.from_price, "→", drop.to_price);
  }
};
es.onerror = (err) => console.error("sse closed", err);
# pip install sseclient-py requests
import json, requests
from sseclient import SSEClient

resp = requests.get(f"{BASE}/odds-drop?key={API_KEY}", stream=True)
for ev in SSEClient(resp).events():
    if not ev.data: continue
    payload = json.loads(ev.data)
    if isinstance(payload, dict) and payload.get("type") in ("connected", "error"):
        continue
    for drop in payload:
        ev_pct = (drop["to_price"] / drop["nvp"]) - 1
        if ev_pct > 0.02:
            print("+EV", drop["sport"], drop["home"], drop["from_price"], "→", drop["to_price"])
package main

import (
  "bufio"; "encoding/json"; "net/http"; "strings"; "fmt"
)

type Drop struct { Home, Sport string; FromPrice, ToPrice, NVP float64 }

req, _ := http.NewRequest("GET", BASE+"/odds-drop?key="+API_KEY, nil)
req.Header.Set("Accept", "text/event-stream")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024)
for scanner.Scan() {
  line := scanner.Text()
  if !strings.HasPrefix(line, "data: ") { continue }
  body := strings.TrimPrefix(line, "data: ")
  var drops []Drop
  if err := json.Unmarshal([]byte(body), &drops); err != nil { continue }
  for _, d := range drops {
    if ev := d.ToPrice/d.NVP - 1; ev > 0.02 {
      fmt.Println("+EV", d.Sport, d.Home, d.FromPrice, "→", d.ToPrice)
    }
  }
}
<?php
$ch = curl_init(BASE . "/odds-drop?key=$API_KEY");
curl_setopt_array($ch, [
  CURLOPT_HTTPHEADER     => ["Accept: text/event-stream"],
  CURLOPT_TIMEOUT        => 0,
  CURLOPT_WRITEFUNCTION  => function($ch, $chunk) {
    static $buf = "";
    $buf .= $chunk;
    while (($pos = strpos($buf, "\n\n")) !== false) {
      $line = substr($buf, 0, $pos); $buf = substr($buf, $pos + 2);
      if (strpos($line, "data: ") !== 0) continue;
      $payload = json_decode(substr($line, 6), true);
      if (!is_array($payload) || isset($payload["type"])) continue;
      foreach ($payload as $d) {
        $ev = ($d["to_price"] / $d["nvp"]) - 1;
        if ($ev > 0.02) echo "+EV {$d['sport']} {$d['home']} → {$d['to_price']}\n";
      }
    }
    return strlen($chunk);
  },
]);
curl_exec($ch);
# Stream framing (what arrives over the wire)
data: {"type": "connected", "id": "d648beb8-bde8-..."}

data: [{"home": "Sunshine Coast Phoenix", "away": "Cairns Dolphins",
        "league": "Australia - NBL1 Women", "from_price": 2.86,
        "to_price": 2.7, "outcome": "Home", "period": 4,
        "sect": "Moneyline", "id": 1629729400, "sport": "Basketball",
        "interval": 25, "alerted": 1777625196, "sport_id": 3,
        "nvp": 3.04, "starts": 1777624200, ...}]
Trial users: connecting with a trial key returns {"type":"error","error":"plan_lacks_sse"} as the only frame. Upgrade to Stream, Pro + SSE, or Scale to access SSE.

Connect throttling. Rapid reconnect loops get a single {"type":"error","error":"rate_limited"} frame with a Retry-After: 60 header — back off instead of hammering the endpoint; a healthy client reconnects with exponential backoff.

SSE /odds-drop-prematch API key OR token

Same shape and auth as /odds-drop, but emits drops on the prematch feed only. Useful when you want to trade upcoming-fixture line moves and not be flooded with in-play noise.

Optional query parameter: ?min_drop=N overrides the default 5% drop threshold for this subscriber (same as on /odds-drop). Composes with ?recheck=N. Floor is 1%.

One connection per account — see the same warning on /odds-drop. Live and prematch each count as one SSE slot, so you can run one of each in parallel under the default cap.

curl -N "https://pinnodds.com/odds-drop-prematch?key=$API_KEY"
// Identical to /odds-drop integration — only the URL changes.
import EventSource from "eventsource";
const es = new EventSource(`${BASE}/odds-drop-prematch?key=${API_KEY}`);
es.onmessage = (e) => {
  const p = JSON.parse(e.data);
  if (Array.isArray(p)) for (const d of p) console.log(d.sport, d.home, d.from_price, "→", d.to_price);
};
import json, requests
from sseclient import SSEClient

resp = requests.get(f"{BASE}/odds-drop-prematch?key={API_KEY}", stream=True)
for ev in SSEClient(resp).events():
    p = json.loads(ev.data) if ev.data else None
    if isinstance(p, list):
        for d in p: print(d["sport"], d["home"], d["from_price"], "→", d["to_price"])
req, _ := http.NewRequest("GET", BASE+"/odds-drop-prematch?key="+API_KEY, nil)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 1024*1024), 10*1024*1024)
for scanner.Scan() {
  line := scanner.Text()
  if !strings.HasPrefix(line, "data: ") { continue }
  // parse JSON array of drops, same shape as /odds-drop
}
<?php
// Same WRITEFUNCTION pattern as /odds-drop, just change the URL:
$ch = curl_init(BASE . "/odds-drop-prematch?key=$API_KEY");
// ... see the /odds-drop PHP example for the full streaming reader ...
Volume note. Prematch lines move slowly. Expect < 1 drop/sec on a typical day, vs. tens per second on the live stream during prime hours. A 90-second sample may surface zero drops — that's normal.

Optional: ?recheck=N stable-price filter

How to use it. Append &recheck=N to the URL where N is the number of seconds you want the server to wait before emitting each drop. The server holds the alert for N seconds, re-checks the current Pinnacle price for that exact line, and only sends it if the drop is still real (price hasn't bounced back). If you want a 30-second stability check:

# Standard prematch feed (instant, default — unchanged)
curl -N "https://pinnodds.com/odds-drop-prematch?key=$API_KEY"

# Wait 30 seconds, then re-verify before emitting
curl -N "https://pinnodds.com/odds-drop-prematch?key=$API_KEY&recheck=30"
// EventSource (Node.js / browser)
const es = new EventSource(`${BASE}/odds-drop-prematch?key=${API_KEY}&recheck=30`);
NameTypeDescription
recheck integer (seconds) How long to hold each drop before re-checking the current Pinnacle price. Surviving alerts pass the same 5% threshold the scanner used and gain a new rechecked_ms field with the actual wait time. Bounced prices are silently suppressed. Omit (or pass 0) for the default instant feed. No upper cap — the parameter only affects your own connection. Other customers connected without it are unaffected.

When to use this. If you place bets manually or your placement loop is >5s, prematch prices that bounce back within seconds create noise. Asking for recheck=30 trades 30s of latency for the confidence that the line was stable enough to be actionable.

Note. Available only on /odds-drop-prematch. The live stream /odds-drop is push-based and sub-second; a recheck window is semantically meaningless there and the parameter is silently ignored.

WebSocket Feed (Add-on)

WS /ws/feed API key $99/mo add-on

Forwards raw Pinnacle frames to your client — not drops, not derived metrics. Live MQTT messages and prematch REST responses delivered byte-identical to what we receive ourselves, wrapped in a thin envelope. Power-user feature for customers who want to rebuild Pinnacle's full state machine on their side.

This is NOT the same as /odds-drop SSE. SSE delivers our filtered drop alerts (≥5% by default). This WS delivers every Pinnacle frame including market open/close/settle signals. Output volume is much higher.

Pricing

  • $99/month add-on on top of any REST-bearing plan (Pro, Pro + SSE, Scale).
  • Stream and Trial plans are not eligible — they have no REST quota.
  • New / renewal checkout: $99 (30d), $297 (90d), $594 (180d) bundled with your base plan.
  • Mid-cycle upgrade: prorated to your renewal date — pay only for the days remaining. Quote via your dashboard.

Subscribe protocol

Client must send a subscribe message within 5s of connecting. You can subscribe by sport (firehose) or by specific event_ids (narrow filter), or both in one message:

{ "type": "subscribe",
  "streams":   ["live", "prematch"],
  "sport_ids": [1, 2],            // 1=Soccer, 2=Tennis — full firehose for the sport
  "event_ids": [1631005165]      // Pinnacle matchup IDs — narrow filter, just these events
}

Filter rule: a frame is delivered if its sport_id matches a sport-level sub or its event_id matches an event-level sub. So sport_ids:[2], event_ids:[12345] means "all of Tennis plus this one specific event in any sport". Either field can be omitted but at least one is required.

Discovery pattern: customers don't usually know matchup IDs upfront. Subscribe to a sport first → grep the snapshot for the events you care about (by team name, league, start time) → unsubscribe from the sport, subscribe to those specific event_ids. This narrows bandwidth to exactly the matches you're trading.

Per-stream cap: 200 event_ids per connection per stream. Subscribing past the cap returns {type:"error", code:"event_id_cap", ...} and the request is rejected wholesale. The hub auto-clears event_ids from your sub when Pinnacle removes the event (op:"del"), so you don't need to send unsubscribe yourself.

Server-sent messages

Immediately after each subscribe, one snapshot per subscription group:

// Sport-level subscribe → one snapshot per (stream, sport_id) with every
// event currently in our store for that sport.
{ "type": "snapshot", "stream": "live", "sport_id": 1,
  "events": [ /* full Pinnacle records, untouched */ ],
  "ts": 1779200000000 }

// Event-level subscribe → one snapshot per batch of event_ids, with just
// the records we currently have. Missing IDs appear in the stream once
// Pinnacle pushes them.
{ "type": "snapshot", "stream": "live",
  "event_ids": [1631005165],
  "events": [ /* full Pinnacle records, untouched (one per matched id) */ ],
  "ts": 1779200000000 }

Then continuous frames as Pinnacle pushes / we poll:

// Live MQTT — `topic`, `op`, `rec` exactly as we received from Pinnacle.
{ "type": "live", "sport_id": 1,
  "topic": "matchups/reg/sp/29/live/ld",
  "op": "upd",
  "rec": { /* Pinnacle record verbatim */ },
  "ts": 1779200000123 }

// Prematch REST poll responses — `data` is the response array verbatim.
{ "type": "prematch_matchups", "sport_id": 1,
  "arcadia_sid": 29,
  "data": [ /* /sports/29/matchups response verbatim */ ],
  "ts": 1779200005000 }

{ "type": "prematch_markets", "sport_id": 1,
  "arcadia_sid": 29,
  "matchup_id": 1631005165,
  "data": [ /* /matchups/.../markets/related/straight verbatim */ ],
  "ts": 1779200005230 }

// Heartbeat every 30s — reply with {"type":"pong"} or socket closes after ~75s.
{ "type": "ping", "ts": 1779200030000 }

Fidelity contract. events, rec, and data are never transformed. We only add envelope fields (type, stream, sport_id, topic, op, ts). Your code can reuse a Pinnacle parser unchanged.

Frame flow on firehose subs. live, prematch_matchups and prematch_markets frames all flow on sport_ids subscriptions exactly the same way they do on event_ids subscriptions. There's no event-id-only mode — the filter is identical, only the criterion (whole sport vs. specific ids) changes.

Enable compression (recommended). Turn on permessage-deflate on your client — live frames compress about . This matters for high-volume or geographically distant clients: without it, a burst of updates can exceed a single connection's throughput and queue up, so frames arrive with a delayed ts. Browsers negotiate it automatically; in Node's ws pass { perMessageDeflate: true }, Go's gorilla/websocket set EnableCompression: true, Python's websockets has it on by default. Same URL, same JSON — your client decompresses transparently.

Example clients

# npm i -g wscat — quick CLI client for ad-hoc inspection
wscat -c "wss://pinnodds.com/ws/feed?key=$API_KEY"

# then paste a subscribe frame at the prompt:
{"type":"subscribe","streams":["live"],"sport_ids":[1,2]}
// npm i ws
import WebSocket from "ws";

// perMessageDeflate: true — compress live frames ~5x (recommended)
const ws = new WebSocket(`wss://pinnodds.com/ws/feed?key=${API_KEY}`, { perMessageDeflate: true });

ws.on("open", () => {
  ws.send(JSON.stringify({
    type: "subscribe",
    streams: ["live", "prematch"],
    sport_ids: [1, 2],            // firehose Soccer + Tennis
    event_ids: [1631005165],       // plus this specific matchup
  }));
});

ws.on("message", (raw) => {
  const msg = JSON.parse(raw);
  switch (msg.type) {
    case "ping":     ws.send(JSON.stringify({type:"pong"})); break;
    case "snapshot": console.log(`snapshot ${msg.stream}: ${msg.events.length} events`); break;
    case "live":     console.log(`live event=${msg.rec.id} op=${msg.op}`); break;
    case "prematch_matchups": console.log(`matchups sport=${msg.sport_id} n=${msg.data.length}`); break;
    case "prematch_markets":  console.log(`markets event=${msg.matchup_id} n=${msg.data.length}`); break;
    case "error":    console.error(msg.code, msg.message); break;
  }
});
ws.on("close", (code) => console.error("closed", code));
# pip install websockets
import asyncio, json, os, websockets

async def main():
    url = f"wss://pinnodds.com/ws/feed?key={os.environ['API_KEY']}"
    async with websockets.connect(url) as ws:
        await ws.send(json.dumps({
            "type": "subscribe",
            "streams": ["live", "prematch"],
            "sport_ids": [1, 2],
            "event_ids": [1631005165],
        }))
        async for raw in ws:
            msg = json.loads(raw)
            t = msg["type"]
            if t == "ping":
                await ws.send(json.dumps({"type": "pong"}))
            elif t == "snapshot":
                print(f"snapshot {msg['stream']}: {len(msg['events'])} events")
            elif t == "live":
                print(f"live event={msg['rec']['id']} op={msg['op']}")
            elif t == "prematch_matchups":
                print(f"matchups sport={msg['sport_id']} n={len(msg['data'])}")
            elif t == "prematch_markets":
                print(f"markets event={msg['matchup_id']} n={len(msg['data'])}")
            elif t == "error":
                print("error", msg["code"], msg.get("message"))

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

import (
  "encoding/json"; "fmt"; "log"; "os"
  "github.com/gorilla/websocket"
)

func main() {
  url := "wss://pinnodds.com/ws/feed?key=" + os.Getenv("API_KEY")
  // EnableCompression: true — compress live frames ~5x (recommended)
  dialer := websocket.Dialer{EnableCompression: true}
  ws, _, err := dialer.Dial(url, nil)
  if err != nil { log.Fatal(err) }
  defer ws.Close()

  sub := map[string]any{
    "type": "subscribe",
    "streams": []string{"live", "prematch"},
    "sport_ids": []int{1, 2},
    "event_ids": []int64{1631005165},
  }
  if err := ws.WriteJSON(sub); err != nil { log.Fatal(err) }

  for {
    _, raw, err := ws.ReadMessage()
    if err != nil { log.Fatal(err) }
    var msg map[string]json.RawMessage
    if err := json.Unmarshal(raw, &msg); err != nil { continue }
    var t string; json.Unmarshal(msg["type"], &t)
    switch t {
    case "ping":     ws.WriteJSON(map[string]any{"type":"pong"})
    case "snapshot": fmt.Println("snapshot", string(msg["stream"]))
    case "live":     fmt.Println("live", string(msg["rec"])[:60])
    case "error":    fmt.Println("error", string(msg["code"]))
    }
  }
}
// composer require textalk/websocket
<?php
use WebSocket\Client;

$ws = new Client("wss://pinnodds.com/ws/feed?key=" . $_ENV["API_KEY"]);

$ws->send(json_encode([
  "type"      => "subscribe",
  "streams"   => ["live", "prematch"],
  "sport_ids" => [1, 2],
  "event_ids" => [1631005165],
]));

while (true) {
  $raw = $ws->receive();
  $msg = json_decode($raw, true);
  switch ($msg["type"]) {
    case "ping":     $ws->send(json_encode(["type"=>"pong"])); break;
    case "snapshot": echo "snapshot {$msg['stream']}: " . count($msg["events"]) . " events\n"; break;
    case "live":     echo "live event={$msg['rec']['id']} op={$msg['op']}\n"; break;
    case "error":    fwrite(STDERR, "error: {$msg['code']} {$msg['message']}\n"); break;
  }
}

Frame semantics — how to read what we forward

Pinnacle pushes incremental updates over MQTT. Once you start receiving frames, the op, topic, and inner rec/data shape determines how to merge into your local state.

Live frames — type:"live"
FieldTypeMeaning
opstringadd = new event or new markets appeared. upd = incremental change (typically just changed markets — merge by composite key, don't replace the whole list). del = event removed by Pinnacle (kicked off, settled, voided) — drop your local copy.
topicstringSource MQTT topic. Format: matchups/reg/sp/{arcadia_sid}/live/{ld|dz|both} for live, matchups/.../pre for prematch.
ld = "line drawing": main markets (moneyline, 1X2, primary spread/total).
dz = "draft zone": alternative spreads and totals.
both = combined push covering both.
matchups/spc/... instead of reg means a "special" matchup (props, futures).
rec.idintPinnacle matchup ID. Stable across the event's lifetime.
rec.marketsarrayMay be a SUBSET on op:"upd" — Pinnacle only sends what changed. Merge by composite key (m.key if present, otherwise type|period|side|points). A market with m.status !== "open" is a CLOSE signal: drop it from your local list, do not merge it back as if still active.
rec.periodsarrayPer-period status. periods[n].status === "closed" or "settled" implicitly closes ALL markets for that period — drop those locally too, even if they weren't listed in markets.
rec.parentIdint?If present, this is a CHILD matchup (alt periods, props derived from a parent event). Its full metadata (participants, league) lives on the parent record.
rec.startTime, statusstringEvent-level metadata. Tennis / Golf / Fights frequently see startTime updates without any market change (reschedules) — track these to surface to your users.
Prematch frames — prematch_matchups + prematch_markets
FieldTypeMeaning
typestringprematch_matchups = response from /sports/{sid}/matchups. The full per-sport matchup list, fired every 5 s. Includes both prematch and live entries — filter by data[i].isLive === false if you want strict prematch.
prematch_markets = response from /matchups/{id}/markets/related/straight. Fired only when Pinnacle bumped matchup.version, so roughly the events that had market activity in the last cycle.
dataarrayFor prematch_matchups: array of full matchup records (event metadata). For prematch_markets: array of market objects scoped to one matchup.
This is the authoritative current snapshot from Pinnacle — any market in your local store that's NOT in this list has been closed by Pinnacle. Drop it.
matchup_idint(prematch_markets only) The matchup this markets array belongs to.
modestring?Present only when MQTT is down and we fell back to live REST polling. Value: "rest_fallback". Frame shape is otherwise identical, so customers usually ignore this field.
Merge recipe (pseudocode)
// Maintain a Map<matchupId, MatchupState>. For each incoming frame:

if (msg.type === "snapshot") {
  for (const ev of msg.events) state.set(ev.id, ev);
}

if (msg.type === "live") {
  if (msg.op === "del")  { state.delete(msg.rec.id); return; }
  const existing = state.get(msg.rec.id) ?? {};
  const merged = { ...existing, ...msg.rec };
  if (msg.rec.markets) {
    const byKey = new Map((existing.markets ?? []).map(m => [marketKey(m), m]));
    for (const m of msg.rec.markets) {
      if (m.status && m.status !== "open") byKey.delete(marketKey(m));   // close signal
      else byKey.set(marketKey(m), m);
    }
    merged.markets = [...byKey.values()];
  }
  // Honour period-level closes — wipe all markets for closed periods.
  if (msg.rec.periods) {
    const closed = new Set(msg.rec.periods.filter(p => p.status !== "open").map(p => p.period));
    if (closed.size) merged.markets = merged.markets.filter(m => !closed.has(m.period ?? 0));
  }
  state.set(msg.rec.id, merged);
}

if (msg.type === "prematch_markets") {
  // AUTHORITATIVE snapshot for one matchup — replace, don't merge.
  const existing = state.get(msg.matchup_id) ?? {};
  state.set(msg.matchup_id, { ...existing, markets: msg.data });
}

function marketKey(m) {
  return m.key ?? `${m.type}|${m.period ?? 0}|${m.side ?? ""}|${m.prices?.[0]?.points ?? ""}`;
}

Reconnect strategy. Re-subscribe on reconnect. Server-side state is not preserved — the snapshot at the start of every connection is your sync point. Exponential backoff (1s → 2s → 4s → 30s cap) recommended. One connection per account on this endpoint (separate slot from SSE — you can run both; opening a second WS connection evicts the first with close code 1001).

Limits & error frames

Error / behaviorTriggerWhat to do
sport_id_capMore than 32 sport_ids in one subscribeSplit into batches (there are ~14 real sports — you'll rarely hit this)
event_id_capMore than 200 tracked event_ids per streamUnsubscribe finished events; slots auto-free on op:"del"
subscribe_rate_limitedMore than 30 subscribe messages per 10 sBatch sports/events into fewer subscribe messages
backpressureSubscribe while your connection is backlogged (>32 MB unsent)The sport was NOT subscribed — drain, then resubscribe
slow_consumer (close)Unsent buffer exceeds 128 MB — you're reading too slowlyConnection is closed; reconnect and read faster / subscribe narrower
rate_limited (HTTP 429)Connection-open flood on the endpointBack off per Retry-After; don't reconnect in a tight loop
plan_lacks_ws (HTTP 403)WS add-on not active on the accountPurchase the add-on in the dashboard
invalid_json / invalid_message / unknown_message_typeMalformed client messageFix the message shape — see subscribe example above

You can also send {"type":"unsubscribe", "streams":[…], "sport_ids":[…]}; every subscribe/unsubscribe is acknowledged with a {"type":"subscribed"|"unsubscribed", "stream", "sport_id"} frame — treat the ack (plus the snapshot) as confirmation, not the send itself.

SportsMeld — team & event matching Free 30-day trial

SportsMeld is our separate entity-resolution service: it decides whether two team/player names refer to the same real-world entity ("Man Utd" = "Manchester United" ≠ "Man City"), and clusters the same fixture across bookmaker feeds. Use it to join this API's Pinnacle names to any other book you consume — no hand-maintained alias tables. 8 sports, high-precision hybrid ML pipeline, cached lookups in sub-millisecond time.

It runs on its own base URL with its own key (not your pinnodds key): Authorization: Bearer sportsmeld_... against https://sportsmeld.arbitragex.pro. Full reference: sportsmeld.arbitragex.pro/docs.

Name matching — one reference vs candidate names:

POST https://sportsmeld.arbitragex.pro/api/match
{
  "reference": "Manchester United",
  "names": ["Man Utd", "Man City", "MUFC"],
  "sport": "soccer"
}
// → details[]: { name, match: true|false, confidence, ambiguous, method }

Fixture matching — cluster the same game across 2+ books:

POST https://sportsmeld.arbitragex.pro/api/match-events
{
  "sport": "soccer",
  "sources": [
    { "name": "pinnodds", "events": [{ "id": "1632373638", "left": "Manchester United", "right": "Liverpool FC", "timestamp": 1720008000 }] },
    { "name": "bet365",   "events": [{ "id": "b77", "left": "Man Utd", "right": "Liverpool", "timestamp": 1720008000 }] }
  ]
}
// → matched[] clusters (ids echoed back), partial[], unmatched{}, stats
// timestamps optional; when both sides have one, matches only within ±12h

Launch offer: every pinnodds user gets a free 30-day trial on the scout tier — 15 req/s, 25 names per /api/match call, 5 sources & 500 events/source on /api/match-events. Free during launch; paid plans later. Claim it yourself in your panel (Subscription card → "Get your free key") — the key is shown once and emailed to your account address, and you can rotate it from the panel any time (rotation never changes your expiry). Questions? Telegram @ArbitrageXpro.

Status

GET /health API key

Operational status of both feeds. Useful for dashboards and alerts.

{
  "status": "healthy",
  "source": "ws",            // ws | rest | none
  "events_in_store": 1554,
  "version": 297592,
  "ws_messages": 595184,
  "rest_polls": 0,
  "uptime_s": 11938,
  "connected_clients": 21,   // live SSE subscribers
  "drops_pipeline": "on",
  "drops_mode": "event-driven",
  "drops_pg": "off",
  "prematch": {
    "enabled": true,
    "events_in_store": 2552,
    "version": 51375,
    "rest_polls": 132,
    "source": "rest",
    "connected_clients": 14,
    "drops": "on"
  }
}
GET /ping Public

Public liveness probe. Returns ok in plain text. Useful for load balancers and uptime monitors that can't send an auth header.

curl https://pinnodds.com/ping
# ok

Sport IDs

Sport IDs are stable integers — once you've coded against an ID it stays valid. Mapped to Pinnacle's internal sport IDs (e.g. dropping-odds 1 = Pinnacle 29 = Soccer).

IDSportPinnacle internal ID
1Soccer29
2Tennis33
3Basketball4
4Hockey19
5Football15
6Baseball3
7Rugby (Union + League)27, 26
8MMA (UFC, Bellator, ONE, etc.)22
9Boxing6
10Other (Volleyball, Handball)34, 18
11Esports (Dota 2, CS2, LoL, etc.)12
12Golf (PGA, DP World Tour, LIV — head-to-heads)17
13Cricket (T20, ODI, Test — added July 2026)8

Golf note: Pinnacle exposes Golf as moneyline-only (head-to-head matchups, outright winners). Spreads, totals, and team totals are not provided for Golf — your client should expect spreads, totals, and team_total to be empty objects on Golf events. Drops on Golf will only fire from the Moneyline section.

Market types

Returned inside each event's periods[<num_N>] object.

FieldShapeDescription
money_line{ home, away, draw }3-way (or 2-way for no-draw sports) match result. Decimal odds.
spreads{ "<hdp>": { hdp, home, away, max } }Handicap markets keyed by the point spread.
totals{ "<points>": { points, over, under, max } }Over/under markets keyed by the total.
team_total{ home: {...}, away: {...} }Primary per-team total line per side. Backward-compatible — only ONE line per side. For all alt lines, use team_totals below.
team_totals{ home: { "<points>": {points, over, under, max} }, away: {...} }ALL alternate team-total lines, keyed by points per side. Use this when Pinnacle exposes multiple lines (e.g. home Over 0.5 / 1.5 / 2.5).
metaobjectPer-period meta: current scores, max stakes.

Event object

{
  "event_id": 1628594960,
  "sport_id": 1,
  "league_id": 2037,
  "league_name": "China - Super League",
  "starts": "2026-04-17T12:00:00Z",
  "last": 1776430365,
  "home": "Yunnan Yukun",
  "away": "Tianjin Jinmen Tiger",
  "event_type": "live",
  "live_status_id": 1,
  "parent_id": 1628594613,
  "is_have_odds": true,
  "is_have_periods": true,
  "periods": {
    "num_0": {
      "number": 0,
      "description": "Game",
      "money_line": { "home": 2.450, "draw": 3.300, "away": 2.900 },
      "spreads": { "-0.5": { "hdp": -0.5, "home": 2.060, "away": 1.820, "max": 500 } },
      "totals":  { "2.5":  { "points": 2.5, "over": 1.900, "under": 1.980, "max": 500 } },
      "team_total": { "home": { "points": 1.5, "over": 1.860, "under": 2.000 } },
      "team_totals": {
        "home": {
          "0.5": { "points": 0.5, "over": 1.200, "under": 4.500, "max": 100 },
          "1.5": { "points": 1.5, "over": 1.860, "under": 2.000, "max": 100 },
          "2.5": { "points": 2.5, "over": 3.200, "under": 1.400, "max": 100 }
        },
        "away": { /* same shape */ }
      },
      "meta": { "number": 0, "max_total": 500, "home_score": 0, "away_score": 1 }
    },
    "num_1": { /* first half */ }
  },
  "state": {
    "match": { "state": 1, "minutes": 23 },
    "home":  { "score": 1, "redCards": 0 },
    "away":  { "score": 0, "redCards": 0 },
    "home_stats": [{ "period": 0 }, { "period": 1 }],
    "away_stats": [{ "period": 0 }, { "period": 1 }]
  }
}

Special-market rows (returned only with include_specials=1, or when fetched by their own event_id) additionally carry special, special_category, special_units and special_markets, with an empty periods object — see Player props & specials for the shape. BTTS / Draw No Bet rows keep their historical shape (special + special_outcomes with prices in the money_line slots) and appear regardless of the flag.

Live state object

Live events include a state object with in-game state passed through from Pinnacle. Field names are sport-specific and are NOT renamed by us. state is null on prematch events.

Three top-level fields make up the object:

SportMatch-level fieldsPer-participant fields
Soccer (1) state (period state code, 1 = 1H, 2 = 2H, etc.), minutes (game clock minute) score, redCards
Basketball (3) quarter (1-4 + OT), timeRemainingInQtr (string "MM:SS") score
Tennis (2) set (current set number) setsWon, gamesBySet (array), points (string: "0"/"15"/"30"/"40"/"Adv"), serving (boolean)
Other sports Varies. Common: period / state Always score. Sport-specific extras may appear.

Note: Per-participant live state currently covers score and redCards (plus the match-level clock/period). Yellow cards and corners are not populated on the main event. Fields appear only when there is a non-default value to publish, so treat every field as optional and code defensively.

Where it lives: the state field is returned by GET /kit/v1/markets?event_type=live only. Prematch endpoints (/kit/v1/prematch/*) do not return state — none of these fields exist before kickoff. The /odds-drop SSE stream does NOT include state; use the REST endpoint when you need live game state.

Affiliate program

Every account gets a unique referral link. Anyone who signs up via your link and becomes a paying customer earns you 30% of every payment they make, for as long as their subscription stays active — first payment, renewals, upgrades, all of it.

TopicDetail
Your linkShown in your dashboard. Format: https://pinnodds.com/?ref=<8-char-code>. Copy + share anywhere.
Commission30% of every USD payment your referrals make. Compounds across renewals and upgrades — not a one-time bounty.
AttributionA signup is attributed if the visitor lands on pinnodds.com with your ?ref= parameter and then signs up + verifies email in the same browser session. We don't use cross-device cookies — straight session-based attribution.
TrackingYour panel shows a live list of every signup attributed to you, whether they're on a paid plan, and how much you've earned (unpaid + paid out).
PayoutsProcessed manually. Once your unpaid balance reaches $50+ we'll reach out to arrange transfer (crypto, bank transfer, or PayPal — whichever you prefer). Faster payouts are available on request — email [email protected].
Self-referralBlocked. You can't sign up via your own link.
RefundsIf a referred customer's payment is refunded by us (rare), the corresponding commission is voided. We don't claw back already-paid commissions for routine churn — only for actual refunds.

Privacy: in your panel, referred users' emails are masked (e.g. j***@gmail.com) so you can recognize friends you invited without exposing full addresses if you screenshot the dashboard. Admins see unmasked emails in our internal tooling.

Need something we don't have? Historical archives, a binary WebSocket stream, custom rate above 30 req/sec, or a market we don't surface yet — email [email protected] and tell us what you need.