FEED LIVE SESSION 00:00:00 UTC DOC API REFERENCE
TRANSPORT REST / SSE / WS BASE pinnodds.com V1
Contents

Pinnacle API Documentation

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

Introduction

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

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

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. During your first 3 days every new key can connect (the signup demo includes the WebSocket feed). After that, trial keys and Pro (REST-only) cannot connect.

⚠ 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 issue a second account on the same plan at no extra cost. The cap itself stays at one connection per key — it's how we detect keys being shared or resold — so extra connections come as extra keys.

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 (every detected drop by default — the engine floor is ~0.99%; narrow it with ?min_drop=). This WS delivers every Pinnacle frame including market open/close/settle signals. Output volume is much higher.

Pricing

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

Subscribe protocol

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

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

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

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

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

Server-sent messages

Immediately after each subscribe, one snapshot per subscription group:

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

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

Large snapshots arrive in multiple frames. A big baseline (e.g. prematch soccer, thousands of events) is split across several snapshot frames so no single message overruns your client's max-message limit. The split frames carry "seq" (0, 1, 2, …) and "final" (true only on the last); a small baseline that fits in one frame is sent as a single snapshot with no seq/final. Merge each frame's events into your book by event id (the same way you apply the deltas that follow) and you need no special handling. If instead you rebuild your book from the snapshot wholesale, wait for final: true before treating the baseline as complete.

// A split (chunked) snapshot — merge events from every frame until final:true.
{ "type": "snapshot", "stream": "prematch", "sport_id": 1,
  "seq": 0, "final": false, "events": [ /* first chunk */ ], "ts": 1779200000000 }
{ "type": "snapshot", "stream": "prematch", "sport_id": 1,
  "seq": 1, "final": true,  "events": [ /* last chunk  */ ], "ts": 1779200000000 }

Then continuous frames as Pinnacle pushes / we poll:

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

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

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

// Membership delta — what JOINED or LEFT the prematch set this cycle.
// MQTT never announces this: /pre carries prices for matchups you already
// track, and is blind to the set itself. Measured over 10 min across all 13
// sports: 34 membership changes, 0 announced by MQTT. This frame is the only
// timely signal — including when a matchup leaves prematch for warmup/live.
{ "type": "prematch_membership", "sport_id": 1,
  "arcadia_sid": 29,
  "added": [ 1634753718, 1634753879 ],
  "removed": [ 1634713508 ],
  "ts": 1779200005010 }

// Heartbeat every 30s — reply with {"type":"pong"} or socket closes after ~75s.
// buffered_max_bytes = the highest server-side send backlog for YOUR socket
// since the previous ping, then reset. Steady-state 0 means you are draining
// as fast as we send; a number that climbs across pings means your read loop
// is falling behind, and at 128 MB you are evicted with
// 1011 "deregistered: slow_consumer". Track it to see headroom, not just breaches.
{ "type": "ping", "ts": 1779200030000, "buffered_max_bytes": 0 }

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

prematch_membership — use this to track the set.

MQTT /pre is a price channel. It carries updates for matchups already in your set and is structurally blind to the set’s boundaries — it never says “this fixture now exists” or “this one is gone”. Measured on production over 10 minutes across all 13 sports: 34 membership changes, 0 announced by MQTT.

A client relying on prematch_ws alone therefore misses new fixtures entirely and holds removed ones as live odds forever. It is also why push goes quiet for a matchup that has moved to warmup/live: that transition is a membership change, and MQTT does not report it.

prematch_membership answers the same question as the full board at roughly 1/50,000th the bytes, every cycle (~25 s) instead of every 10 minutes.

Recommended consumption: take the snapshot chunks as your baseline → apply prematch_ws for prices → apply prematch_membership (fetch details for added, drop removed) → ignore prematch_matchups unless you specifically want the periodic full resync. Complete, and never transfers a ~19 MB board.

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

Live channels: ld vs dz. Pinnacle runs two update channels for every live match, and both arrive on your one WebSocket. Which channel a live frame came from is the last segment of its topic — e.g. matchups/reg/sp/29/live/ld:
  • ldlive_delay, the primary channel (~85% of updates). This is the current price — the same line pinnacle.com shows. Treat it as your source of truth.
  • dzdanger_zone (~15%), fired only in volatile moments (goal threat, break point, red card) when Pinnacle is repricing hard and often about to suspend. Earliest signal of a fast move, but jumpy.
  • pre — a prematch matchup update carried on the live stream.
Parse it in one line: const channel = msg.topic.split("/").pop(); ("ld" | "dz" | "pre"). Key your price/book on ld; use dz as a volatility/suspension signal, not a second price. Merging both channels into one book makes the price appear to “spike and revert” — that’s the two channels interleaving, not a line move and not out-of-order delivery (each single market’s stream is strictly ordered; verify with rec.version per event_id+channel). This split is live only — prematch has no danger_zone.
One fixture can have several matchup ids — key by event_id, not by teams. Pinnacle issues a live match as a parent matchup plus re-issued/related child matchups, all sharing the same rec.parentId and the same team names. The same market (identical market key, e.g. s;0;s;-1.5) can therefore appear under two different event_ids at the same time — one live, one stale/closing — with different prices.
  • Key your book by rec.id (the matchup/event id) + market key — never by team names or parentId. If you merge markets across matchup ids, the two ids’ prices interleave and the market appears to “spike and revert” even though each individual matchup’s stream is perfectly stable and strictly ordered. That’s an assembly artifact, not a line move — pinnacle.com and our REST /kit endpoints don’t show it because they collapse to one canonical matchup per fixture.
  • Dedupe on the market version. We forward every upstream frame verbatim, and Pinnacle re-sends unchanged markets frequently (same markets[i].version, same price — often >50% of updates). Skip a market whose version you’ve already applied to drop that noise.
If you need the pre-collapsed “one row per fixture” view instead of the raw multi-matchup stream, use the REST snapshot endpoints (/kit/v1/markets) which already do this.
Two version fields — use markets[i].version for change-detection, NOT rec.version. A live record carries a top-level rec.version AND a per-market markets[i].version, and they behave differently:
  • rec.version is a coarse record/board stamp. Pinnacle freezes it for the whole match once a fixture is in-play — it does not increment when a price changes. Keying dedup or ordering on it will make live prices look like they “change under a frozen version” and cause you to drop real price updates.
  • markets[i].version is the real per-market counter. It increments on every genuine price change and is strictly monotonic per (event_id, market key). This is the field to dedupe and order on.
So if you see the moneyline move while rec.version stays constant, that is not a re-published/stale frame — check markets[i].version, which will have advanced. A spike-then-revert whose intermediate frame has a higher market version is a genuine transient Pinnacle reprice (relayed verbatim), not a replay.
One connection per account. The limit is keyed on the account — not on the API key, and not on the source IP. Two processes on the same account collide even from different machines. (SSE is a separate slot; you can run SSE and WebSocket at the same time.)

Opening a second connection does not fail the new connection — it evicts the oldest one with close code 1001, reason "evicted by newer connection".

This is the most common cause of “the socket goes silent for long stretches while REST stays healthy”. The trigger is usually not a deliberate second consumer, but a monitoring or logging process sharing the key, a deploy where the new instance connects before the old one closes, or a retry loop that reconnects without closing the previous socket. Two consumers on one account kick each other continuously, and whichever socket lost the race receives nothing.

Supported pattern: open one connection and fan out to your components inside your own process — this also guarantees your components share an identical view of the book. If you need transport-level separation, contact us about a second credential.

Close codes: 1001 "evicted by newer connection" · 1001 "stale" (no pong for 75 s) · 1001 "server shutdown" · 1011 "deregistered: <cause>" · 1006 with no reason (abnormal close — historically an oversized frame exceeding your client's max-message limit).

On 1011 "deregistered: …". This means a server-side fault dropped you out of the fan-out — a failed write, or backpressure eviction. Causes seen today are deregistered: send_err, deregistered: send_threw and deregistered: slow_consumer. Match on the deregistered: prefix, not on the full string, so any cause added later is still caught. It is deliberately outside the 1001 lifecycle family so you can distinguish "we closed you on purpose" from "something broke". Treat it as retryable: reconnect immediately, no cooldown needed.
Compression is a tradeoff — choose deliberately. permessage-deflate is negotiated per connection, so your client decides.

What it costs. Compression uses context takeover, which makes it a strictly serial per-connection pipeline: every message waits for the previous message's zlib job. Pinnacle pushes in mass-update cycles, and during those bursts the compression queue backs up — frames already carrying their ts get compressed and flushed together, so you receive a burst of frames whose timestamps span more than a second. Measured back-to-back on the same subscription (soccer + tennis live, ~62 frames/s, from a client with ample bandwidth), local_recv − ts:
deflate ondeflate off
p5043 ms−46 ms
p95748 ms136 ms
p991241 ms187 ms
max1527 ms228 ms
frames > 1 s2.14 %0.00 %
What it buys. Live frames compress about . A single TCP connection over a long-haul path tops out around 0.4 MB/s (measured Dublin → Greece at ~70 ms RTT), so if your link is the bottleneck, an update burst overflows the pipe and queues in the kernel — producing multi-second lag that compression genuinely eliminates.

How to choose. Stable connection, bandwidth not your limit → turn it off, the tail disappears. Far from the server, thin link, or many sports at high volume → leave it on. Don't guess — measure local_recv − ts percentiles both ways on your own path.

Defaults differ by language: Python's websockets is on by default (compression=None to disable); Node's ws ({ perMessageDeflate: true }) and Go's gorilla/websocket (EnableCompression: true) are off by default. Same URL, same JSON either way — decompression is transparent.
Maximum message size — set your client limit to 8 MB. It's a ceiling, not an allocation, so it costs nothing and absorbs future sport or specials expansion. 2 MB is the smallest we'd advise. Current measured maxima (uncompressed, 5-minute capture, soccer + tennis): prematch_matchups 512 KB and snapshot 512 KB (both chunked, bounded by construction); prematch_markets 32 KB and live 13 KB (not chunked, scoped to a single event).

Before 2026-07-22 19:48 UTC these frames were not chunked — the soccer snapshot reached ~19 MB and the bulk prematch_matchups frame ~10 MB, so any client whose limit sat below that had the socket dropped with 1006 and no close frame.

Example clients

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

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

// Compression adds a ~1.2s p99 tail during mass-update bursts — leave it OFF
// unless bandwidth is your bottleneck. maxPayload 8 MB clears the 512 KB chunk ceiling.
const ws = new WebSocket(`wss://pinnodds.com/ws/feed?key=${API_KEY}`, { perMessageDeflate: false, maxPayload: 8 * 1024 * 1024 });

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

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

async def main():
    url = f"wss://pinnodds.com/ws/feed?key={os.environ['API_KEY']}"
    # websockets enables compression BY DEFAULT — it serializes sends and adds a
    # ~1.2s p99 tail during mass-update bursts. Pass compression=None unless your
    # link (not our server) is the bottleneck. max_size clears the 512 KB chunk ceiling.
    async with websockets.connect(url, compression=None, max_size=8 * 1024 * 1024) as ws:
        await ws.send(json.dumps({
            "type": "subscribe",
            "streams": ["live", "prematch"],
            "sport_ids": [1, 2],
            "event_ids": [1631005165],
        }))
        async for raw in ws:
            msg = json.loads(raw)
            t = msg["type"]
            if t == "ping":
                await ws.send(json.dumps({"type": "pong"}))
            elif t == "snapshot":
                print(f"snapshot {msg['stream']}: {len(msg['events'])} events")
            elif t == "live":
                print(f"live event={msg['rec']['id']} op={msg['op']}")
            elif t == "prematch_matchups":
                print(f"matchups sport={msg['sport_id']} n={len(msg['data'])}")
            elif t == "prematch_markets":
                print(f"markets event={msg['matchup_id']} n={len(msg['data'])}")
            elif t == "error":
                print("error", msg["code"], msg.get("message"))

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

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

func main() {
  url := "wss://pinnodds.com/ws/feed?key=" + os.Getenv("API_KEY")
  // EnableCompression stays false: it serializes sends and adds a ~1.2s p99 tail.
  // Turn it on only if your link (not our server) is the bottleneck.
  dialer := websocket.Dialer{EnableCompression: false}
  ws, _, err := dialer.Dial(url, nil)
  if err != nil { log.Fatal(err) }
  defer ws.Close()

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

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

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

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

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

Frame semantics — how to read what we forward

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

Live frames — type:"live"
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. For a large sport (e.g. soccer) this frame is split into several chunks tagged seq/final (same as the subscribe snapshot); when chunked, the authoritative set is the union across the whole chunk group — accumulate until final: true before dropping anything.
matchup_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, custom rate above 30 req/sec, or a market we don't surface yet — email [email protected] and tell us what you need.