# Horse racing API in Python: Discord and Telegram

> Read a horse racing API in Python and post each race's prices, each-way terms and exchange lays to Discord or Telegram, only when the board changes.

Published 12 October 2026

**`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](https://oddsrelay.io/blog/horse-racing-and-each-way-odds-data), the script prints this:

Output · example:

```text
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](https://oddsrelay.io/products/each-way-feed), [Extra place](https://oddsrelay.io/products/extra-place-feed) and [BOG](https://oddsrelay.io/products/bog-feed) boards of the [horse racing odds API](https://oddsrelay.io/sports/horse-racing), and the script takes one of them with `--board`.

The HTTP plumbing lives in `api.py`, the helper the [Python oddsmatcher tutorial](https://oddsrelay.io/blog/odds-api-in-python) 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](https://github.com/oddsrelay/examples/tree/main/racing-board) 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 `ETag`s.

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:

```python
"""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:

```sh
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](https://oddsrelay.io/docs/guides/polling-cheaply) 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](https://oddsrelay.io/docs/guides/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](https://oddsrelay.io/blog/horse-racing-and-each-way-odds-data) 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](https://oddsrelay.io/blog/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](https://oddsrelay.io/docs/guides/matched-board-filters) lists every filter the racing boards take.

## Sources

- [Discord Developer Docs: Execute Webhook](https://docs.discord.com/developers/resources/webhook), checked 11 October 2026
- [Discord Developer Docs: API reference (User-Agent)](https://docs.discord.com/developers/reference), checked 11 October 2026
- [Discord Developer Docs: Allowed mentions](https://docs.discord.com/developers/resources/message), checked 11 October 2026
- [Telegram Bot API: sendMessage and formatting options](https://core.telegram.org/bots/api#sendmessage), checked 11 October 2026

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

Source: https://oddsrelay.io/blog/horse-racing-api-in-python
