Skip to main content

betflux

Python client + CLI for the BetFlux data API — normalized sportsbook odds across US books, queryable for sharp bettors.

Install

pip install betflux            # core client + CLI
pip install "betflux[pandas]"  # add .df() DataFrames

For an isolated CLI installation with automatic updates, use uv:

uv tool install betflux

If your uv tool installation predates 0.3.0, run uv tool upgrade betflux once to get the updater. If your version pin excludes 0.3.0, revise it before upgrading. Use betflux auto-update status to see whether this installation supports automatic updates and whether they are enabled.

On verified Linux and macOS uv tool install betflux installations, BetFlux checks about once a day and installs an eligible stable release before the next command. It preserves the tool's version constraints and package source. uv may also change dependency versions within those constraints; upgrades are not atomic and do not roll back automatically. To disable updates for one process, set BETFLUX_NO_AUTO_UPDATE=1; to manage the saved preference, run betflux auto-update off or betflux auto-update on. Use betflux update to update now, or betflux update --check to check without installing. SDK/project and pip installations, editable developer installs (including bun run install-cli from this repo), temporary uvx runs, and unverifiable tool environments are not modified automatically. Importing or using the Python SDK never triggers an update.

Authenticate

For an existing onboarded account with API access:

betflux login

Open the printed link, enter the terminal code, and approve the new API key. The CLI saves it in your system keychain. CLI commands and Python Client() automatically use the saved login unless you supply an explicit key. Each login uses one key slot; manage keys at betflux.ai/account/api-keys.

On headless machines, use betflux login --no-browser --credential-store file to explicitly choose a protected local file. Storage is scoped to the API URL. For local development, provide both origins:

betflux --base-url http://localhost:8800 login --auth-url http://localhost:5173

betflux logout revokes the saved key and removes it locally. If offline, use betflux logout --local-only to remove it without revoking. Revocation can take up to five minutes to propagate. Neither command changes environment keys.

If saved login is unreadable, unlock the keychain and retry, or reset with betflux logout --local-only before logging in again. Use betflux login --credential-store file to switch to protected file storage. For custom API URLs, supply the same --base-url to both commands. Resetting does not revoke the old server key; revoke it through your account. The CLI warns if a keychain entry could not be removed.

You can still set an API key manually (mint one at betflux.ai/account/api-keys):

export BETFLUX_API_KEY=bfx_live_...

The file model

Every dataset payload is a per-game Parquet file served at GET /v1/games/{game_id}/{dataset} — the server streams typed, compressed bytes (with HTTP Range support) and never filters. The SDK/CLI keep their query interfaces: they discover game ids via /v1/games, download each game's file, and evaluate your filters locally with pyarrow (a core dependency). Quota is metered as rows downloaded, so narrow date ranges and --game fetches are the cheap path — local filters don't reduce spend.

Datasets: closing-lines, market-results (graded closing lines), sportsbook-lines (the full ~190k-row change-only line history per game), game-state-timeline (flat ts/field/value/source observation rows). The MSF datasets work differently, as a snapshot plus a revision feed — see MSF game and league snapshots.

A game that has not settled yet has no final file. Ask for its sportsbook-lines anyway and you get them — see Live games.

CLI

betflux keys check
betflux datasets
betflux games --league NBA --date-from 2026-05-01 --date-to 2026-05-07
betflux get closing-lines --league NBA --date-from 2026-05-01 --date-to 2026-05-07
betflux get closing-lines --game NBA_DET_ORL_20260501 --format jsonl
betflux get market-results --league MLB --date-from 2026-07-01 --date-to 2026-07-07 --outcome WON
betflux get sportsbook-lines --game MLB_BOS_NYY_20260715 --output lines.parquet
betflux get game-state-timeline --game NBA_DET_ORL_20260501
betflux live-board MLB_WAS_DET_20260922 --operator FANDUEL
betflux live-tail MLB_WAS_DET_20260922 --every 5
betflux leagues
betflux teams --league NBA
betflux players --league NBA --team-id <team-uuid>

Game ids are readable — LEAGUE_AWAY_HOME_YYYYMMDD (ET date; a _2 suffix marks doubleheaders), case-insensitive, discoverable via betflux games. The game record also carries the schedule, both teams, the score, the venue and game-time weather — betflux games --id <id> --format record shows it all; the default table is a curated subset (--wide for every column). See the Reference data docs.

closing-lines and market-results accept a date range or --game; game-state-timeline and sportsbook-lines are --game only (sportsbook-lines over a range would debit ~190k quota rows per game). Filters (--operator, --market-type, --team, --player-id, --side, --outcome) run locally after download; a filter the dataset has no column for is rejected up front. --limit N stops fetching once N rows have been yielded. With --format jsonl|csv rows stream as each game's file arrives.

--output PATH (with --game) saves the raw Parquet file for any of these four datasets — no parsing, one summary line with the row count (for MSF datasets it writes the snapshot's rows instead; see below):

wrote lines.parquet — sportsbook-lines for MLB_BOS_NYY_20260715: 190,412 rows, 8,214,567 bytes

Timestamp columns are real Parquet timestamps and render as ISO 8601 in every output format.

Output defaults to a compact table showing a curated column subset (the gold datasets are wide). Widen or reshape it:

  • --wide — every column in the table
  • --columns game_date,operator,side,closing_odds — pick columns
  • --format record — vertical key: value blocks, ideal for one wide row
  • --format json|jsonl|csv — machine formats (always full-fidelity)

betflux keys check validates the key and reports your plan:

key valid (https://api.betflux.ai)
tier: Beta — 120 requests/min
usage: 12,345 rows this month (no row cap on this plan)

Tiers with a configured row cap also print the percentage used and the reset date.

Library

from betflux import Client

with Client() as bf:
    for row in bf.closing_lines.iter(
        league="NBA", date_from="2026-05-01", date_to="2026-06-30",
        operator="FANDUEL",  # local filters: operator/market_type/team/player_id/side/outcome
        max_rows=10_000,     # stop fetching once this many rows yielded; default None = all
    ):
        print(row["market_key"], row["closing_odds"])

    games = bf.games(league="NBA", date_from="2026-05-01", date_to="2026-05-07")
    df = bf.closing_lines.df(league="NBA", date_from="2026-05-01", date_to="2026-05-07")
    game_rows = bf.market_results.game(games[0]["id"], outcome="WON")
    observations = bf.state_timeline(games[0]["id"])  # flat rows; ts is epoch-ms
    raw = bf.sportsbook_lines.raw(games[0]["id"])     # the Parquet bytes (verbatim once settled)

Datasets hang off the client as closing_lines, market_results, sportsbook_lines, and game_state_timeline (or bf.dataset("closing-lines") by public name). Timestamp/date columns come back as Python datetime/date objects (pyarrow decodes the Parquet types), not ISO strings. Filters that name a column the dataset lacks raise ValueError; team matches home or away, player_id matches the market/selection player-id list columns.

Errors are typed (AuthError, PaymentRequiredError, RateLimitError, QuotaExceededError, NetworkError, …); rate limits, 5xx, and transport failures retry automatically with backoff that honors Retry-After (capped).

MSF game and league snapshots

The five MSF datasets are game-state, plays, player-stats, team-stats and injuries. The first four take a public game ID; injuries takes a league. Snapshots contain the current rows and a cursor for changes published afterward.

betflux get player-stats --game NFL_IND_KC_20260920 --metric FOOTBALL_RUSHING_YARDS
betflux get plays --game NFL_IND_KC_20260920 --output plays.parquet
betflux get injuries --league NFL --output injuries.csv
betflux live-tail NFL_IND_KC_20260920 --dataset plays --output plays.jsonl
betflux live-tail --dataset injuries --league NFL --from-now --output injuries.jsonl

MSF live-tail writes one JSONL object per complete revision, including revisions with no rows. A replace revision replaces all prior rows; a patch updates or deletes named rows; an append adds plays. The sidecar <output>.cursor.json resumes a managed output file after interruption: re-run the same command. Once a checkpoint exists it wins over --from-now and over the --cursor the output started from; a --cursor from another stream, or ahead of the checkpoint, is refused. Filters are applied locally after download, so quota reflects downloaded rows. Parquet snapshot export retains the published column types even for an empty snapshot or an empty filtered result. Games that started before 2025-01-01 00:00 UTC are listed by betflux games but never published; their MSF datasets answer 404 not-yet-available for good. A snapshot's freshness fields are all null today: the health evidence behind them is not published yet.

with Client() as bf:
    snapshot = bf.plays.snapshot("NFL_IND_KC_20260920")
    print(snapshot.freshness)
    print(bf.plays.freshness("NFL_IND_KC_20260920"))  # metadata only
    for revision in bf.plays.changes(snapshot.cursor, "NFL_IND_KC_20260920"):
        print(revision.metadata["revision"], revision.rows)

    injuries = bf.injuries.snapshot(league="NFL")

Archives preserve older revisions as a baseline plus complete change files. None are published yet, so archives() yields nothing today, and every revision is still read directly with snapshot(at_revision=..., epoch=...). Discovery and revision metadata are free; each Parquet download charges its published row count. Archive reconstruction is explicit and runs in the SDK:

with Client() as bf:
    for archive in bf.plays.archives("NFL_IND_KC_20260920"):
        print(archive.descriptor["first_revision"], archive.descriptor["last_revision"])
        historical = archive.snapshot(at_revision=archive.descriptor["first_revision"])
        print(historical.rows)

    for archive in bf.injuries.archives(league="NFL"):
        final = archive.snapshot()  # last revision in this archive

An archive snapshot downloads the baseline and changes through the requested revision, verifies their hashes and row counts, and checks each reconstructed state against its committed digest. It preserves source observation and coverage metadata; freshness fields are null, since archived revisions carry no freshness evidence. It never silently falls back from a current snapshot or expired cursor to an archive. Resume with a fresh current snapshot after a 410; use the supplied archive discovery link when historical reconstruction is needed. Archive reads obey the same history entitlement as direct historical reads.

Freshness health fields are nullable. Null means the server lacks independent evidence for that timestamp; the published revision's source_observation does not prove when the newest request completed or how far unchanged source observations have advanced.

follow(..., from_now=True) starts at the current snapshot cursor without emitting the baseline. A filtered follower still loads that baseline internally so a later attribute correction can emit a delete for a row leaving the filter. For the same reason, resuming a filtered follower from a cursor (including a filtered live-tail resuming its output) downloads the whole historical snapshot at the cursor's revision, charged at its full row count. Before that download it checks the cursor against the dataset and scope and makes one free changes listing, so a cursor for another stream, a stream reset (409) or an expired cursor (410) fails without a charge. Once retention retires that snapshot, the resume raises the same 410 cursor-expired APIError as an expired cursor, one revision earlier than an unfiltered resume; start again from a fresh snapshot. Between polls follow() waits the poll_after the server advises (every= / --every override it), from its first listing on.

Each game and league an MSF feed covers is published from its first observation. A covered game or league with nothing published yet, usually a game that has not started, answers 404 with problem type not-yet-available (NotFoundError); wait for publication. follow() and live-tail started without a cursor wait for the first publication themselves, checking again every poll interval; follow(on_wait=...) is called with the error and the seconds before each wait, and live-tail prints a waiting: line. Any other 404 still raises.

Agent plugin installation

Run the command for each agent you use, specifying one agent per invocation:

betflux plugin install --claude
betflux plugin install --codex

These register betflux/skills and install betflux@betflux through the agent's own CLI. Claude uses user scope. The selected agent must already be on PATH; installation needs network access but no BetFlux API key. Rerun the command to refresh the repository, update the plugin, and enable it. Manage removal through the agent, and start a new session after installation or updating.

betflux plugin install --cursor provides manual local-plugin instructions and exits nonzero; it does not install anything. The wrapper never writes agent configuration or copies skills.

betflux agent-guide prints the SDK's bundled guide offline, independently of any plugin installation. For direct installation through your agent, see https://github.com/betflux/skills.

Breaking changes (Parquet cutover)

  • iter() lost page_size — there is no server pagination to tune anymore.
  • Client.state_timeline() returns the flat observation rows (list[dict]) instead of the old {..., observations: [...]} envelope.
  • Timestamps in rows are datetime objects, not ISO strings.

Power user: DuckDB straight at the files

The dataset endpoints are plain authenticated Parquet URLs with Range support, so DuckDB can query them directly — predicate pushdown means it reads only the byte ranges it needs:

CREATE SECRET betflux (
    TYPE http,
    EXTRA_HTTP_HEADERS MAP {'Authorization': 'Bearer bfx_live_...'}
);
SELECT operator, market_type, odds, timestamp
FROM read_parquet('https://api.betflux.ai/v1/games/MLB_BOS_NYY_20260715/sportsbook-lines')
WHERE market_type = 'MONEYLINE'
ORDER BY timestamp;

Note: quota is debited for the file's full row count per request, partial read or not.

Live games

A game's sportsbook-lines history only becomes a single final file once the game settles. Until then the API publishes it as an append-only series of Parquet segments plus a current board (one file per operator).

You do not have to care. Every ordinary call works on a live game: the SDK follows the API's pointer, downloads the segments, concatenates them in order and returns the same columns the final file would have.

rows = bf.sportsbook_lines.game("MLB_WAS_DET_20260922")   # just works
raw  = bf.sportsbook_lines.raw("MLB_WAS_DET_20260922")    # Parquet bytes, re-encoded by the SDK
bf.sportsbook_lines.is_live("MLB_WAS_DET_20260922")       # True, if you want to know

The one visible difference: live rows are provisional — the final build may revise them. query_game() reports it as .provisional, and betflux get prints one line to stderr (stdout is byte-identical to a settled game's, so pipes never notice):

provisional: MLB_WAS_DET_20260922 is live; data may be revised by the final build

What it costs: listings are free, and each row of a live segment is charged once per account per month — reading a segment again costs nothing, and the growing open tail costs only the rows it gained since you last read it. So calling game() on a live game again charges only what was published since the last call, though it still downloads every segment each time; for anything that repeats, poll.

Polling, for people who want it

live_segments takes an opaque cursor, lists only what is new since it, and downloads the segments that listing names:

cursor = None                            # or live_cursor(game): start from now, skip the history
while True:
    step = bf.sportsbook_lines.live_segments("MLB_WAS_DET_20260922", cursor)
    cursor = step.cursor                 # "0:0" until the first segment exists
    for row in step.rows:                # also step.table (pyarrow)
        print(row["operator"], row["selection_name"], row["odds"])
    time.sleep(5)                        # listings are edge-cached for 5 s

The listing is free and incremental. Each segment it names is downloaded whole — the open tail on every poll in which it grew — but each row is charged only once per account per month, so a poll costs only the rows it brings in. Polling faster than the ~5 s edge cache just re-downloads the same bytes. A first poll with no cursor backfills — and is charged for — everything published so far; live_cursor() is one free listing that returns the current position, for following from now on.

The cursor is a position, not a timestamp — a slower operator's file can land after a faster one's later-stamped rows, so a timestamp would silently drop them. Two things can go wrong with it, both typed. LiveCursorResetError: the game's live log was rebuilt under the cursor — start again without one; rows already consumed may be superseded. LiveInconsistentError: the listing and a segment body kept disagreeing for the whole bounded retry the SDK runs (the edge cache can serve the two from moments apart) — nothing was returned and the cursor did not move, so just call again.

betflux live-tail <game> is that loop as a command (--every N, --output FILE, --format jsonl|csv); it stops by itself once the game settles. --output appends, so a rerun should start from the cursor the previous run printed: --cursor SEQ:ROWS resumes there and --from-now skips the history; with neither, the first poll backfills the whole history (charged, unless this account already read it this month).

For "what is quotable right now" rather than history, read the board — one row per selection, with quote_state (live / stale_suspect), last_changed_at and operator_observed_through on top of the usual columns:

board = bf.sportsbook_lines.live_board("MLB_WAS_DET_20260922")              # every operator
board = bf.sportsbook_lines.live_board("MLB_WAS_DET_20260922", "FANDUEL")   # or one

betflux live-board <game> [--operator X] renders it, or saves it with --output board.parquet. Board reads are metered at a weight the API sets (free during Beta); the listing behind them is free.

Live segments in DuckDB

live_urls() hands you the segment URLs, which are ordinary authenticated Parquet URLs with Range support:

urls = bf.sportsbook_lines.live_urls("MLB_WAS_DET_20260922")
CREATE SECRET betflux (
    TYPE http,
    EXTRA_HTTP_HEADERS MAP {'Authorization': 'Bearer bfx_live_...'}
);
SELECT operator, market_type, odds, timestamp
FROM read_parquet(['https://api.betflux.ai/v1/games/.../segments/0?rows=3800',
                   'https://api.betflux.ai/v1/games/.../segments/1?rows=1204'])
ORDER BY timestamp;

Use the URLs as listed, ?rows=N included: it is the row count the listing promised, and a segment that has fallen behind it answers 409 live-segment-behind rather than a short file — list again. Closed segments are immutable (cached 24 h); the highest one is the open tail, rewritten in place as rows are appended. Each row is charged once per account per month, however often it is read.

A game that is neither live nor settled raises NotFoundError from all of these, as it always did. Low-level callers driving Client.get_bytes themselves see the live case as GameLiveError (a NotFoundError subclass) carrying game_id, segments_url and board_url, and a listed segment URL that has fallen behind its listing as LiveSegmentBehindError (seq, rows) — the dataset methods handle both for you.

Metadata

Release files for betflux 0.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for betflux 0.7.0
File Size Uploaded
betflux-0.7.0.tar.gz 123.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for betflux 0.7.0
File Interpreter ABI Platform
betflux-0.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 258.5 kB

Release files / betflux-0.7.0.tar.gz

Download URL betflux-0.7.0.tar.gz
Size 123.8 kB
Tags Source
SHA-256 checksum
How to use checksums
74020cf4da9d114582dcbcd0cf8dbc70801862e5a2e2725c5b6a544b6026b2d3
BLAKE2b-256 checksum
How to use checksums
f89473e73f45b5f8c4d1d32fc623b8976b34bcd0ad5721e44d28a8c3b9dbc1d1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release files / betflux-0.7.0-py3-none-any.whl

Download URL betflux-0.7.0-py3-none-any.whl
Size 134.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
51b5097f741430404039d3bc62f29cfc42907c93ab67f6c861c4f74f4018feb6
BLAKE2b-256 checksum
How to use checksums
78dc1108cbfc07c893260a885a6c944429132ccb491ff49819eaecd80e4713f5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.7.0 This release

2 release files

0.6.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page