This release is a pre-release and may not be stable for production use.
Betwatch Python SDK
Public /v2 REST + SSE client. 2.0.0b2 on the beta branch — not the
GraphQL 1.x get_races client.
Agents: read AGENTS.md first.
Install
pip install betwatch==2.0.0b2
# or, from the beta branch:
# pip install "betwatch @ git+https://github.com/betwatch/betwatch-sdk-python@beta"
Secrets live in a gitignored fnox.toml, encrypted to this machine's age
key (~/.config/fnox/age.txt). Ciphertext is not committed.
fnox exec -- uv run examples/live_check.py # local → http://127.0.0.1:8888
fnox exec --profile prod -- uv run examples/watch_event.py
Or set BETWATCH_API_KEY yourself. Optional BETWATCH_API_URL (default
https://api-beta.betwatch.com). FNOX_PROFILE=prod selects the hosted API.
Usage
See examples — the same use cases as 1.x (get_races,
get_race_prices, subscriptions), plus firehose.py for every
code with a resumable cursor, and tui.py for a Textual raceday grid.
Discover, then price, then follow
The API is built around one workflow, and so is this client. Find races, ask for their prices, then attach a stream at exactly the position the price read returned.
from betwatch import Betwatch, OddsFrame
with Betwatch() as client:
# 1. Discover. /v2/odds and friends refuse an unscoped read, so start here.
page = client.events.list(sport="thoroughbred", country="au", limit=5)
event = page[0]
print(event.name, event.start_at, event.racing.race_number)
# 2. Price. The snapshot carries a stream cursor captured *before* it read,
# so nothing changes in the gap between pricing and following.
card = client.events.snapshot(event.id)
print(card.best_price(card.entrants[0]))
# 3. Follow. follow() sends that cursor as Last-Event-ID with snapshot=none.
with client.follow(card) as live:
for frame in live:
if isinstance(frame, OddsFrame):
print(frame.data.source.id, frame.data.price)
client.watch(event_id) does all three in one call when you do not need the
snapshot yourself.
snapshot(..., include="history") fills Odds.history with each source's
fluctuations. It is honoured as of contract 1.0.0 and doubles the call's quota
cost, so ask for it only when you use it.
Stream instead of polling
Stream frames are not metered. Polling /v2/odds on a timer is the
expensive way to stay current and the slowest to see a move; bootstrapping once
and following costs nothing beyond that first read. A single filtered
connection covers a whole raceday — client.stream(sport="thoroughbred", country="au") — and a connection per race is the shape to avoid, which your
plan's concurrent-stream cap will enforce with StreamLimitError.
Following a whole scope
One call returns the card, the prices and the cursor to follow them:
snap = client.snapshot(sport="thoroughbred", country="au")
with client.follow(snap) as live:
for frame in live:
...
Every page returns the same stream.cursor, captured before the first page was
read, so paging to the end and then following cannot miss a change to a race you
read earlier. Follow from any page; page the rest with after=snap.next.
Hydration costs roughly half a second per race, so for a large scope take a small first page, attach, and read the rest while already live — you are following in about five seconds instead of fifty, and the cursor guarantees the races you have not read yet still replay.
Paging a collection
Every collection has iter(), which follows next until it stops coming:
for venue in client.venues.iter(country="au"):
print(venue.name)
A cursor belongs to the collection that issued it — a next from /v2/venues
is not valid on /v2/meetings. iter() feeds each cursor back to the endpoint
that produced it, so this cannot go wrong by accident. Cursors are opaque: do
not decode, build, or edit one.
Handling failures
Every failure is an RFC 9457 problem document with a stable code. Branch on
the code (or on the exception type, which is selected from it), never on prose:
import time
from betwatch import QuotaExceededError, RateLimitError
try:
page = client.odds.list(event=event.id)
except RateLimitError as err:
time.sleep(err.retry_after or 1) # short window; worth waiting out
except QuotaExceededError as err:
alert(f"monthly quota spent, resets {err.rate_limit.monthly_reset}")
Ids are opaque, and merges are the reason that matters: a stored id (evt_,
ent_, cmp_, mtg_, ven_) resolves to the surviving record after a merge,
while a derived id (mkt_, out_, odd_) gets a NotFoundError because the
resource genuinely has a new identity. Re-read the event and take the new ids.
QuotaExceededError is deliberately not a subclass of RateLimitError:
one resets in seconds, the other in weeks. The client retries rate_limited
and the 5xx codes for you, and fails fast on everything the docs mark as
non-retryable. Every exception carries code, detail, errors,
request_id, and trace_id — quote the last two to support.
Operations
The client groups operations by resource, which is the Python idiom. The
mapping to the contract's operationIds is one-to-one:
| operationId | SDK |
|---|---|
listEvents / getEvent / getEventSnapshot |
client.events.list / .retrieve / .snapshot |
listEntrants / getEntrant |
client.entrants.list / .retrieve |
getCompetitor |
client.competitors.retrieve |
listOdds / getOdds |
client.odds.list / .retrieve |
listMeetings / getMeeting |
client.meetings.list / .retrieve |
listVenues / getVenue |
client.venues.list / .retrieve |
listSources |
client.sources.list |
streamRacing |
client.stream / .watch / .follow |
Dump to pandas without caring that the backend is msgspec:
import pandas as pd
card = client.events.snapshot(event.id)
df = pd.DataFrame.from_records(card.to_records())
Sync and async clients share one resource tree (AsyncBetwatch).
Reads retry twice by default, driven by the problem code rather than the HTTP
status — the status cannot tell rate_limited from quota_exceeded, and both
are 429. Set max_retries=0 to disable retries or another non-negative value
to change the budget. Stream reconnect is separate: SSE reconnects only after
transport interruption, while HTTP, cursor, server frame, and decode failures
surface immediately.
client.rate_limit holds the budget headers from the last response — both the
per-minute window and the monthly quota, including when the quota resets.
The contract only grows. Unknown response fields (including $schema) are
ignored, unknown SSE frame names are no-ops, and a vocabulary value newer than
this release reads as "unknown" rather than failing to decode.
An event snapshot carries a required server-issued stream continuation. Pass
the complete snapshot to client.follow(card); do not copy its cursor into a
new stream with reconstructed filters.
TUI
examples/tui.py is a Textual demo (not part of the installed package). Left
pane is the raceday list ordered by time-to-jump; right pane is the runner
× bookmaker grid. The selected race follows live /v2/stream.
uv sync
fnox exec -- uv run examples/tui.py
fnox exec -- uv run examples/tui.py --sport harness --country au
1/2/3 switch code, n jumps to the next race, w/p is win/place,
/ filters tracks, ? is help.
Development
uv sync
uv run ruff check
uv run ty check
uv run pytest
The SDK's error codes, budget-header names, and operation coverage are pinned
against a committed copy of the published contract at
tests/contract/openapi.json. When the API ships a new spec:
uv run tests/contract/sync_openapi.py # or: BETWATCH_OPENAPI=/path/to/openapi.json
uv run pytest tests/test_contract_spec.py
A failure there names exactly what moved — a new error code with no retry decision, a budget header nothing parses, an operation with no method.
Measuring the stream
tools/stream_timing.py reports how long /v2/stream takes to bootstrap for a
given filter scope: time to ready, time to the first data frame, the silence
between them, and time to sync. A broad snapshot=full sends nothing for
tens of seconds while the server builds the snapshot, so the silence is the
number worth watching.
fnox exec --profile prod -- uv run tools/stream_timing.py --sport thoroughbred --country au
fnox exec --profile prod -- uv run tools/stream_timing.py --json --require-sync 60
--require-sync exits non-zero if the bootstrap takes longer than that, so it
works as a regression gate.
Live check against a local API:
export BETWATCH_API_KEY=bw_...
export BETWATCH_API_URL=http://localhost:8888
uv run examples/live_check.py run-1
Releasing
Tag the exact version in src/betwatch/__about__.py (v2.0.0b2) and push the
tag. .github/workflows/release.yml builds that commit, checks the tag matches
the package version, and publishes with PyPI Trusted Publishing. Do not bump
versions from CI.
Changelog locally: uv run git-cliff --unreleased.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file betwatch-2.0.0b2.tar.gz.
File metadata
- Download URL: betwatch-2.0.0b2.tar.gz
- Upload date:
- Size: 89.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e12ab7291712c0577ec24955035f804d30258cfcb9925b3e76c80a6cbfe9e8be
|
|
| MD5 |
3a95ee661f1789e6100ed4b9b09e69e2
|
|
| BLAKE2b-256 |
36bb5b1d4b2afd33a063dbc64eba936510247d4960db4e950faa0f86a8859ab0
|
Provenance
The following attestation bundles were made for betwatch-2.0.0b2.tar.gz:
Publisher:
release.yml on betwatch/betwatch-sdk-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
betwatch-2.0.0b2.tar.gz -
Subject digest:
e12ab7291712c0577ec24955035f804d30258cfcb9925b3e76c80a6cbfe9e8be - Sigstore transparency entry: 2617673997
- Sigstore integration time:
-
Permalink:
betwatch/betwatch-sdk-python@603f16d3ab6eb5c05d848d236b063f3e2e944dce -
Branch / Tag:
refs/tags/v2.0.0b2 - Owner: https://github.com/betwatch
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@603f16d3ab6eb5c05d848d236b063f3e2e944dce -
Trigger Event:
push
-
Statement type:
File details
Details for the file betwatch-2.0.0b2-py3-none-any.whl.
File metadata
- Download URL: betwatch-2.0.0b2-py3-none-any.whl
- Upload date:
- Size: 50.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cd1e64420eb47bd0694114eb1a2719612894426bc3c70b0837c4a17d59d29e2f
|
|
| MD5 |
c970c1c86880c37b632c5ed48e1813c9
|
|
| BLAKE2b-256 |
e5aab134e4458c5ac65426b8c5b6714c91c163a25e6eb840b26510c1b2b794ec
|
Provenance
The following attestation bundles were made for betwatch-2.0.0b2-py3-none-any.whl:
Publisher:
release.yml on betwatch/betwatch-sdk-python
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
betwatch-2.0.0b2-py3-none-any.whl -
Subject digest:
cd1e64420eb47bd0694114eb1a2719612894426bc3c70b0837c4a17d59d29e2f - Sigstore transparency entry: 2617674001
- Sigstore integration time:
-
Permalink:
betwatch/betwatch-sdk-python@603f16d3ab6eb5c05d848d236b063f3e2e944dce -
Branch / Tag:
refs/tags/v2.0.0b2 - Owner: https://github.com/betwatch
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@603f16d3ab6eb5c05d848d236b063f3e2e944dce -
Trigger Event:
push
-
Statement type: