BlogRacing and each way

Horse racing API in Python: Discord and Telegram

racing-board is a horse racing API Python script that runs on the standard library alone: it reads one of OddsRelay's racing boards and posts the next races' prices to a Discord channel or a Telegram chat, only when the board has changed.

It prints a line per runner and comes as two files: main.py, below in full, and the api.py helper. We tested it on the OpenAPI file's example replies, then tried it against the API with --dry-run. It is for a racing service or community that posts prices to its members, and it posts prices and terms, never a selection.

What lands in the channel

Fed the OpenAPI file's Best odds guaranteed (BOG) example and the each-way sample from the racing data explainer, the script prints this:

Output · example

Ayr 13:30 BST
Bassrah               4.0  ladbrokes, betfred  lay 4.3 (17)

Newmarket 14:50 BST
Harbour Lights        9.5  paddy_power 3pl 1/5  lay 10.0 (48)

Each race opens with its course and start time, in UK time. Each runner line gives the best back price on the board and the bookmakers offering it, then the lowest win lay across Betfair, Smarkets, Matchbook and BETDAQ, with the liquidity available at that lay in brackets. On the each-way and Extra place boards each bookmaker's terms follow its name: 3pl 1/5 is three places at a fifth of the odds, read from the offer's places and place_fraction.

Discord gets the table inside a code block and Telegram inside a <pre> block, so the columns stay aligned in a monospaced font.

How do you read a horse racing API from Python?

To read OddsRelay's horse racing odds from Python, make one GET call to a racing board, /v2/odds/each-way, /v2/odds/extra-place or /v2/odds/bog, with sports=horse_racing, a start-time window and format=bare. Those are the each-way, Extra place and BOG boards of the horse racing odds API, and the script takes one of them with --board.

The HTTP plumbing lives in api.py, the helper the Python oddsmatcher tutorial prints line by line. It sends the key as a Bearer header, asks for gzip and unzips the reply, waits out a 429, and stops on any other error with the API's error code. Put a copy in the same folder as main.py. Both files sit in the racing-board folder of our public examples repo, with tests that run on the contract's example replies.

commenceTimeFrom and commenceTimeTo set the window as ISO-8601 times in UTC, and the script fills them from --hours. The window is never priced, so a wide one costs what a narrow one does. format=bare returns the list of races without the envelope around it, which matters once the script starts saving ETags.

The racing boards share one event shape. A race carries venue in place of teams, and each runner is an outcome in markets[].outcomes[]. The lay side differs. On each-way and extra-place, lay is an object holding win and place arrays. On bog it is a plain array of win lays. The script reads the win lays from either.

The script in full

main.py · example

"""Post a price table for the next races on a racing board to a Discord channel or a Telegram chat.

For each race: every runner's best back price, the bookmakers offering it (with their each-way terms on
the each-way and extra-place boards) and the lowest exchange lay for the win. Prices only, no advice.
The last post's ETag goes back as If-None-Match, so an unchanged board is a free 304 and nothing is sent. A board
that changed only where the table does not look (a place lay, a price below the best) posts nothing either.

Run: ODDSRELAY_KEY=<your key> python3 main.py --dry-run
"""
from __future__ import annotations

import argparse
import hashlib
import html
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from fractions import Fraction
from http.client import HTTPException
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from api import afford, get

try:
    from zoneinfo import ZoneInfo
    UK = ZoneInfo("Europe/London")
except KeyError:  # no time zone data on this machine (Windows without the tzdata package): show UTC
    UK = timezone.utc
MAX_CHARS = 1990  # Discord's limit is 2000 characters with the code fence, Telegram's 4096: one cut fits both


def terms(offer: dict) -> str:
    """An offer's each-way terms, such as '3pl 1/5', or '' on a board without them."""
    if not offer.get("places") or not offer.get("place_fraction"):
        return ""
    return f"{offer['places']}pl {Fraction(offer['place_fraction']).limit_denominator(10)}"


def race(event: dict, runners: int) -> str:
    """One race: a heading, then a line per runner with a priced back offer, shortest price first."""
    start = datetime.fromisoformat(event["commence_time"].replace("Z", "+00:00")).astimezone(UK)
    field: dict[str, dict] = {}
    for outcome in (o for market in event["markets"] for o in market["outcomes"]):
        # One line per runner: names that differ only in case or punctuation are folded together.
        runner = field.setdefault(re.sub(r"[^a-z0-9]", "", outcome["name"].lower()),
                                  {"name": outcome["name"], "back": [], "lay": []})
        if runner["name"].isupper():
            runner["name"] = outcome["name"]
        lay = outcome.get("lay") or []
        runner["back"] += [b for b in outcome["back"] if b.get("price")]
        runner["lay"] += [o for o in (lay.get("win") or [] if isinstance(lay, dict) else lay) if o.get("price")]
    rows = []
    for runner in (r for r in field.values() if r["back"]):
        best = max(b["price"] for b in runner["back"])
        books = list(dict.fromkeys(f"{b['bookmaker']} {terms(b)}".rstrip()
                                   for b in runner["back"] if b["price"] == best))
        lay = min(runner["lay"], key=lambda o: o["price"], default=None)
        rows.append((best, f"{runner['name'][:18]:<18} {best:>6}  {', '.join(books[:2])}"
                     + (f" +{len(books) - 2}" if len(books) > 2 else "")
                     + (f"  lay {lay['price']} ({lay.get('available') or 0:.0f})" if lay else "")))
    return "\n".join([f"{event.get('venue') or event['event_id']} {start:%H:%M %Z}"]
                     + [line for _, line in sorted(rows)[:runners]])


def message(events: list[dict], runners: int) -> str:
    """Every race in start order, cut before the first one that would pass MAX_CHARS."""
    timed = sorted((e for e in events if e.get("commence_time")), key=lambda e: e["commence_time"])
    blocks = [race(e, runners) for e in timed]
    text = ""
    for n, block in enumerate(blocks):
        more = f"(+{len(blocks) - n} more races: narrow --hours or --bookmakers)"
        if len(text) + len(block) + 2 + len(more) > MAX_CHARS:  # 2: the blank line after the block
            return text + more
        text += block + "\n\n"
    return text.rstrip()


def send(text: str) -> None:
    """Post to Discord (DISCORD_WEBHOOK_URL) or Telegram (TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)."""
    hook, bot, chat = (os.environ.get(name, "").strip()
                       for name in ("DISCORD_WEBHOOK_URL", "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"))
    if hook:  # Discord asks for a User-Agent in this form; parse [] stops any @mention pinging the channel
        url, headers = hook, {"User-Agent": "DiscordBot (https://oddsrelay.io, 1)"}
        body = {"content": f"```\n{text}\n```", "allowed_mentions": {"parse": []}}
    elif bot and chat:
        url, headers = f"https://api.telegram.org/bot{bot}/sendMessage", {}
        body = {"chat_id": chat, "parse_mode": "HTML", "text": f"<pre>{html.escape(text, quote=False)}</pre>"}
    else:
        sys.exit("set DISCORD_WEBHOOK_URL, or TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID, or pass --dry-run")
    try:  # no message names the URL: it carries the webhook's or the bot's token
        req = Request(url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json", **headers})
        urlopen(req, timeout=30).close()
    except HTTPError as err:
        sys.exit(f"the post was refused: HTTP {err.code} {err.read()[:200].decode(errors='replace')}")
    except URLError as err:
        sys.exit(f"could not post: {err.reason}")
    except (ValueError, HTTPException) as err:  # a malformed URL or token: Python's own message would quote it
        sys.exit(f"could not post ({type(err).__name__}): check DISCORD_WEBHOOK_URL, or TELEGRAM_BOT_TOKEN")


def main() -> None:
    ap = argparse.ArgumentParser(description="Post the next races' prices to Discord or Telegram.")
    ap.add_argument("--board", choices=["each-way", "extra-place", "bog"], default="each-way")
    ap.add_argument("--hours", type=float, default=1, help="races starting within this many hours")
    ap.add_argument("--bookmakers", help="comma-separated bookmaker keys (all of them if left out)")
    ap.add_argument("--runners", type=int, default=10, help="most runners shown per race")
    ap.add_argument("--max-tokens", type=int, help="spend up to this many tokens without asking")
    ap.add_argument("--state", default=".racing-board-etag", help="file that keeps the last post's ETag and digest")
    ap.add_argument("--dry-run", action="store_true", help="print the table instead of posting it")
    args = ap.parse_args()
    now = datetime.now(timezone.utc).replace(microsecond=0)
    path, params = f"/v2/odds/{args.board}", {
        "sports": "horse_racing", "bookmakers": args.bookmakers, "format": "bare",  # bare: the ETag follows the events
        "commenceTimeFrom": now.isoformat(), "commenceTimeTo": (now + timedelta(hours=args.hours)).isoformat()}
    if not afford(get(path, {**params, "quote": "true"})[2], args.max_tokens):
        sys.exit("not fetched: pass --max-tokens to allow this price")
    state = Path(args.state)
    saved = state.read_text().splitlines() if state.exists() and not args.dry_run else []  # a dry run always prints
    etag, last = (saved + ["", ""])[:2]  # the last post's ETag, then a digest of its text
    status, headers, events = get(path, params, {"If-None-Match": etag} if etag else None)
    if status == 304 or not events:
        print("nothing sent: " + ("unchanged since the last post" if status == 304 else "no races in the window"))
        return
    text = message(events, args.runners)
    if args.dry_run:
        print(text)
        return
    digest = hashlib.sha256(text.encode()).hexdigest()
    if digest == last:  # the board moved where the table does not look: keep the new ETag, post nothing
        print("nothing sent: the table is the same as the last post")
    else:
        send(text)
    state.write_text(f"{headers.get('ETag') or ''}\n{digest}")


if __name__ == "__main__":
    main()

Run it with --dry-run first. It prints the table and posts nothing, and it ignores any saved ETag, so it always shows the whole board:

Run it · example

export ODDSRELAY_KEY="your API key"
python3 main.py --dry-run --hours 3
python3 main.py --board bog --bookmakers bet365,betfred --dry-run

One line per runner

race() turns one event into its block. Back offers arrive best price first, but the script takes the maximum anyway, then lists every bookmaker at that price: two by name, and +N for the rest. On the BOG board the offers carry no terms, so the names stand alone.

terms() reads each offer's place terms. place_fraction arrives as a decimal, 0.2 for a fifth, and Fraction(...).limit_denominator(10) turns it back into the 1/5 a racing reader expects.

The lay column is the lowest priced win lay among the runner's offers, with its available liquidity rounded to a whole number. A high lay with little behind it is a thin exchange market, and the bracket lets your members see that at a glance.

Each runner is keyed on the letters and digits of its name, so one horse prints as one line whatever the case or punctuation. Runners print shortest price first, like a betting show, and --runners caps how many each race shows.

Post only when the board has changed

A scheduled script must not post the table it posted last time. Two checks stop it: a conditional request and a digest of the last post. Every data reply carries an ETag, a label for its body. The script saves it in a small file, --state, and the next run sends it back as If-None-Match. If nothing has changed, the API answers 304 Not Modified with no body, the call costs no tokens, and nothing is posted.

That is why the call asks for format=bare. In the default envelope, meta carries the time of each new version of the board, so the label would move even when none of your races did. With the bare list it moves only when your races do. The polling cheaply guide works through the same reasoning.

On a whole racing board, though, the label rarely holds from one run to the next. It covers the whole board, not only the table, so any price or liquidity change moves it, a place lay or a price below the best included. So the state file also keeps a digest of the last post's text, and a table that reads the same as the last one is not sent again. That digest does most of the work. A run it stops still pays for the board. A run that ends in a 304 is the bonus.

Two other paths cost nothing. A window with no races returns an empty list, which is free, and the script says so and stops. And before any data call, a first call with quote=true prices the request without spending. An unattended run refuses any price above --max-tokens, so set that flag once you have seen the quote.

On the racing boards a call costs the same whatever you filter, so --bookmakers trims the table rather than the price. It still helps. A narrower board changes less often, so more runs end in a free 304. Filters and tokens sets out how each board is priced.

Discord and Telegram want different requests

Both take one JSON POST, and send() builds whichever one the environment names.

  • Discord. Create a webhook in the server's Integrations settings and put its URL in DISCORD_WEBHOOK_URL. The body's content holds up to 2,000 characters, and the script wraps the table in a code fence. allowed_mentions with an empty parse list stops any mention in the text from pinging the channel. Discord asks HTTP clients for a User-Agent in the form DiscordBot ($url, $versionNumber) and may block a request without one, so the script sends that in place of Python's default.
  • Telegram. Create a bot with @BotFather and put its token in TELEGRAM_BOT_TOKEN. To post in a channel, the bot must be an administrator there with the right to post messages. TELEGRAM_CHAT_ID takes the channel's @username or its numeric id. The script calls sendMessage with parse_mode set to HTML and the table in a <pre> block, after escaping &, < and >, which Telegram's HTML mode needs written as entities. A message may run to 4,096 characters.
  • One cut for both. The script stops adding races once the next one would take the message past 1,990 characters, so with its fence it fits Discord's 2,000 and sits well inside Telegram's limit. It ends with a line counting the races it left out. If that line appears, narrow --hours or --bookmakers.

The webhook URL and the bot token are secrets: either one lets anyone post as you. The script reads both from the environment only, and none of its own messages names them, even when one is malformed. A refused post is reported by its status code and the platform's reply, never by the address it went to.

What the table leaves to your code

The table holds prices, terms and liquidity, and nothing worked out from them. Whatever your members see beyond that is your product's job.

  • Each-way value. The table sorts on the win price, and the best win price is not always the best each-way offer. A bookmaker paying a quarter of the odds at a slightly shorter price can have the better place part, worked as 1 + (price − 1) × place_fraction, as the racing explainer above shows. Print it beside each offer if your members compare terms.
  • Extra places. On the Extra place board, an extra place is a bookmaker's places above the places of the exchange's place market, which sits in each outcome's lay.place array. Each-way and extra-place odds data, explained walks through the comparison.
  • Starting prices. The BOG board pairs win prices only, and the feed carries no starting prices. A line comparing the price taken with the starting price needs a results source of your own. Best odds guaranteed, explained covers what the board does hold.
  • Fractional odds. The racing boards serve decimal, or American with oddsFormat=american. If your channel prefers 5/2 to 3.5, the fraction is yours to compute.
  • Display names. The table prints venue keys such as paddy_power. GET /v2/bookmakers maps each key to its name, and it costs no tokens.

Try it on a wide window first

Run --dry-run with an --hours window wide enough to reach the next meeting, read what the board holds, then set one destination and hand the command to your scheduler. The matched-board filters guide lists every filter the racing boards take.

Sources, checked 11 October 2026

  1. Discord Developer Docs: Execute Webhook (opens in a new tab)
  2. Discord Developer Docs: API reference (User-Agent) (opens in a new tab)
  3. Discord Developer Docs: Allowed mentions (opens in a new tab)
  4. Telegram Bot API: sendMessage and formatting options (opens in a new tab)

Related posts

18+ · OddsRelay supplies odds data only and takes no bets. Please gamble responsibly.