SmartStake Odds API
Pregame and live sportsbook odds in one schema. Pull the current board over REST and keep it current with a Server-Sent Events stream.
Early access
The API is in early access. Fields may change before general release; we will give notice before they do. Use of the API is subject to the Odds API Early Access Terms.
Overview
The SmartStake Odds API serves pregame and live odds from sportsbooks, exchanges and prediction markets in one schema.
- REST returns the quotes on the board right now, for chosen events or a whole league.
- The stream pushes every change to those quotes as it happens.
Base URL:
https://api.server.smartstake.ca
Quick start
export SMARTSTAKE_API_KEY=your-key
# Events on the board for a league
curl --compressed -H "Authorization: Bearer $SMARTSTAKE_API_KEY" \
"https://api.server.smartstake.ca/v1/events?league=mlb"
# Current quotes for one event from two books
curl --compressed -H "Authorization: Bearer $SMARTSTAKE_API_KEY" \
"https://api.server.smartstake.ca/v1/odds?event_id=evt_01M3JFTX43BKN11ZN73APGWD2X&book=draftkings&book=fanduel"
# Stream changes for that event
curl -N --compressed -H "Authorization: Bearer $SMARTSTAKE_API_KEY" \
"https://api.server.smartstake.ca/v1/stream/baseball?event_id=evt_01M3JFTX43BKN11ZN73APGWD2X&book=draftkings&book=fanduel"
Authentication
Send your API key as a bearer token on every request:
Authorization: Bearer your-key
Clients that cannot set headers can pass key=your-key as a query parameter instead. Your key determines which books and leagues you can access and how many streams you can open. Keep it secret.
Compression
Every request must include Accept-Encoding: gzip. Requests without it return 406. Responses larger than 1 KB, league snapshot pages and streams are gzip-compressed.
Most HTTP clients send this header and decompress responses automatically. With curl, add --compressed.
Conventions
- All endpoints use
GET. - Successful responses have the shape
{ "data": ... }. Errors have the shape{ "error": "..." }. - To pass several values for a parameter, repeat it (
book=draftkings&book=fanduel) or separate the values with commas (book=draftkings,fanduel). formatsets the odds format:american(default) ordecimal.- Times are ISO 8601 in UTC (
starts_at) or epoch milliseconds (updated_at).
Keeping a local board
To hold a current copy of the board:
- Open the stream.
- Pull the current quotes over REST.
- Apply each stream event to your copy, keyed by
quote_id. Keep the version with the newestupdated_at.
Open the stream before you pull so that no change is missed between the two. After a reconnect, pull again.
Reference data
| Endpoint | Parameters | Returns |
|---|---|---|
GET /v1/status | none | Readiness, and the last update time for each book |
GET /v1/sports | none | Sports with at least one event on the board |
GET /v1/leagues | sport (optional) | Leagues with at least one event on the board |
GET /v1/books | none | Books your key can access |
GET /v1/events | sport, league (at least one) | Events in start-time order |
GET /v1/markets | sport, league (at least one) | Market keys and names |
An event has the fields id, sport, league, starts_at, home, away and live.
A sport or league with no events listed drops out of /v1/sports and /v1/leagues until its next event is posted. Your key still includes it. This happens between playoff rounds, for example, when the next game has not been scheduled yet.
Current odds
GET /v1/odds returns the quotes currently on the board. A quote that is not returned is suspended or no longer offered.
It works in two modes: by event, or a whole league (see League snapshots).
| Parameter | Description |
|---|---|
event_id | Required. 1 to 5 event ids. |
book | Required. 1 to 5 books. |
market | Market keys from /v1/markets. |
alternate | true for alternate lines only, false for main lines only. |
live | true for live quotes only, false for pregame only. |
format | american (default) or decimal. |
state, platform | Point bet_url at a region and device. See Bet links. |
curl --compressed -H "Authorization: Bearer $SMARTSTAKE_API_KEY" \
"https://api.server.smartstake.ca/v1/odds?event_id=evt_01M3JFTX43BKN11ZN73APGWD2X&book=draftkings&format=decimal"
League snapshots
Call /v1/odds with one league and no event_id to get every quote in that league, in pages. book is optional; without it, every book your key can access is included.
Each page contains whole events in start-time order, about 25,000 quotes. Pass the returned next_cursor as cursor to get the next page. next_cursor is null on the last page.
{
"league": "nfl",
"event_ids": ["evt_01KZCTK537WSKW9Q44E178X194"],
"next_cursor": "2026-10-02T00:15:00Z|evt_01KZCTK537WSKW9Q44E178X194",
"data": [ ... ]
}
Snapshot pages count against a separate per-minute limit. A 429 or 503 response includes a Retry-After header; wait that many seconds and repeat the same request.
import gzip, json, os, time, urllib.error, urllib.parse, urllib.request
API = "https://api.server.smartstake.ca"
HEADERS = {
"Authorization": f"Bearer {os.environ['SMARTSTAKE_API_KEY']}",
"Accept-Encoding": "gzip",
}
def league_snapshot(league, books=()):
cursor, quotes = None, []
while True:
query = [("league", league)] + [("book", book) for book in books]
if cursor:
query.append(("cursor", cursor))
url = f"{API}/v1/odds?{urllib.parse.urlencode(query)}"
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=HEADERS), timeout=120) as response:
body = response.read()
if response.headers.get("Content-Encoding") == "gzip":
body = gzip.decompress(body)
except urllib.error.HTTPError as error:
if error.code in (429, 503):
time.sleep(int(error.headers.get("Retry-After", "5")))
continue
raise
page = json.loads(body)
quotes += page["data"]
cursor = page["next_cursor"]
if cursor is None:
return quotes
quotes = league_snapshot("nfl")
Stream
GET /v1/stream/{sport} opens a Server-Sent Events stream of quote changes. sport is one of football, basketball, baseball, hockey, soccer, tennis or mma.
| Parameter | Description |
|---|---|
league | Leagues to include. |
event_id | Events to include. |
book | Books to include. |
market | Market keys to include. |
alternate | true for alternate lines only, false for main lines only. |
live | true for live quotes only, false for pregame only. |
include_status | true to receive event-status events. |
format | american (default) or decimal. |
state, platform | Point bet_url at a region and device. See Bet links. |
Changes are sent in batches every 250 ms. A batch contains only the latest version of each quote. The stream is gzip-compressed and each batch is flushed as soon as it is written, so decompress it incrementally as bytes arrive.
Events
| Event | Meaning |
|---|---|
connected | The stream is open. |
quote | One or more quotes are new, changed or back on the board. |
quote-removed | One or more quotes were suspended or pulled. |
event-status | An event's status, score or start time changed. Sent only with include_status=true. |
ping | Keep-alive, sent after 25 seconds without other events. |
board-reset | The board is being rebuilt and the stream is closing. See Board resets. |
error | Your client read the stream too slowly and the stream is closing. See Slow clients. |
quote and quote-removed carry { "type": "...", "quotes": [ ... ] }. Match removals to your board by quote_id. Some removals include only quote_id, event_id, book, market_key, side, line, live and updated_at.
event: connected
data: ok
event: quote
id: 1790787140829
data: {"type":"quote","quotes":[{"quote_id":"m~evt_01KZCTK5WDFG6R6TWYDZV61GV2~football_player_receptions~none~43576~1.5~over~caesars", ...}]}
event: quote-removed
id: 1790787141120
data: {"type":"quote-removed","quotes":[{"quote_id":"m~evt_01KZCTK52NHV0W2Q2PCZVK6R3N~football_player_receptions~none~41593~9.5~under~fanduel", ...}]}
Slow clients
The stream only sends as fast as your client reads. If your client reads too slowly, updates wait on our side and age. When the oldest unsent update has waited more than 10 seconds, you receive an error event and the stream closes, rather than keep delivering stale prices:
event: error
data: {"error":"Your client read this stream too slowly: ...","reason":"client_too_slow","waited_seconds":12,"limit_seconds":10,"docs":"https://www.smartstake.app/api#slow-clients"}
To avoid it:
- Read the stream on its own thread or process and hand events to a queue. Do slow work, such as database writes or model runs, outside the read loop.
- Batch your writes rather than writing once per quote.
- Filter the stream to the leagues, books and markets you use.
- Compare each quote's
updated_atwith your own clock to see when your client is falling behind.
After an error, reconnect and pull current odds again.
Example: recording the stream
The API serves the current board and its changes, not historical odds. This script records a stream to a JSON Lines file, one line per event with the time it was received, and reconnects automatically. It uses only the Python standard library.
import codecs, json, os, sys, time, urllib.request, zlib
API = "https://api.server.smartstake.ca"
SPORT = sys.argv[1] if len(sys.argv) > 1 else "football"
QUERY = "book=draftkings&book=fanduel&format=decimal"
OUTPUT = f"quotes_{SPORT}_{time.strftime('%Y%m%d')}.jsonl"
def stream_lines(response):
inflater = zlib.decompressobj(16 + zlib.MAX_WBITS)
decoder = codecs.getincrementaldecoder("utf-8")()
pending = ""
while chunk := response.read1(65536):
pending += decoder.decode(inflater.decompress(chunk))
*lines, pending = pending.split("\n")
yield from lines
def record(output):
request = urllib.request.Request(f"{API}/v1/stream/{SPORT}?{QUERY}", headers={
"Authorization": f"Bearer {os.environ['SMARTSTAKE_API_KEY']}",
"Accept-Encoding": "gzip",
})
event = None
with urllib.request.urlopen(request, timeout=90) as response:
for line in stream_lines(response):
if line.startswith("event: "):
event = line[len("event: "):]
elif line.startswith("data: ") and event in ("quote", "quote-removed", "board-reset"):
output.write(json.dumps({
"received_ms": int(time.time() * 1000),
"event": event,
"payload": json.loads(line[len("data: "):]),
}) + "\n")
output.flush()
with open(OUTPUT, "a") as output:
delay = 1
while True:
try:
record(output)
delay = 1
except Exception as error:
print("reconnecting after", error, file=sys.stderr)
time.sleep(delay)
delay = min(delay * 2, 60)
Run one process per sport, for example python record_stream.py basketball.
The quote object
{
"quote_id": "m~evt_01KZCTK5WDFG6R6TWYDZV61GV2~football_player_receptions~none~43576~1.5~over~caesars",
"event_id": "evt_01KZCTK5WDFG6R6TWYDZV61GV2",
"sport": "football",
"league": "nfl",
"starts_at": "2026-10-04T17:00:00Z",
"home": "tampa bay buccaneers",
"away": "green bay packers",
"book": "caesars",
"market_name": "Player Receptions",
"market_key": "football_player_receptions",
"label": "Cade Otton Over 1.5",
"participant": "Cade Otton",
"side": "over",
"line": 1.5,
"odds": -530,
"alternate": true,
"live": false,
"player_id": 43576,
"team_id": null,
"bet_url": null,
"max_bet": null,
"updated_at": 1790787140829
}
| Field | Description |
|---|---|
quote_id | Stable id for one book's price on one side of one line. See Quote ids. |
event_id | The event. |
sport, league | The event's sport and league. |
starts_at | Scheduled start time. |
home, away | Team or competitor names. |
book | The book offering the price. |
market_name, market_key | Display name and stable key of the market. |
label | Readable description of the selection. |
participant | Player or team the selection refers to, if any. |
side | One of hometeam, awayteam, over, under, draw, yes, no, even, odd. |
line | This side's line. null for moneylines and yes/no markets. |
odds | The price, in American or decimal format. |
alternate | true for alternate lines. |
live | true for in-play quotes. |
player_id, team_id | Ids of the player or team the market is about, if any. |
bet_url | Link to the selection on the book's site, where available and enabled for your key. See Bet links. |
max_bet | The largest stake the book accepts, where the book publishes it. |
updated_at | When this price last changed, in epoch milliseconds. |
Exchange and prediction market prices are what it costs to take the price, net of the venue's fee: Kalshi, Polymarket and Polymarket US prices include the taker fee, and ProphetX prices are net of its commission on winnings. Novig pregame prices carry no fee.
Bet links
Many books run a different site in each state or province, and some open a bet slip in their app through a separate link. Pass where your user is and what they are on, and bet_url comes back ready to open:
| Parameter | Description |
|---|---|
state | Two-letter state or province code, such as NJ or ON. |
platform | desktop (default) or mobile. mobile returns the app link for books that have one. |
Both work on /v1/odds and on streams. Without them, bet_url is the link as we collected it. An unrecognised value returns 400.
curl --compressed -H "Authorization: Bearer YOUR_KEY" \
"https://api.server.smartstake.ca/v1/odds?event_id=evt_01M3JFTX43BKN11ZN73APGWD2X&book=fanduel&state=NJ&platform=mobile"
| Book | state | platform=mobile |
|---|---|---|
betmgm | State site | App link |
fanduel | State site | App link |
draftkings | App link | |
novig | App link | |
prophetx | App link | |
hard_rock, hardrock_on | Ontario site | Ontario app link |
bet365 | State or province site | |
betrivers | State site | |
caesars | State site (desktop only) | |
bally_bet, bet99, bwin, pinnacle | Canadian or international site |
For every other book, and where a book has no site in the state you pass, bet_url is returned unchanged. A link opens the selection on the book; whether the user can bet there is still up to the book.
Quote ids
A quote_id is made of segments separated by ~:
m ~ event ~ market_key ~ team ~ player ~ line ~ side ~ book
Removing the last two segments (side and book) gives an id shared by every book's quotes on both sides of the same line. Use it to group quotes and to find the opposite side (over and under, hometeam and awayteam, yes and no). Spread lines are written from the home side in the id, so both sides of a spread share it.
Board resets
Occasionally the odds board is rebuilt. When a rebuild starts:
- Every open stream receives
board-resetwith"phase": "started"and closes. - Every endpoint except
/v1/statusreturns503with aRetry-Afterheader, and/v1/statusreportsboard_reset_in_progress: true.
Discard every quote you hold. When requests succeed again, reconnect and pull. The board fills back in as books publish prices, so it can be incomplete for a few minutes after a reset.
Limits
Default limits for each key:
| Limit | Value |
|---|---|
| Open stream connections | 7 |
| Requests per minute | 120 |
| League snapshot pages per minute | 10 |
Events and books per /v1/odds request | 5 each |
| Time an update can wait for a slow client | 10 seconds |
Per-minute limits reset at the start of each minute. Contact us to change your key's limits, books or leagues.
Errors
| Status | Meaning | What to do |
|---|---|---|
400 | A parameter is missing or invalid. | Fix the request. |
401 | The API key is missing or unknown. | Check your key. |
403 | A requested book or league is not included in your key. | Remove it, or contact us. |
404 | Unknown endpoint or sport. | Check the path. |
406 | The request did not include Accept-Encoding: gzip. | Add the header. |
429 | A request, snapshot or stream limit was exceeded. | Wait for Retry-After seconds. |
502 | The odds board is briefly unavailable. | Retry with backoff. |
503 | A board reset is in progress, or snapshot capacity is busy. | Wait for Retry-After seconds. |