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.
Reference data and discovery
Section titled “Reference data and discovery”| 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"])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-05-01", date_to="2026-06-30", operator="FANDUEL",): ...
# Materialize a listrows = 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 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")
# Explicit ids instead of a date range — e.g. the games a preview said fitrows = 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.
Filters run locally
Section titled “Filters run locally”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 and cost
Section titled “Usage and cost”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 gameprint(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.
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.
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 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)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() (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 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.