Skip to content
Product Documentation

Python client

from betflux import Client
with Client() as bf: # reads BETFLUX_API_KEY
leagues = bf.leagues()
games = bf.games(league="NBA", date_from="2026-05-01", date_to="2026-05-07")
game = bf.game("NBA_ORL_DET_20260503")
timeline = bf.state_timeline("NBA_ORL_DET_20260503")

The client is a context manager (it owns an HTTP connection pool); close() works too if you prefer explicit lifecycle.

Method Endpoint
bf.leagues() /v1/leagues
bf.teams(league=...) /v1/teams
bf.players(league=..., team_id=...) /v1/players
bf.games(league=..., date_from=..., date_to=..., team=..., status=...) /v1/games
bf.game(game_id) /v1/games/{id}
bf.datasets() /v1/datasets
bf.state_timeline(game_id) shorthand for bf.game_state_timeline.game(game_id)
bf.check_key() /v1/me — the plan, its limits and the period’s games, as a plain dict
bf.usage() /v1/me — the same, as a typed Usage (see Usage and cost)
bf.preview_games(ids) POST /v1/me/games/preview — what reading those games would cost, counting nothing
bf.counted_games(league=..., date_from=..., date_to=...) GET /v1/me/games — the games already counted or exempt this period, free to read again

List methods paginate transparently and return complete lists of plain dicts. A game dict carries the id, schedule, both teams, the score, venue, and game-time weather — the fields are listed on the games page:

game = bf.game("NBA_ORL_DET_20260503")
print(game["home_score"], game["away_score"], game["venue_name"], game["weather_type"])

Four Dataset handles hang off the client — bf.closing_lines, bf.market_results, bf.sportsbook_lines, bf.game_state_timeline — or look one up by public name with bf.dataset("closing-lines"). Each wraps the same file model: fetch per-game Parquet artifacts, parse with pyarrow, filter locally.

# Stream rows lazily over a date range: discovers games via /v1/games,
# downloads each game's Parquet file, filters locally, yields dicts
for row in bf.closing_lines.iter(
league="NBA", date_from="2026-05-01", date_to="2026-06-30",
operator="FANDUEL",
):
...
# Materialize a list
rows = bf.closing_lines.rows(league="NBA", date_from="2026-05-01", date_to="2026-05-07")
# pandas DataFrame (needs the [pandas] extra)
df = bf.closing_lines.df(league="NBA", date_from="2026-05-01", date_to="2026-05-07")
# Everything for one game, filtered locally
game_rows = bf.market_results.game("MLB_BOS_NYY_20260715", outcome="WON")
# The raw Parquet bytes, verbatim
data = bf.sportsbook_lines.raw("MLB_BOS_NYY_20260715")
# Explicit ids instead of a date range — e.g. the games a preview said fit
rows = bf.closing_lines.rows(games=["NBA_ORL_DET_20260503", "NBA_PHI_BOS_20260502"])

iter()/rows()/df() take a required league + date_from/date_to window, or games=[...] with explicit ids. game() and raw() take one game id. game_state_timeline and sportsbook_lines are per-game datasets — use game()/raw(); their files are large, so the range interface is deliberately reserved for closing-lines and market-results. A game costs the same whichever dataset you read, and once it is counted all of its datasets are free for the rest of the period.

Filter keywords are evaluated client-side after download — they shape the rows you see, not what a game costs:

Keyword Matches Datasets
league, operator, market_type equality all lines datasets
team home_team or away_team (also forwarded to /v1/games discovery, which does skip fetches) all lines datasets
side equality on closing-lines; list membership on market-results / sportsbook-lines lines datasets
player_id membership in market_player_ids / selection_player_ids closing-lines, market-results, sportsbook-lines
mainline is_mainline is True — the operator’s primary full-game spread / moneyline / total; pre-flag NULL rows never match all lines datasets
outcome equality (WON, LOST, PUSH, INDETERMINATE) market-results
league equality game-state-timeline

A filter the dataset has no columns for raises ValueError before anything is fetched.

max_rows caps the total: iteration stops — and no further game files are downloaded, so no further games are counted — once that many rows have been yielded. It bounds cost as well as output, but not tightly: a game is read (and counted) before its rows are filtered. (There is no page_size anymore; nothing paginates server-side.)

Usage is metered in distinct games per billing period: a game counts once, on its first read of any per-game dataset, and every later read of it is free; games dated within the plan’s recent-game window are exempt; at the allowance only a new game is refused. The SDK reads your key’s numbers at runtime and never assumes any:

usage = bf.usage() # GET /v1/me — free, never counts a game
print(usage.plan.label, usage.limits.games_per_period, usage.games_remaining, usage.resets_at)
preview = bf.closing_lines.preview(league="NBA", date_from="2026-05-01", date_to="2026-05-07")
print(preview.new, preview.already, preview.exempt, preview.invalid, preview.fits, preview.over_by)
if preview.fits:
rows = bf.closing_lines.rows(games=list(preview.games))
else:
rows = bf.closing_lines.rows(games=preview.fitting()) # only what the allowance covers
for game in bf.counted_games(league="NBA"): # free to read again all period
print(game.game_id, game.first_read_at, game.first_dataset, game.cost)

Usage carries everything /v1/me reports: plan (label, size), limits (rate_limit_per_minute, games_per_period, free_recent_game_days, max_keys, live, league_datasets, sample), the period’s counters (games_used, games_allowance, games_remaining, games_exempt, period_start, resets_at, counted_by_league) and upgrade_url. A None means the gateway reports no such number — an uncapped plan, or an older gateway. preview_games(ids) and Dataset.preview(...) return a GamesPreview whose games maps each id to new, already, exempt or invalid; a large list is sent in chunks. counted_games() pages through GET /v1/me/games as CountedGames.

Every read also reports what it cost, from the x-betflux-game and x-betflux-games-remaining headers: GameDone.counted and GameDone.games_remaining on progress events, and .cost (a GameCost) on GameRows, GameTable, LiveSegments, LiveBoard, SportsDataSnapshot and SportsDataRevision. Uncapped plans send no headers, so these are None there.

Timestamp and date columns come back as Python datetime / date objects — pyarrow decodes the real Parquet types; nothing is an ISO string to parse.

All errors derive from BetfluxError; API failures raise typed subclasses of APIError, each carrying the problem document’s fields (status, title, detail, type — the stable problem type URI — and extensions in extra):

Exception Status
AuthError 401
PaymentRequiredError 402 subscription-required (key valid, but account has no active subscription; response includes upgrade_url)
ForbiddenError 403 (key disabled)
NotFoundError 404
RateLimitError 429 (rate limit)
QuotaExceededError 429 quota-exhausted — the period’s games allowance is spent and this game would be a new one; carries .game_id, .games_used, .games_allowance, .reset_at (and .reset, its date) and .upgrade_url

Rate-limit 429s and 5xx retry automatically with exponential backoff honoring Retry-After (capped at 120 s). QuotaExceededError is raised immediately — the allowance won’t clear by retrying, though every game already counted this period can still be read.

Transport failures — DNS, connect, TLS, timeout — are retried the same way; when retries are exhausted they raise NetworkError (also a BetfluxError) with a friendly message. It carries no HTTP status because no response arrived.

During a range iter(), games without an artifact (not settled yet, or not served for that dataset) are skipped silently rather than raising NotFoundError — a fetch of a single missing game via game()/raw() does raise.

from betflux import Client, QuotaExceededError
with Client() as bf:
try:
rows = bf.closing_lines.rows(league="NBA", date_from="2026-05-01", date_to="2026-05-07")
except QuotaExceededError as e:
print("allowance spent:", e.game_id, "resets", e.reset_at, "upgrade at", e.upgrade_url)

Long pulls are observable: pass on_progress= (or assign bf.on_progress) a callable and the client emits typed events from betflux.progress as work happens — DiscoveryStart/DiscoveryDone around /v1/games discovery, GameStart/GameDone/GameSkipped per game in a range iter() (GameDone carries counted and games_remaining from the admission headers), DownloadStart/DownloadProgress/DownloadDone as each file’s bytes stream in (with total_bytes from the response), and RetryWait whenever the client is about to sleep before a retry. Events are frozen dataclasses; dispatch on type:

from betflux import Client
from betflux.progress import DownloadProgress, RetryWait
def show(event):
if isinstance(event, DownloadProgress):
print(f"\r{event.received_bytes}/{event.total_bytes or '?'} bytes", end="")
elif isinstance(event, RetryWait):
print(f"\n{event.reason} — retrying in {event.seconds:.0f}s")
with Client(on_progress=show) as bf:
data = bf.sportsbook_lines.raw("MLB_BOS_NYY_20260715")

This is exactly how the CLI draws its progress bar; wiring the events into tqdm or rich is a few lines of the same shape.