A FastAPI service that sits in front of the PuntersEdge Australian racing and sports odds API and gives your own frontend a small, cached, stable contract to call.
It is the reference answer to "I have an odds API key, how do I build a backend on
it" — one shared HTTP connection pool, a TTL cache with single-flight so a hundred
concurrent users cost one upstream call, the upstream key held server-side where the
browser can never see it, and RFC 9457 problem+json errors that distinguish "come
back in 37 seconds" from "there is no point coming back". Racing data is Australian
and New Zealand thoroughbred, harness and greyhound; sports are AFL, NRL, NBA and the
rest of the keys listed below.
It runs with no API key at all by proxying the keyless PuntersEdge demo endpoints, so you can start it and see real Australian race prices before deciding whether to register.
git clone https://github.com/Propertyscout001/racing-data-fastapi
cd racing-data-fastapi
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --port 8000Then, in another terminal:
curl -s 'localhost:8000/races/next?country=AU&limit=2'No key, no signup, no config file. Open http://127.0.0.1:8000/ for a small HTML board rendered from the same service, and http://127.0.0.1:8000/docs for this service's own OpenAPI documentation.
Docker, if you prefer:
docker compose up --builddocs/output.txt is generated end to end by tools/capture_output.sh, which starts
and stops every server it needs — keyless, keyed, the error-path stub and the
container. Regenerate it with:
tools/capture_output.sh > docs/output.txtIt captures what it can and says what it cannot: the keyed section is skipped with a
note when PE_API_KEY is unset, and the container section is skipped with a note when
no Docker daemon answers. The versions that produced the committed file are pinned in
requirements-lock.txt.
racing-data-fastapi -- real captured output
Generated by tools/capture_output.sh (this whole file, top to bottom)
Captured: 2026-09-15 00:18:27Z UTC
Host: Darwin 24.6.0, Python 3.9.6, uvicorn 127.0.0.1:8000, upstream https://api.puntersedge.online/v1
Two things in that file will not reproduce byte for byte, and it is worth knowing which before you compare:
- the races are whatever was next to jump at capture time;
- the keyless demo sample quotes three bookmakers per runner and rotates which
three between calls — observed changing across consecutive requests. So the
bookmaker rows move even when the race does not. Best-price ties break
alphabetically by bookmaker key (see
app/normalise.py), which at least makesbest_bookmakerstable for a given set of prices.
From the keyless run:
$ curl -s 'http://127.0.0.1:8000/races/next?country=AU&limit=2'
{
"source": {
"mode": "demo",
"cached": false,
"cache_age_seconds": 0.0,
"upstream_path": "/demo/racing/next-to-go",
"credits_charged": 0,
"note": "Free sandbox sample (truncated). Get a free API key for full data: https://puntersedge.online/api?utm_source=demo_api&utm_medium=sandbox"
},
"count": 2,
"races": [
{
"race_id": "e79e50b1-bc25-4d1d-976c-6b34d0b6426c",
"venue": "Angle Park",
"race_number": 1,
"category": "greyhound",
"country": "AU",
"start_time": "2026-09-15T01:38:00Z",
"runners": [
{
"name": "Alia Rose",
"number": 1,
"best_price": 14.0,
"best_bookmaker": "ladbrokes_au",
"prices": [
{
"bookmaker": "ladbrokes_au",
"win_price": 14.0,
"place_price": null,
"age_seconds": null,
"source_url": "https://www.ladbrokes.com.au/racing/angle-park/f9f11182-6bc7-4b23-b9b2-bc4af444f325"
},
{
"bookmaker": "neds",
"win_price": 14.0,
"place_price": null,
"age_seconds": null,
"source_url": "https://www.neds.com.au/racing/angle-park/f9f11182-6bc7-4b23-b9b2-bc4af444f325"
},
{
"bookmaker": "playup",
"win_price": 14.0,
"place_price": null,
"age_seconds": null,
"source_url": "https://www.playup.com.au/betting/racing/race/236947"
}
[... elided: the rest of this race, and the second race ...]
That first runner is also the tie-break in action: ladbrokes_au, neds and playup
all quote 14.0, and best_bookmaker is the alphabetically first of them rather than
whichever one upstream happened to list first.
place_price and age_seconds are null here because the keyless demo sample omits
them. With a key they are populated.
Set PE_API_KEY and the same routes serve the full feed instead of the truncated
sample. Measured on one race during this capture — Angle Park R1, greyhound,
e79e50b1-bc25-4d1d-976c-6b34d0b6426c, 2026-09-15 00:18 UTC:
| keyless demo | with a key | |
|---|---|---|
| runners in that race | 5 | 6 |
| bookmakers per runner | 3 | 14 |
place_price, age_seconds |
null |
populated |
Both halves are in docs/output.txt, not just asserted here: the keyed capture prints
one runner complete — every bookmaker it carries, nothing removed — and then states
the runner and per-runner bookmaker counts for the rest of the race.
Treat 14 as a reading, not a constant. The same race read 11 bookmakers per runner an
hour earlier in the same build: books open their fixed-odds markets at different times,
so the panel fills as the jump approaches. Upstream's age_seconds and refresh_tier
per bookmaker are how you tell a thin panel from a stale one.
export PE_API_KEY=your_key_here
uvicorn app.main:app --port 8000cp .env.example .env
$EDITOR .env # set PE_API_KEY=...
uvicorn app.main:app --port 8000app/config.py loads .env at import, before any setting is read — the settings are
dataclass field defaults calling os.getenv, which evaluate once at class-definition
time, so a loader that runs in a startup hook would be too late to do anything. No
--env-file flag and no extra step is needed. A real environment variable always wins
over the file.
If the file exists but PE_API_KEY in it is empty, startup says so instead of quietly
serving demo data:
INFO proxy mode=demo upstream=https://api.puntersedge.online/v1 ttl_races=20s
INFO proxy loaded /path/to/racing-data-fastapi/.env
WARNING proxy .env was loaded but PE_API_KEY is empty -- running KEYLESS on the demo
endpoints. Put a key in that file (PE_API_KEY=...) to serve the full feed.
docker compose up --builddocker-compose.yml passes .env through with env_file (marked required: false,
so no .env is not an error), and an explicit PE_API_KEY in your shell overrides it.
All three cases were run: no key anywhere gives mode=demo, .env alone is picked up,
and a shell variable wins over .env.
PART 4 of docs/output.txt is the container being built, reported healthy by its own
HEALTHCHECK, running as uid 10001, and answering a request. Worth saying why that
section exists: the image did not build the first time it was run. It created its
unprivileged user with useradd proxy, and Debian already ships a system account called
proxy, so useradd exited 9 and the build failed — a bug that had been sitting in an
"unverified" Dockerfile. The user is appuser now.
A free key is 1,500 credits a month with no credit card: https://puntersedge.online/api?utm_source=racing-data-fastapi&utm_medium=readme
What each route costs upstream, per uncached call:
| Route | Upstream endpoint | Credits | Keyless? |
|---|---|---|---|
GET /health |
/v1/demo/racing/next-to-go |
0 | yes |
GET /races/next |
/v1/racing/next-to-go |
2 | yes, via /v1/demo/racing/next-to-go |
GET /races/{race_id}/prices |
(reads the /races/next snapshot) |
0 | yes |
GET /best-odds/{sport} |
/v1/best-odds/{sport_key} |
3 | yes, via /v1/demo/best-odds |
GET /races/{race_id}/history |
/v1/racing/price-history |
5 | no, needs a key |
source.credits_charged on every response tells you what that specific request cost,
which is 0 on a cache hit.
/v1/racing/next-to-go costs 2 credits. Proxy it one-to-one and 750 page loads spends
a free month — about 25 a day. Put a 20 second TTL in front of it and upstream cost
stops tracking your traffic and starts tracking the clock: 3 calls a minute, whether
one person is watching or ten thousand.
The TTL alone is not enough, because on a cold key every concurrent request checks the
cache before any of them has finished filling it. One asyncio.Lock per key collapses
that stampede. Measured against the live API with a real key:
$ python3 tools/demo_single_flight.py 25 # keyed: a cache key nothing has asked for
mode keyed
url /races/next?country=AU&limit=16
concurrent requests 25
wall time 427 ms
200 responses 25
credits charged 2 (sum of source.credits_charged across all 25)
misses 4 -> 5 (+1)
coalesced 0 -> 24 (+24)
hits 2 -> 2 (+0)
upstream_calls 4 -> 5 (+1)
credits_spent 9 -> 11 (+2)
Twenty-five simultaneous requests, one upstream call, two credits.
Getting a genuinely cold key is mode-dependent, and tools/demo_single_flight.py
checks the mode before it fires. In keyed mode the cache key contains the query
parameters, so a limit nothing has asked for is cold by construction. In keyless
mode /v1/demo/racing/next-to-go takes no parameters at all, every request shares the
single key races:demo, and a second run inside the TTL is all hits and proves
nothing — so the tool waits for that key to expire first and says that it is doing so.
Two upstream shapes, one contract. The keyless demo endpoints return an envelope,
{"demo": true, "note": ..., "races": [...]}. The keyed endpoints return a bare
array. Anything supporting both has to handle both, and app/normalise.py does it
once so no route and no browser ever has to.
country=AU cuts both ways, and the second cut is the one that hurts.
/v1/racing/next-to-go serves every country it has: measured 2026-09-15 at 00:00 UTC,
an unfiltered categories=horse call returned 55 races — 29 AU, 20 GB, 5 US, 1 CA.
Omit the filter and those arrive mixed into whatever your UI is labelling "Australian
racing". But the upstream sets country from the confirmed meeting, so a race whose
meeting has not been confirmed yet has country: null and the same filter throws it
away too — upstream's own parameter documentation puts that at 58.8% of horse races in
a measured window. country=AU alone is therefore not "the Australian card", it is the
confirmed slice of it.
So this service sends include_unresolved=true by default and exposes it as a query
parameter. Honest caveat: in the window this was built (an Australian morning) zero
of the 29 AU horse races were unresolved, so the flag changed nothing I could measure.
It is on by default precisely because the window where it matters is the one you cannot
detect from the response — a short card looks exactly like a quiet day.
One httpx.AsyncClient for the process lifetime. Built in the lifespan handler,
never per request. Measured from this machine on 2026-09-15 with
tools/measure_connection_reuse.py, over 5 cold and 8 warm calls:
cold (new client per call, n=5): median 201.1 ms min 181.7 max 220.4
warm (reused connection, n=8): median 50.6 ms min 39.9 max 58.4
reuse saves a median of 150.5 ms per call from here.
Your absolute numbers depend on where you are relative to the origin; the ratio is the
part that transfers. Accept-Encoding: gzip is set on the same client —
tools/measure_gzip.py measured the demo next-to-go payload at 7,025 bytes identity
against 1,460 on the wire, 4.81x, on 2026-09-15. Quote the ratio rather than the
byte counts: the demo sample varies per call, so the absolute figures are stale by the
next request (4.7–4.8x has held across runs).
The key never leaves the server. It is read from the environment in
app/config.py, attached to outbound requests in app/upstream.py, and appears in no
response body, no log line and no OpenAPI document. Callers of this service
authenticate to it however you like, or not at all — they have no PuntersEdge key and
need none. app/main.py's strip_client_key middleware deletes inbound X-API-Key,
Authorization and X-RapidAPI-Key headers at the edge, so no future handler can
start honouring a caller-supplied key and quietly turn this proxy into a credential
relay. The bundled HTML page at / makes the boundary visible from the other side:
open the network tab and the only host it talks to is your own.
402 and 429 are not the same failure. Both mean the upstream said no, and collapsing them into a generic 502 destroys the only information a caller can act on.
| Upstream | This service returns | Retry-After |
retryable |
|---|---|---|---|
| 402 credits exhausted | 402 |
not sent | false |
| 429 rate limited | 429 |
forwarded from upstream | true |
| 401 / 403 | 502 |
not sent | false |
| 5xx | 502 |
not sent | true |
| timeout | 504 |
not sent | true |
401 becomes 502 deliberately: the caller has no key to fix, so returning 401 to them would be a lie about whose problem it is. Reproduce the whole table with no upstream account using the bundled stub, which is stdlib-only:
python3 tools/stub_upstream.py 402 &
PE_API_KEY=not-a-real-key PE_UPSTREAM_BASE=http://127.0.0.1:8099/v1 \
uvicorn app.main:app --port 8000
curl -i 'localhost:8000/races/next?limit=2'Nothing is retried automatically, and nothing pretends to be fresh. A silent retry
on a price endpoint can return a quote recorded before a move. The same rule applies to
the cache: /races/{race_id}/prices answers out of the snapshot /races/next already
paid for, and every entry in that index carries the time it was stored, back-dated by
the age of the snapshot it came from. Past CACHE_TTL_RACES it is a miss and the
service goes back upstream. An index without that timestamp reports
cache_age_seconds: 0.0 over an hour-old price, which is the same failure as a silent
retry wearing better clothes.
Failed fetches are not cached, so one bad minute upstream does not become twenty
seconds of stored error for every caller. /health reports upstream_calls and
upstream_errors separately, and misses == upstream_calls + upstream_errors.
Unknown sport keys are refused locally — for the answer, not the cost. There is no
horse-racing sport key; racing lives under /races/*. Upstream documents that an
unknown sport_key returns 404 and costs nothing, so forwarding a typo is free. The
local allowlist buys a 400 that names the twenty valid keys, one round trip sooner
than a bare 404 that names none. The price of that is a local snapshot going stale, so
PE_EXTRA_SPORT_KEYS=padel,darts adds keys at runtime when PuntersEdge adds a sport.
app/
config.py env settings and .env loading, the only place the key is read
upstream.py the single AsyncClient, and the status mapping table
cache.py TTL + single-flight, sweep-on-use eviction, credit arithmetic
normalise.py demo envelope vs bare array, folded into one shape
models.py response models -- narrower than upstream on purpose
routers/ races, best_odds, health
static/ the HTML board served at /
tools/
stub_upstream.py a failing upstream, for the error paths
demo_single_flight.py proves N concurrent = 1 upstream call
measure_connection_reuse.py reproduces the latency numbers above
measure_gzip.py reproduces the compression ratio above
capture_output.sh regenerates ALL of docs/output.txt
docs/
output.txt the real captured session, generated by capture_output.sh
openapi.json this service's own spec
screenshot.png the HTML board at /
demo-page.html a self-contained render of that board -- the page script is
stripped, so it calls nothing when opened (clone and open it;
GitHub serves .html in docs/ as source, not as a page)
logo-*.svg light and dark header art
requirements-lock.txt at the top level pins the versions docs/output.txt was
captured on.
- Not a general proxy. Five routes over four upstream endpoints. PuntersEdge publishes 60 paths; results, movers, acceptances, jockey and trainer stats, arbitrage and the CSV exports are all absent. Add them by copying a router.
- In-process cache only. A
dictin one process. Run two replicas and you get two caches and twice the upstream calls. For a real deployment put Redis behind theTTLCacheinterface — the single-flight lock would need to become a distributed lock too. - No authentication on this service. Anyone who can reach it can spend your credits. Put it behind your own auth, or on a private network, before exposing it.
- No memory bound, only sweep-on-use eviction.
TTLCache.sweep()runs at the top of everyget_or_fetchand drops expired entries plus any lock nobody holds; the race index is capped at 2,000 entries, oldest evicted first. Nothing runs on a timer, so an idle process holds whatever it last had until the next request. - The keyless demo tier allows 30 requests/minute per IP.
/healthprobes the upstream, so an uncached probe would make liveness-probe frequency set upstream request volume — a 5s probe is 12/min of that budget, and tripping the limit makes the service report itselfdegradedfor a self-inflicted 429. The probe result is therefore cached for 10 seconds (upstream_probe_cachedandupstream_probe_age_secondssay so on every response). I still tripped the limit twice by hand while testing this repo. - Demo mode ignores your filters upstream.
/v1/demo/racing/next-to-gotakes no parameters and returns a fixed handful of Australian races — greyhound races throughout this build.country,limitandcategoryare applied locally to whatever that sample contained, so a demo-mode request for harness races legitimately returns nothing. The HTML board says this on the page rather than showing a bare "no races matched". credits_remainingis usuallynull./v1/usagereturned HTTP 500 for the key used during this build while every data endpoint answered normally, so the field is best-effort and cannot turn the health check red. It may populate with other keys.- Price history needs a key and a finished race.
/v1/racing/price-historyreturns 404 for a race that has not jumped — history is written once the market closes. Verified 2026-09-15 across greyhound, harness and thoroughbred races. /races/{race_id}/pricesonly covers currently quoted races, because it reads the same snapshot/races/nextfetched. Once a race jumps it leaves that window; use/races/{race_id}/history.- No tests. The build machine had no pytest.
tools/holds runnable checks instead, which is not the same thing.requirements-lock.txtpins the exact versions the capture was taken on, because there is no test suite to catch the morning a new FastAPI or Pydantic major breaks the one documented command. - The 402 path is proven against a stub, not production. Triggering a real 402 means
exhausting a credit allowance.
tools/stub_upstream.pystands in for it. The 429 path was proven against the live API — the keyless tier's limit is easy to trip by hand, and I tripped it twice. - Bookmaker coverage is whatever the upstream serves, and it changes. This README quotes no coverage count; the live list is at https://puntersedge.online/coverage-report?utm_source=racing-data-fastapi&utm_medium=readme. Betfair and Pinnacle are not included.
PuntersEdge guides:
- Getting started
- API reference
- Australian odds API Python guide
- Betting model data pipeline in Python
Sibling repositories:
- puntersedge-python — the official Python SDK
- puntersedge-node — the Node SDK
- puntersedge-mcp — MCP server for LLM tooling
- puntersedge-examples — standalone single-file Python scripts
- au-racing-odds-dashboard — one stdlib-only file, no framework
afl aflw nrl nrlw nba wnba nfl ncaaf mlb nhl mma tennis_atp
tennis_wta cricket_test cricket_other rugby_union super_league soccer_epl
soccer_other basketball_other
Snapshot taken 2026-09-15 from https://api.puntersedge.online/openapi.json. Racing is
not a sport key. It lives under /races/*, covering Australian thoroughbred, harness
and greyhound racing, and New Zealand thoroughbred and harness.
MIT. See LICENSE.
18+ only. Gambling can be addictive — please gamble responsibly. Gambling Help: 1800 858 858 · https://www.gambleaware.nsw.gov.au This repository is a developer example for reading an odds data feed. It is not betting advice, it places no bets and it holds no bookmaker credentials.
