Documentation

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).
  • format sets the odds format: american (default) or decimal.
  • 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:

  1. Open the stream.
  2. Pull the current quotes over REST.
  3. Apply each stream event to your copy, keyed by quote_id. Keep the version with the newest updated_at.

Open the stream before you pull so that no change is missed between the two. After a reconnect, pull again.

Reference data

EndpointParametersReturns
GET /v1/statusnoneReadiness, and the last update time for each book
GET /v1/sportsnoneSports with at least one event on the board
GET /v1/leaguessport (optional)Leagues with at least one event on the board
GET /v1/booksnoneBooks your key can access
GET /v1/eventssport, league (at least one)Events in start-time order
GET /v1/marketssport, 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).

ParameterDescription
event_idRequired. 1 to 5 event ids.
bookRequired. 1 to 5 books.
marketMarket keys from /v1/markets.
alternatetrue for alternate lines only, false for main lines only.
livetrue for live quotes only, false for pregame only.
formatamerican (default) or decimal.
state, platformPoint 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.

ParameterDescription
leagueLeagues to include.
event_idEvents to include.
bookBooks to include.
marketMarket keys to include.
alternatetrue for alternate lines only, false for main lines only.
livetrue for live quotes only, false for pregame only.
include_statustrue to receive event-status events.
formatamerican (default) or decimal.
state, platformPoint 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

EventMeaning
connectedThe stream is open.
quoteOne or more quotes are new, changed or back on the board.
quote-removedOne or more quotes were suspended or pulled.
event-statusAn event's status, score or start time changed. Sent only with include_status=true.
pingKeep-alive, sent after 25 seconds without other events.
board-resetThe board is being rebuilt and the stream is closing. See Board resets.
errorYour 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_at with 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
}
FieldDescription
quote_idStable id for one book's price on one side of one line. See Quote ids.
event_idThe event.
sport, leagueThe event's sport and league.
starts_atScheduled start time.
home, awayTeam or competitor names.
bookThe book offering the price.
market_name, market_keyDisplay name and stable key of the market.
labelReadable description of the selection.
participantPlayer or team the selection refers to, if any.
sideOne of hometeam, awayteam, over, under, draw, yes, no, even, odd.
lineThis side's line. null for moneylines and yes/no markets.
oddsThe price, in American or decimal format.
alternatetrue for alternate lines.
livetrue for in-play quotes.
player_id, team_idIds of the player or team the market is about, if any.
bet_urlLink to the selection on the book's site, where available and enabled for your key. See Bet links.
max_betThe largest stake the book accepts, where the book publishes it.
updated_atWhen 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.

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:

ParameterDescription
stateTwo-letter state or province code, such as NJ or ON.
platformdesktop (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"
Bookstateplatform=mobile
betmgmState siteApp link
fanduelState siteApp link
draftkingsApp link
novigApp link
prophetxApp link
hard_rock, hardrock_onOntario siteOntario app link
bet365State or province site
betriversState site
caesarsState site (desktop only)
bally_bet, bet99, bwin, pinnacleCanadian 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-reset with "phase": "started" and closes.
  • Every endpoint except /v1/status returns 503 with a Retry-After header, and /v1/status reports board_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:

LimitValue
Open stream connections7
Requests per minute120
League snapshot pages per minute10
Events and books per /v1/odds request5 each
Time an update can wait for a slow client10 seconds

Per-minute limits reset at the start of each minute. Contact us to change your key's limits, books or leagues.

Errors

StatusMeaningWhat to do
400A parameter is missing or invalid.Fix the request.
401The API key is missing or unknown.Check your key.
403A requested book or league is not included in your key.Remove it, or contact us.
404Unknown endpoint or sport.Check the path.
406The request did not include Accept-Encoding: gzip.Add the header.
429A request, snapshot or stream limit was exceeded.Wait for Retry-After seconds.
502The odds board is briefly unavailable.Retry with backoff.
503A board reset is in progress, or snapshot capacity is busy.Wait for Retry-After seconds.