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.
Discovery methods
Section titled “Discovery methods”| 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.
Datasets
Section titled “Datasets”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 dictsfor row in bf.closing_lines.iter( league="NBA", date_from="2026-01-01", date_to="2026-04-01", operator="FANDUEL",): ...
# Materialize a listrows = 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 locallygame_rows = bf.market_results.game("MLB_BOS_NYY_20260715", outcome="WON")
# The raw Parquet bytes, verbatimdata = 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.
Filters run locally
Section titled “Filters run locally”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.)
Values are typed
Section titled “Values are typed”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.
Errors and retries
Section titled “Errors and retries”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)Progress hooks
Section titled “Progress hooks”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 Clientfrom 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.