On this page
Pinnacle's own REST API stopped accepting new clients on 23 July 2025, and the old pinnacleapi Python wrappers on PyPI point at endpoints that need a funded account and bespoke approval. This page shows the working path in 2026: the pinnodds feed, which carries Pinnacle's live and prematch markets over REST, SSE and WebSocket, and its official Python client. If you are still deciding between services, the alternatives comparison covers the field. Node developers want the Node.js quickstart instead.
Install
The SDK is a single package on PyPI with one dependency, requests. It supports Python 3.8 and newer. Your API key is issued at signup without a card or a Pinnacle account; keep it in an environment variable, never in source.
python -m venv .venv && source .venv/bin/activate
pip install pinnodds # Python 3.8+, one dependency: requests
export PINNODDS_KEY=your_key_here A trial key works for every REST call on this page. The SSE stream in the drops section needs a plan with streaming enabled (Stream, Pro + SSE, Scale, or the 3-day full demo).
First request: the live board
One call returns every live event for a sport with all its markets and periods. SPORTS maps names to the integer ids the API uses (1 soccer, 2 tennis, 3 basketball, and so on). The response is a dict with sport_id, sport_name, a last cursor and an events list.
import os
from pinnodds import Client, SPORTS
api = Client(os.environ["PINNODDS_KEY"])
board = api.markets(sport_id=SPORTS["tennis"]) # live board, event_type defaults to live
events = board["events"]
print(len(events), "live tennis events, cursor", board["last"])
if events:
ev = events[0]
print(ev["event_id"], ev["league_name"], "-", ev["home"], "v", ev["away"])
ml = ev["periods"]["num_0"]["money_line"] # period 0 = full match
print("moneyline:", ml) 6 live tennis events, cursor 297592
1628594960 ATP Madrid - Carlos Alcaraz v Jannik Sinner
moneyline: {'home': 2.45, 'away': 1.6}Each event carries event_id, league_id, league_name, starts (ISO 8601 UTC), home, away, event_type and a periods dict keyed num_0, num_1 and so on. Period 0 is the full match. Inside a period you find money_line, spreads, totals and team_total. Prices are decimal. Spreads are quoted from the home side, so a spread of −0.5 means home −0.5.
Prematch fixtures, markets and lines
Prematch has three endpoints with three payload sizes. prematch_fixtures returns the whole board for a sport in the same envelope as the live board. prematch_markets returns one event with every period. prematch_lines returns only the active prices for one event, flattened, optionally filtered to one market type.
import os
from pinnodds import Client, SPORTS
api = Client(os.environ["PINNODDS_KEY"])
fixtures = api.prematch_fixtures(sport_id=SPORTS["tennis"])
ev = fixtures["events"][0]
print(ev["starts"], ev["home"], "v", ev["away"])
# Every period and market for that one event
full = api.prematch_markets(ev["event_id"])
print(sorted(full["periods"].keys())) # ['num_0', 'num_1', ...]
# Compact view: only the active totals lines, no period nesting
lines = api.prematch_lines(ev["event_id"], market_type="totals")
print(lines) 2026-09-29T11:00:00Z Alcaraz v Sinner
['num_0', 'num_1', 'num_2']
{... totals lines for event 1628594960, decimal prices ...}Valid market_type values are money_line, spreads, totals and team_total. Pass include_specials=1 to prematch_fixtures or markets to also receive player props, team props and exact-score events as extra rows; each special row carries a parent_id pointing at the main match. Quarter lines and specials are included on every plan.
Deltas with since
Polling the full board every few seconds wastes your rate limit. Instead, keep the last value from each response and pass it back as since. The API returns only events whose own last is newer than your cursor, so you apply the returned events on top of your in-memory copy.
import os, time
from pinnodds import Client, SPORTS, RateLimitError
api = Client(os.environ["PINNODDS_KEY"])
SPORT = SPORTS["soccer"]
snap = api.markets(sport_id=SPORT, event_type="prematch")
board = {e["event_id"]: e for e in snap["events"]}
cursor = snap["last"]
print("snapshot:", len(board), "events")
while True:
time.sleep(30) # Trial: 20/min, 100/day
try:
delta = api.markets(sport_id=SPORT, event_type="prematch", since=cursor)
except RateLimitError as e:
time.sleep(e.retry_after)
continue
for e in delta["events"]:
board[e["event_id"]] = e # whole event, replace in place
cursor = delta["last"] # feed it back next call
print(f"{len(delta['events'])} changed, cursor -> {cursor}") Two things to know. First, the returned events are complete objects, not patches, so replacing by event_id is correct. Second, cursors age fast: a since value that is more than a few minutes old will match most of the board anyway, so after a long pause just take a fresh snapshot. Trial keys are limited to 20 requests a minute and 100 a day; the 30-second sleep above stays inside the per-minute cap but exhausts the daily cap in under an hour. Pro allows 10 requests a second and Scale 30, see the plan breakdown.
Odds-drop alerts
The feed watches every price and emits an alert when one falls past a threshold. You can consume that two ways: stream_drops opens the SSE stream and yields alerts as they happen, while drops queries a buffer of recent drops over REST. The two return different shapes.
import os
from pinnodds import Client
api = Client(os.environ["PINNODDS_KEY"])
# SSE push. Blocks, yields one alert dict at a time, reconnects on its own.
for d in api.stream_drops(min_drop=5):
pct = (1 - d["to_price"] / d["from_price"]) * 100
print(f'{d["home"]} v {d["away"]} {d["sect"]} {d["outcome"]} p{d["period"]} '
f'{d["from_price"]} -> {d["to_price"]} ({pct:.1f}%) nvp {d["nvp"]}') Sunshine Coast Phoenix v Cairns Dolphins Moneyline Home p4 2.86 -> 2.7 (5.6%) nvp 3.04 Bentleigh Greens v St Albans Saints Spread Home p0 2.37 -> 2.25 (5.1%) nvp 2.31
An SSE alert has id (the event id), sport, sport_id, league, home, away, sect (Moneyline, Spread, Total, TeamTotal), outcome (Home, Away, Over, Under and similar), period, point, from_price, to_price, nvp (the no-vig price of the new odds), limit, price_x, price_y, price_z, and a Unix starts. There is no percentage field, so compute it as (1 - to_price / from_price) * 100. The SSE guide covers filtering, backfill and the one-connection rule in detail.
The REST buffer is the pull-style alternative and the right choice on a trial key:
import os
from pinnodds import Client, SPORTS
api = Client(os.environ["PINNODDS_KEY"])
# Pull the buffer of recent drops instead of holding a stream open
res = api.drops(sport_id=SPORTS["soccer"], min_drop_pct=3)
for row in res["drops"]:
print(row["ts"], row["home"], "v", row["away"], row["market"], row["designation"],
row["points"], row["from"], "->", row["to"], f'{row["drop_pct"]}%', row["market_key"]) REST rows use market, designation, points, from, to, and include a precomputed drop_pct plus a stable market_key you can dedupe on. Do not write one parser for both shapes; write two small ones. For a complete alerting script with filtering and Telegram delivery, see the Telegram alert recipe.
Errors and rate limits
The SDK raises three exception types. AuthError means the key is missing or unknown and retrying is pointless. RateLimitError carries retry_after, the number of seconds until your window resets. PinnoddsError is the base class for everything else, including transport failures.
import os, sys, time
from pinnodds import Client, SPORTS, AuthError, RateLimitError, PinnoddsError
api = Client(os.environ.get("PINNODDS_KEY", ""))
def fetch(**kw):
for attempt in range(3):
try:
return api.markets(**kw)
except AuthError:
sys.exit("bad or missing key: check PINNODDS_KEY")
except RateLimitError as e:
time.sleep(e.retry_after) # seconds until the window resets
except PinnoddsError as e:
print("api error:", e, file=sys.stderr)
time.sleep(2 ** attempt)
raise SystemExit("gave up after 3 attempts")
print(len(fetch(sport_id=SPORTS["soccer"])["events"]), "events") Rate limits are enforced per key by plan, not per IP. Trial is 20 a minute, 100 an hour and 100 a day. Pro is 10 requests a second, Scale is 30. Retrying a 429 before retry_after only extends the wait. The SQLite recipe shows a scheduler that derives its poll interval from the plan.
Raw REST with requests
If you would rather not add a dependency, the REST API is plain JSON over HTTPS with an x-api-key header. Everything the SDK does maps to one URL, and every response carries data-freshness headers you cannot see through the SDK.
import os, requests
KEY = os.environ["PINNODDS_KEY"]
BASE = "https://pinnodds.com"
s = requests.Session()
s.headers["x-api-key"] = KEY
r = s.get(f"{BASE}/kit/v1/markets", params={"sport_id": 1, "event_type": "live"}, timeout=15)
if r.status_code == 401:
raise SystemExit("invalid key")
if r.status_code == 429:
raise SystemExit(f"rate limited, retry after {r.headers.get('Retry-After')}")
r.raise_for_status()
body = r.json()
print(body["sport_name"], len(body["events"]), "events; data status:", r.headers.get("X-Data-Status"))
# Recent prematch drops, no SDK needed
d = s.get(f"{BASE}/api/drops", params={"mode": "prematch", "min_drop_pct": 2, "max_age_sec": 3600}, timeout=15).json()
print(d["total"], "drops in the last hour") Endpoints you will use: /kit/v1/markets (live or prematch board, with since), /kit/v1/details, /kit/v1/prematch/fixtures, /kit/v1/prematch/markets, /kit/v1/prematch/lines and /api/drops. Kit endpoints also accept ?key= in the query string, handy for a quick curl but not for code. A 429 body includes retry_after_ms and which window (minute, hour or day) you exhausted. The full parameter reference is in the pinnodds docs; a mapping from the old Pinnacle endpoints to these lives on the documentation archive.
Next steps
You now have snapshots, deltas and alerts. The remaining pieces depend on what you are building.
- Every price change, not just drops. The raw WebSocket feed forwards each upstream frame so you maintain your own board.
- Push alerts in production. The SSE guide explains cold-start backfill from the drops buffer and fanning one connection out to many consumers.
- Persistence and closing-line value. The SQLite recipe stores price history for later analysis; pinnodds has a plain-language CLV guide.
- Why Pinnacle prices at all. If you need to justify the data source to a teammate, read why Pinnacle odds work as fair value.