API & Data
How to get Pinnacle odds from an API in Python

Get a free key, list sports and events, read one event's odds, and poll Pinnacle prices in Python within rate limits, handling 429 and stale data.
This guide takes you from no key to a Python script that reads Pinnacle prices, picks out one event, and keeps polling without tripping rate limits. It uses plain requests. There is no SDK to install, because the API is ordinary HTTP and JSON.
PinnRadar is an independent data service and is not affiliated with Pinnacle. You don't need a Pinnacle account to use it.
Step 1: get a free API key
Open the dashboard, sign up with an email address and verify it. The Free plan's key never expires. It has these limits:
| Window | Free plan limit |
|---|---|
| Per minute | 20 requests |
| Per hour | 100 requests |
| Per day | 100 requests (resets at 00:00 UTC) |
That is enough to build and test a client. The pricing page covers the paid plans, and our plan guide explains how to choose one.
Keep the key in an environment variable so it never ends up in your code or your shell history:
export PINNRADAR_KEY="paste-your-key-here"
Step 2: a small, polite HTTP helper
The key can go in three places. When a request carries more than one, the first match in this order wins:
- the
x-portal-apikeyheader - the
x-api-keyheader - a
?key=query parameter
This guide uses x-api-key. Avoid ?key= outside quick tests, because URLs end up in proxy logs.
Every call goes through one helper. It handles HTTP 429 properly from the start, since you will meet it as soon as you poll in a loop.
import os
import time
import requests
BASE = "https://pinnradar.com"
KEY = os.environ["PINNRADAR_KEY"]
session = requests.Session()
session.headers["x-api-key"] = KEY
class DailyQuotaExhausted(Exception):
"""Raised when the per-day window is used up (Free and Drops plans)."""
def get(path, params=None, max_attempts=5):
"""GET a PinnRadar endpoint. Honours Retry-After on 429 and backs off on 5xx."""
backoff = 1.0
for _ in range(max_attempts):
resp = session.get(BASE + path, params=params, timeout=30)
if resp.status_code == 429:
try:
body = resp.json()
except ValueError:
body = {}
if body.get("window") == "day":
raise DailyQuotaExhausted("daily limit reached; it resets at 00:00 UTC")
wait = float(resp.headers.get("Retry-After", backoff))
print(f"429 on the {body.get('window', '?')} window, sleeping {wait:.0f}s")
time.sleep(wait)
continue
if resp.status_code >= 500:
# 500 "internal" is documented as safe to retry with backoff
time.sleep(backoff)
backoff = min(backoff * 2, 30)
continue
resp.raise_for_status() # 400, 401 missing_key / invalid_key, 503 prematch_disabled
return resp
raise RuntimeError(f"gave up on {path} after {max_attempts} attempts")
A 429 response looks like this:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 17
{ "error": "rate_limited", "limit": 20, "window": "minute", "retry_after_ms": 16480 }
Retry-After is in seconds. The body says which window you hit and gives the same wait in milliseconds as retry_after_ms. The windows are fixed to the clock rather than rolling. When the daily window is used up, retrying is pointless, so the helper raises an error instead of sleeping until midnight.
Step 3: sport ids
There is no endpoint that lists sports. The ids are stable integers and you can hard-code them:
SPORTS = {
1: "Soccer", 2: "Tennis", 3: "Basketball", 4: "Hockey", 5: "Football",
6: "Baseball", 7: "Rugby", 8: "MMA", 9: "Boxing",
10: "Other (volleyball + handball)", 11: "Esports", 12: "Golf",
13: "Cricket", 14: "Volleyball", 15: "Handball",
}
14 and 15 are subsets of 10. If you poll 10, 14 and 15 together, you get the same volleyball and handball events twice. Golf is moneyline only, so its spreads, totals and team_total objects are empty.
Step 4: list events
There are two boards:
- In-play:
GET /kit/v1/markets?sport_id=<id>.event_typedefaults tolive. - Upcoming:
GET /kit/v1/prematch/fixtures?sport_id=<id>, or the same/kit/v1/marketscall withevent_type=prematch.
Both return the same envelope and the same event layout. Only the event_type value on each event differs, so one parser handles both:
def list_events(sport_id, live=False):
if live:
resp = get("/kit/v1/markets", {"sport_id": sport_id, "event_type": "live"})
else:
resp = get("/kit/v1/prematch/fixtures", {"sport_id": sport_id})
board = resp.json()
print(board["sport_name"], "cursor", board["last"],
"| X-Data-Status:", resp.headers.get("X-Data-Status"))
return board["events"]
def full_game_moneyline(event):
p0 = (event.get("periods") or {}).get("num_0") or {}
return p0.get("money_line") or {}
for ev in list_events(1)[:10]:
ml = full_game_moneyline(ev)
print(ev["event_id"], ev["starts"], f"{ev['home']} v {ev['away']}",
ml.get("home"), ml.get("draw"), ml.get("away"))
Each event has event_id, league_name, home, away, starts (ISO 8601, UTC) and a periods object. num_0 is the full match, num_1 the first half, num_2 the second half, and so on. Inside a period you'll find:
money_line:{home, draw, away}in decimal odds (drawonly where the sport has one)spreads: keyed by handicap, each{hdp, home, away, max}totals: keyed by points, each{points, over, under, max}team_total(the main line per side) andteam_totals(every alternate line)meta: period metadata, score and maximum stakes
A soccer prematch board is large. Each call costs one request whatever its size, so on the Free plan, fetch the board once and keep the event_id values you care about.
Step 5: odds for a single event
To price one match, call the single-event endpoint:
- In-play:
GET /kit/v1/details?event_id=<id> - Prematch:
GET /kit/v1/prematch/markets?event_id=<id>
In the documented examples, both reply with the event inside an events array, using the same Event layout as the board:
def event_prices(event_id, live=False):
path = "/kit/v1/details" if live else "/kit/v1/prematch/markets"
events = get(path, {"event_id": event_id}).json().get("events") or []
return events[0] if events else None
ev = event_prices(1630412287)
if ev is None:
print("not on the board any more (kicked off, withdrawn, or wrong board)")
else:
p0 = ev["periods"]["num_0"]
print("1X2:", p0.get("money_line"))
for line, s in sorted(p0.get("spreads", {}).items(), key=lambda kv: float(kv[0])):
print(f"handicap {s['hdp']:+}: home {s['home']} / away {s['away']} (max {s['max']})")
for line, t in sorted(p0.get("totals", {}).items(), key=lambda kv: float(kv[0])):
print(f"total {t['points']}: over {t['over']} / under {t['under']}")
Once a fixture starts and its in-play listing exists, it leaves the prematch board. From then on, read it with /kit/v1/details. For a smaller payload on a prematch event, GET /kit/v1/prematch/lines?event_id=<id>&market_type=totals returns just one market type per period (money_line, spreads, totals or team_total).
Step 6: poll politely with the since cursor
Every board response carries a top-level last. This is the store's sequence number, and it only ever increases. Send it back as since and you get only the events that changed after it. That makes repeated polls much smaller.
Three rules from the docs decide how to merge:
- If an event is not in a
sincereply, it hasn't changed. Keep your copy. - Removals only show in a full response. An event missing from a full response was withdrawn, or kicked off and moved to live.
- Always send the
lastfrom the latest reply, never an older value and never a clock time.
def follow_prematch(sport_id, interval_s, full_every=12):
book, cursor, polls = {}, None, 0
while True:
full = cursor is None or polls % full_every == 0
params = {"sport_id": sport_id}
if not full:
params["since"] = cursor
resp = get("/kit/v1/prematch/fixtures", params)
status = resp.headers.get("X-Data-Status")
if status != "fresh":
# "stale" or "unknown": the board is held back and comes back empty.
# That means no fresh data, not no fixtures, so keep the book as it is.
print("data status", status, "- keeping", len(book), "cached events")
else:
body = resp.json()
events = {ev["event_id"]: ev for ev in body["events"]}
if full:
gone = book.keys() - events.keys()
if gone:
print(len(gone), "events left the prematch board")
book = events
else:
book.update(events)
cursor = body["last"]
print(f"{'full' if full else 'delta'} poll: {len(events)} rows, book {len(book)}")
polls += 1
time.sleep(interval_s)
How fast can you poll? On the Free plan the daily window is the binding limit. 100 requests spread over 24 hours is one request every 864 seconds. interval_s=900 (15 minutes) uses 96 a day, which leaves almost nothing for other calls. That suits a development loop, not a production poller. The REST and Edge plans allow 10 requests per second and Scale allows 30, with no per-day cap. For example, polling one sport every 5 seconds is 17,280 requests a day, well inside those limits.
Step 7: read the freshness headers
Every REST response has three headers worth logging:
| Header | Meaning |
|---|---|
X-Data-Status: fresh | The data is current |
X-Data-Status: stale | No upstream writes for longer than the threshold. The board is held back and you get the normal empty shape, not an error |
X-Data-Status: unknown | Nothing written yet (cold start). Not an outage |
X-Data-Age-Ms | Age of the newest data on the board, rounded down to whole seconds. Omitted when unknown |
X-Data-Board | live or prematch |
The distinction matters. Without the header, an empty soccer board at 3 a.m. and a paused feed look identical. If you need a tighter tolerance than the server's threshold, compare X-Data-Age-Ms with your own limit. GET /health (key required) reports the live and prematch feeds separately, and GET /ping returns ok with no key, for uptime monitors.
Errors you should handle
| Status | error | What to do |
|---|---|---|
| 400 | (none) | A required parameter is missing or malformed. Fix the request |
| 401 | missing_key / invalid_key | Check the header. A regenerated key replaces the old one |
| 429 | rate_limited | Wait for Retry-After |
| 500 | internal | Retry with backoff |
| 503 | prematch_disabled | The prematch feed is switched off. Try later |
Where to go next
- If you poll mainly to catch price falls, a stream fits better. See detecting dropping odds with Server-Sent Events.
- To record closing prices for your own analysis, see tracking closing line value.
- If you're not sure polling is the right transport, read REST vs WebSocket vs SSE.
- The full parameter reference, including player props via
include_specials, is in the API docs.
Questions readers ask
Do I need a Pinnacle account to use this API?
No. PinnRadar is an independent data service. You sign up with an email address on the dashboard and get a free API key.
Which header carries the API key?
Send it as x-api-key or x-portal-apikey, or as a ?key= query parameter for quick tests. If a request carries more than one, x-portal-apikey wins, then x-api-key, then ?key=.
How often can I poll on the Free plan?
The Free plan allows 20 requests a minute, 100 an hour and 100 a day. Over a full day that works out to about one request every 15 minutes, which suits development. The REST and Edge plans allow 10 requests a second and Scale allows 30.
What should my code do on HTTP 429?
Wait the number of seconds in the Retry-After header, then retry. The JSON body names the window you hit (minute, hour or day) and gives retry_after_ms. When the daily window is used up, wait for the reset at 00:00 UTC.
Why did I get an empty board with HTTP 200?
Check the X-Data-Status header. stale means upstream writes stopped for longer than the threshold, so the board is held back and returned in its normal empty shape. unknown means a cold start. Neither means there are no matches.
Pull Pinnacle prices into your own code
The PinnRadar odds APIcarries Pinnacle’s live and prematch markets, drop alerts included, over REST, SSE and WebSocket. Compare the plans, or start on a free key: just an email, and no Pinnacle account needed.