API Documentation

Sharp Pinnacle prices — in-play and pre-game — paired with a live steam-move detector, served over a clean REST API (drop-in compatible with the formats you already use) plus optional push streams over Server-Sent Events. The live feed rides Pinnacle's MQTT WebSocket, so a price change reaches you in roughly 200 ms.

Introduction

Everything hangs off one host: https://pinnapi.com. Read every path below as relative to it, and expect JSON back on every call.

Auth is a single API key — no username, no password; the key is your identity. Mint one free in a click from the landing page.

Already wired to another Pinnacle feed? The /kit/v1/* responses follow the same shape as the common third-party formats — point your existing client at this base URL with your key and it should run as-is.
Building with an AI coding agent? Hand Cursor / Claude / ChatGPT the /llms-full.txt dump — the whole reference as one plain-text file sized for model context. Crawlers that probe the root also pick up the slimmer /llms.txt index.

Authentication

Send your key any of three ways — we check them in this 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://pinnapi.com/kit/v1/markets?sport_id=2&event_type=live"

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

Rate limits

Quotas are tracked per key over fixed time windows. Cross one and the next call comes back 429 with a Retry-After header telling you when to try again.

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
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)
429rate_limitedKey exceeded its rate limit
503prematch_disabledPrematch endpoint hit while the prematch ingester is off

Live odds

A complete snapshot of everything live right now. The store is fed by Pinnacle's MQTT WebSocket, so each request hands back current prices — usually under 200 ms behind the source.

GET /kit/v1/markets API key

Hands back every event for a sport, each carrying its full market set — moneyline, spreads, totals, team totals — across all periods (full match, 1st half, 2nd half, and so on). Defaults to live; add event_type=prematch to read the pre-game feed instead.

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).
is_have_odds0 | 1Legacy compat — accepted and ignored. We always return events with odds.
Worth knowing. Live and prematch responses share one envelope and one per-event shape — the only thing that changes is the event_type on each event. Code that walks events[*].periods.num_0 handles both feeds with zero branching.
curl -H "x-portal-apikey: $KEY" \
  "https://pinnapi.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

Pull one event by its ID. The payload matches the per-event shape from /kit/v1/markets exactly.

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

Prematch odds

Pre-game lines for fixtures that haven't kicked off yet. A dedicated REST poller keeps them fresh (every few seconds), held in its own store apart from the live feed — so hitting prematch never steals cycles from the in-play snapshot.

Which one do I call? Reach for /kit/v1/markets when you want in-play prices and /kit/v1/prematch/fixtures for upcoming-fixture lines. Both speak the same sport IDs and the same event object, so a single field is all your code needs to branch on.
GET /kit/v1/prematch/fixtures API key

Lists all pre-game fixtures for a sport with their complete markets-and-periods tree. Identical in shape to /kit/v1/markets — the lone difference is the event_type field.

Query parameters

NameTypeDescription
sport_id requiredintegerDropping-odds sport id. See Sport IDs.
sinceintegerReturn only events that changed since this last value (incremental polling).
curl -H "x-portal-apikey: $KEY" \
  "https://pinnapi.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

The complete markets payload for one prematch event — every period, plus money line, spread, total and team total. Call it after you've picked a fixture out of /prematch/fixtures.

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

A stripped-down view of one prematch event — just the active prices, with the period tree omitted. Much smaller than /markets, which makes it a good fit for tickers and quick reads.

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

Dropping odds

Catch price moves ("drops") as they happen on both the live and prematch feeds. A drop is emitted when an outcome's decimal odds fall by at least your threshold inside the configured window, and every drop ships with its from and to price alongside the full event context.

GET /api/drops API key

A queryable, pull-style buffer of recent drops. Filter it however you like and read back the whole result set — handy for batch jobs, dashboards, or any polling client.

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://pinnapi.com/api/drops?mode=live&sport_id=1&min_drop_pct=7&max_age_sec=600"

# Prematch drops, all sports, last hour
curl -H "x-portal-apikey: $KEY" \
  "https://pinnapi.com/api/drops?mode=prematch&min_drop_pct=2&max_age_sec=3600"
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,
      "drop_pct": 5.06,
      "age_s": 12,
      "is_live": false
    }
  ],
  "meta": { "mode": "prematch", "events_in_store": 2552, "tracked_outcomes": 122035 }
}
SSE /odds-drop API key OR token

A Server-Sent Events feed that pushes live drops the instant they fire — one long-lived TCP connection, no polling loop. Open to the Stream, Pro + SSE, and Scale plans. Trial keys and REST-only Pro keys can't 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://pinnapi.com/odds-drop?key=$API_KEY"

# Or with a legacy SSE token
curl -N "https://pinnapi.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.
SSE /odds-drop-prematch API key OR token

Identical shape and auth to /odds-drop, except it only emits drops off the prematch feed. Handy when you're trading upcoming-fixture line moves and don't want in-play chatter drowning them out.

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://pinnapi.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://pinnapi.com/odds-drop-prematch?key=$API_KEY"

# Wait 30 seconds, then re-verify before emitting
curl -N "https://pinnapi.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

Relays raw Pinnacle frames straight to your client — no drops, no derived metrics. You receive the live MQTT messages and prematch REST payloads byte-for-byte as they reach us, wrapped in a thin envelope. It's a power-user surface for anyone rebuilding Pinnacle's full state machine on their own side.

Don't confuse this with /odds-drop SSE. SSE sends only our filtered drop alerts (≥5% by default); this WebSocket sends every Pinnacle frame — including market open/close/settle signals — so expect a far heavier stream.

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

Your client has 5 seconds after connecting to send a subscribe message. Subscribe by sport (the full firehose), by specific event_ids (a tight filter), or mix 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.

Example clients

# npm i -g wscat — quick CLI client for ad-hoc inspection
wscat -c "wss://pinnapi.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";

const ws = new WebSocket(`wss://pinnapi.com/ws/feed?key=${API_KEY}`);

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://pinnapi.com/ws/feed?key={os.environ['API_KEY']}"
    async with websockets.connect(url) as ws:
        await ws.send(json.dumps({
            "type": "subscribe",
            "streams": ["live", "prematch"],
            "sport_ids": [1, 2],
            "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://pinnapi.com/ws/feed?key=" + os.Getenv("API_KEY")
  ws, _, err := websocket.DefaultDialer.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://pinnapi.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).

Status

GET /health API key

A live health read on both feeds — handy to wire into dashboards or alerting.

{
  "status": "healthy",
  "source": "ws",            // ws | rest | none
  "events_in_store": 1554,
  "version": 297592,
  "ws_messages": 595184,
  "rest_polls": 0,
  "uptime_s": 11938,
  "prematch": {
    "enabled": true,
    "events_in_store": 2552,
    "rest_polls": 132,
    "source": "rest",
    "drops": "on"
  }
}
GET /ping Public

An unauthenticated liveness check that simply replies ok in plain text — built for load balancers and uptime monitors that can't attach an auth header.

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

Sport IDs

These IDs are stable integers — code against one and it stays put. Each maps to Pinnacle's own internal sport ID (for example, our 1 = Pinnacle's 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

About Golf: Pinnacle runs Golf as moneyline-only — head-to-head matchups and outright winners. There are no spreads, totals, or team totals, so treat spreads, totals, and team_total as empty objects on Golf events, and expect Golf drops to come only from the Moneyline section.

Market types

Each of these lives inside an 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, "yellowCards": 1, "corners": 3 },
    "away":  { "score": 0, "redCards": 0, "yellowCards": 2, "corners": 5 },
    "home_stats": [{ "period": 0, "score": 1, "redCards": 0 }, { "period": 1 }],
    "away_stats": [{ "period": 0, "score": 0, "redCards": 0 }, { "period": 1 }]
  }
}

Live state object

Live events carry a state object holding in-game data, forwarded from Pinnacle exactly as it lands on their MQTT feed. The field names are sport-specific and we leave them untouched. On prematch events state is null.

The object breaks down into three top-level fields:

SportMatch-level fieldsPer-participant fields
Soccer (1) state (period state code, 1 = 1H, 2 = 2H, etc.), minutes (game clock minute) score, redCards, yellowCards, corners
Basketball (3) quarter (1-4 + OT), timeRemainingInQtr (string "MM:SS") score, scoreByQuarter (array of N integers)
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.

Heads-up: a field only shows up once Pinnacle has a real value for it — corners, for instance, stays absent in soccer until the first corner is taken. Assume every field is optional and code defensively.

Where you'll find it: only GET /kit/v1/markets?event_type=live returns state. The prematch endpoints (/kit/v1/prematch/*) never include it — none of these fields exist before kickoff — and the /odds-drop SSE stream omits it too. Hit the REST endpoint whenever you need live game state.

Affiliate program

Every account comes with its own referral link. Refer someone who signs up through it and turns into a paying customer, and you earn 30% of every payment they make for the life of their subscription — first charge, renewals, upgrades, the lot.

TopicDetail
Your linkShown in your dashboard. Format: https://pinnapi.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 pinnapi.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.

Missing something you need? Historical archives, a binary WebSocket stream, a custom rate beyond 30 req/sec, or a market we don't expose yet — drop a line to [email protected] and tell us what you're after.