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-04-01", date_to="2026-04-07")
game = bf.game("NBA_GSW_MIA_20260401")
timeline = bf.state_timeline("NBA_GSW_MIA_20260401")

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 — tier, rate limit, quota usage

List methods paginate transparently and return complete lists.

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-01-01", date_to="2026-04-01",
operator="FANDUEL",
):
...
# Materialize a list
rows = bf.closing_lines.rows(league="NBA", date_from="2026-04-01", date_to="2026-04-07")
# pandas DataFrame (needs the [pandas] extra)
df = bf.closing_lines.df(league="NBA", date_from="2026-04-01", date_to="2026-04-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")

iter()/rows()/df() take a required league + date_from/date_to window. game() and raw() take one game id (the public id). game_state_timeline and sportsbook_lines are per-game datasets — use game()/raw(); a sportsbook-lines range pull would debit ~190k quota rows per game, so the range interface is deliberately reserved for closing-lines and market-results.

Filter keywords are evaluated client-side after download — they shape the rows you see, not the quota you spend:

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
outcome equality (WON, LOST, PUSH, INDETERMINATE) market-results
field, source, 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 — once that many rows have been yielded, so it bounds quota spend, not just output. (There is no page_size anymore; nothing paginates server-side.)

Timestamp and date columns come back as Python datetime / date objects — pyarrow decodes the real Parquet types; nothing is an ISO string to parse. The timeline’s ts is an int of epoch milliseconds UTC.

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
ForbiddenError 403 (key disabled, or history window)
NotFoundError 404
RateLimitError 429 (rate limit)
QuotaExceededError 429 (monthly quota; carries .reset 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 quota won’t clear by retrying.

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-04-01", date_to="2026-04-07")
except QuotaExceededError as e:
print("quota exhausted:", e)

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(), 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.