Skip to content

Channels

Channel Contains Published
odds:v2:snapshot The full catalogue: every race currently tracked, every runner, every book When the upstream catalogue is rebuilt (for example after a restart), not on a timer
odds:v2:delta Only what changed: one race per message, the whole race object as it now stands As soon as any book moves a price

| odds:v2:changes | Only the cells that moved, many races per message, one message per 0.5 s at most. Opt-in, API keys only (see Changes-only channel) | Every 0.5 s while anything moves |

You do not call subscribe. When your connection is accepted, the server subscribes you to the channels your key is entitled to and you get a subscribed event for each. Client-initiated subscribes and publishes are disabled. Handle data on the client-level publication event and use ctx.channel to tell the two apart.

A delta only tells you about the race that changed. To start from a complete picture, fetch the last snapshot from the channel’s history right after connecting:

hist = await client.history("odds:v2:snapshot", limit=1)
state = hist.publications[0].data if hist.publications else None

The snapshot channel keeps one publication (history_size: 1) for 5 minutes (history_ttl: 300s). Snapshots are published when the upstream catalogue is rebuilt, not on a timer, so history can be empty if the last one is older than five minutes.

For a guaranteed-current catalogue, call the snapshot RPC on your connection. The server answers with the catalogue as it stands at that moment, in the same {"events": [...]} shape:

reply = await client.rpc("snapshot", {}) # Python
state = reply.data
const { data: state } = await client.rpc('snapshot', {}); // JavaScript

Recommended order on connect: rpc("snapshot"), and if that fails, fall back to history(odds:v2:snapshot, limit=1). Both return the same structure. An RPC error 1503 snapshot unavailable means the upstream catalogue could not be read right then; retry after a few seconds.

A snapshot is big: about 16.8 MB of JSON on the channel, and more on a busy racing day (it grows with the number of races and books). It is compressed on the wire with permessage-deflate. Allow for that in timeouts and memory.

Every message on either channel has the same shape:

{ "type": "delta", "events": [ { "type": "race", "key": "lismore|R|6|2026-11-09", "...": "..." } ], "ts": 1794180947.26, "sent_ts": 1794180947.31 }

Keep a map of race.key → race and replace the race object whenever it arrives. A delta is not a patch of individual fields; it is the race in full. sent_ts (seconds since epoch) is when the message was published, so now - sent_ts is your end-to-end latency.

A delta repeats the whole race (about 25-50 KB) when one Betfair tick moves, so a subscriber downloads ~100 MB a minute (~22 MB deflated) on a busy day. odds:v2:changes carries only what moved: about 2.8 MB a minute (~0.4 MB deflated), roughly 40x less, measured on the same recorded stream. It is opt-in: nothing changes for existing clients.

Opt in by sending changes: true in the connect data. You are then subscribed to odds:v2:changes instead of snapshot + delta (keeping both would defeat the saving):

client = Client(url, headers={"Authorization": f"Bearer {key}"},
data={"key": key, "changes": True})

Join without the 17 MB snapshot. The snapshot RPC takes optional params:

Params Returns
{"index": true} one small row per race: key, seq, status, venue, number, type, day, start, country, runners, books (~200 KB for ~1,000 races)
{"races": [key, ...]} only those races (keep it to 25 or fewer per call)
{"offset": 0, "limit": 25} a page of races (total tells you how many)
{"prices_only": true} drops ids, silks, tote pools and Betfair ladders: ~55% smaller (0.8 MB deflated for the whole catalogue)

Every reply carries epoch, plus seq and status per race: the point you continue from. Requests for index, or 25 or fewer races, have their own limit (60/min); anything that can return the whole catalogue keeps the 6/min limit. rpc("snapshot", {}) with no params is unchanged.

A changes message:

{"type": "changes", "epoch": "9f2c4e1a7b30", "ts": 1794360156.5, "sent_ts": 1794360156.9,
"races": [
{"key": "bendigo|G|6|2026-11-11", "seq": 41,
"ops": [[["selections", 3, "odds", "betfair", "back"], 9.6],
[["selections", 3, "odds", "betfair", "back_ladder"], [[0, 9.6, 12.1]]],
[["bf_updated"], 1794360156.36]]},
{"key": "bendigo|G|7|2026-11-11", "seq": 12, "ops": [], "status": "jumped"},
{"key": "cranbourne|G|1|2026-11-11", "seq": 1, "race": {"...": "a new race, in full"}, "status": "open"},
{"key": "gone|G|2|2026-11-10", "seq": 9, "removed": true}
]}
  • ops is a list of [path, value] (set) or [path] (delete). A path walks the race object exactly as it appears in a delta. Under selections the step is the runner’s key, not a list position. Objects are patched field by field; numbers, strings and lists (such as a Betfair ladder) are replaced whole.
  • order (rare) is the runner keys in their new order, sent only if the order changed.
  • race (instead of ops) means “here is the whole race”: a new race, or one that changed too much to patch. removed: true means the race left the catalogue (finished day).
  • status is sent when it changes: open, suspended, live (in play), closed, or jumped (the advertised start time has passed). It is derived from bf_status, bf_inplay and the start time; a race can change status with no price move, so you may get an entry with "ops": [].
  • seq goes up by exactly 1 per entry for each race. If you see anything else for a race (a gap), do not apply the ops on top of a hole: re-request that race with rpc("snapshot", {"races": [key]}) and continue from the seq it returns. Entries with seq at or below what your snapshot already contained can be skipped. A different epoch means the exporter restarted: start again from index.
  • Applying every ops list to the matching snapshot reproduces the race exactly as delta would have delivered it (byte for byte; this is tested against recorded data). Books we do not redistribute are removed here too.

A complete working subscriber is in client/changes_client.py (about 150 lines, including the decoder). Join, apply, and refetch on a gap:

def apply_race(race, ops, order=None): # the whole decoder
sels = {s["key"]: s for s in race["selections"]}
keys = [s["key"] for s in race["selections"]]
for op in ops:
path = op[0]
if path[0] == "selections":
if len(path) == 2: # a whole runner added / removed
if len(op) == 1: sels.pop(path[1], None); keys = [k for k in keys if k != path[1]]
else:
if path[1] not in sels: keys.append(path[1])
sels[path[1]] = op[1]
continue
target, rest = sels[path[1]], path[2:]
else:
target, rest = race, path
for step in rest[:-1]:
target = target.setdefault(step, {})
target.pop(rest[-1], None) if len(op) == 1 else target.__setitem__(rest[-1], op[1])
race["selections"] = [sels[k] for k in (order or keys)]

The client libraries reconnect automatically with backoff. After any reconnect:

  1. Your server-side subscriptions are restored by the server (positions are recovered where possible).
  2. On odds:v2:changes, re-join from index (or re-request the races you hold) after a reconnect: the channel keeps no history, so a gap in seq is how you find out what you missed.
  3. Re-fetch the snapshot (rpc("snapshot"), or history as a fallback) if you may have missed deltas while disconnected, then carry on applying deltas.

Messages are never written to disk on our side, so there is no replay beyond the most recent snapshot.

Odds compares prices only. It is not a bookmaker, takes no bets and does not tell you what to back. Prices come from licensed Australian bookmakers' public sites and can change at any moment; always check the price with the bookmaker before you bet.

18+ · Chances are you're about to lose. For free and confidential support call 1800 858 858 or visit gamblinghelponline.org.au