On this page
  1. Setup: key, bot token, chat id
  2. The script
  3. Filtering: sport, threshold, kickoff window
  4. Dedupe by event and market
  5. Sending to Telegram without getting throttled
  6. Run it under systemd
  7. Tuning and next steps

A drop alert is only useful if it arrives once, arrives in time, and arrives for a match you care about. This recipe builds a small Python service that does those three things on top of the pinnodds SSE stream and the Telegram Bot API, then runs it under systemd so it survives reboots and its own crashes. It assumes you have read the SSE guide or the Python quickstart; the SDK call is one line and the rest is filtering and plumbing. pinnodds published its own Telegram alert bot walkthrough, which is a good related read; this one differs in the kickoff-window filter, per-market dedupe, self rate-limiting and the deployment unit.

Setup: key, bot token, chat id

You need three secrets, all read from the environment: a pinnodds key on a plan that includes SSE, a Telegram bot token from BotFather, and the id of the chat the bot should post to. A group chat id is negative and usually starts with -100.

setup
pip install pinnodds requests

# 1. pinnodds key on a plan with SSE (Stream, Pro + SSE, Scale, or the 3-day demo)
export PINNODDS_KEY=...
# 2. Telegram: talk to @BotFather -> /newbot -> copy the token
export BOT_TOKEN=123456:ABC...
# 3. Chat id: add the bot to a group or message it, then
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getUpdates" | python -m json.tool | grep '"id"'
export CHAT_ID=-1001234567890

A fresh trial key is REST only, so the stream will return 403 until the key is on Stream, Pro + SSE, Scale or the 3-day full demo. The plan comparison lists what each includes.

The script

One file, about a hundred lines, no framework. The loop reads alerts from stream_drops, applies the filters, dedupes, formats an HTML message and posts it. Everything tunable is an environment variable so the same file runs unchanged on your laptop and on the server.

drop_alerts.py
#!/usr/bin/env python3
"""drop_alerts.py - Pinnacle odds-drop alerts to Telegram via pinnodds SSE."""
import collections, html, logging, os, sys, time
import requests
from pinnodds import Client, AuthError, PinnoddsError

KEY       = os.environ["PINNODDS_KEY"]
BOT_TOKEN = os.environ["BOT_TOKEN"]
CHAT_ID   = os.environ["CHAT_ID"]

MIN_DROP     = float(os.environ.get("MIN_DROP", "4"))          # percent, server-side floor is 1
SPORTS       = {int(s) for s in os.environ.get("SPORT_IDS", "1,2,3").split(",")}   # 1 soccer 2 tennis 3 basketball
MIN_TO_START = int(os.environ.get("MIN_TO_START", "900"))      # seconds; skip if kickoff is closer than this
MAX_TO_START = int(os.environ.get("MAX_TO_START", str(48 * 3600)))
DEDUPE_TTL   = int(os.environ.get("DEDUPE_TTL", "1800"))       # one alert per market per 30 min
SEND_PER_MIN = 15                                              # our own cap, under Telegram's 20/min per chat

log = logging.getLogger("drops")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout)

api = Client(KEY)
tg  = requests.Session()
TG_URL = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"

def drop_pct(d):
    return (1 - d["to_price"] / d["from_price"]) * 100

def market_key(d):
    """SSE alerts have no market_key; build the REST-style one from event + market fields."""
    return f'{d["id"]}:{d["sect"]}:{d["outcome"]}:{d.get("point")}:{d["period"]}'.lower()

def wanted(d, now):
    if d.get("sport_id") not in SPORTS:
        return False
    to_start = (d.get("starts") or 0) - now                    # SSE starts is a Unix timestamp
    if not (MIN_TO_START <= to_start <= MAX_TO_START):        # prematch only, inside the window
        return False
    return drop_pct(d) >= MIN_DROP

def fmt(d, pct):
    line = f' {d["point"]:+g}' if d.get("point") is not None and d["sect"] != "Moneyline" else ""
    mins = int((d["starts"] - time.time()) // 60)
    return (f'<b>{html.escape(d["home"])}</b> v <b>{html.escape(d["away"])}</b>\n'
            f'{html.escape(d.get("league", ""))} · starts in {mins} min\n'
            f'{d["sect"]} {d["outcome"]}{line} (p{d["period"]}): '
            f'<code>{d["from_price"]}</code> → <code>{d["to_price"]}</code>  <b>−{pct:.1f}%</b>\n'
            f'no-vig {d["nvp"]}')

class Sender:
    """Token bucket for our own sends + Telegram's retry_after handling."""
    def __init__(self, per_min):
        self.per_min, self.sent = per_min, collections.deque()
    def send(self, text):
        now = time.time()
        while self.sent and now - self.sent[0] > 60:
            self.sent.popleft()
        if len(self.sent) >= self.per_min:
            log.warning("local send cap hit, dropping alert"); return
        r = tg.post(TG_URL, json={"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML",
                                  "disable_web_page_preview": True}, timeout=10)
        if r.status_code == 429:
            wait = r.json().get("parameters", {}).get("retry_after", 5)
            log.warning("telegram 429, sleeping %ss", wait); time.sleep(wait)
            r = tg.post(TG_URL, json={"chat_id": CHAT_ID, "text": text, "parse_mode": "HTML"}, timeout=10)
        r.raise_for_status()
        self.sent.append(time.time())

def main():
    sender = Sender(SEND_PER_MIN)
    seen = {}                                                  # market_key -> last alert ts
    backoff = 2
    while True:
        try:
            log.info("connecting to drop stream, min_drop=%s", MIN_DROP)
            for d in api.stream_drops(min_drop=MIN_DROP):     # SDK reconnects on transient errors
                now = time.time()
                if not wanted(d, now):
                    continue
                k = market_key(d)
                if now - seen.get(k, 0) < DEDUPE_TTL:
                    continue
                seen[k] = now
                if len(seen) > 5000:                           # keep memory flat on busy days
                    seen = {kk: t for kk, t in seen.items() if now - t < DEDUPE_TTL}
                pct = drop_pct(d)
                log.info("ALERT %s v %s %s %s %.1f%%", d["home"], d["away"], d["sect"], d["outcome"], pct)
                sender.send(fmt(d, pct))
                backoff = 2
        except AuthError:
            log.error("key rejected or plan lacks SSE; exiting"); sys.exit(1)
        except (PinnoddsError, requests.RequestException) as e:
            log.warning("stream error: %s; reconnecting in %ss", e, backoff)
            time.sleep(backoff); backoff = min(backoff * 2, 60)

if __name__ == "__main__":
    main()

What the log looks like on a quiet Sunday morning:

journal
2026-09-28 10:41:07 INFO connecting to drop stream, min_drop=4.0
2026-09-28 10:43:52 INFO ALERT Bentleigh Greens v St Albans Saints Spread Home 5.1%
2026-09-28 10:51:10 INFO ALERT Alcaraz v Sinner Moneyline Away 4.4%

The SDK's iterator already reconnects on transient failures; the outer loop exists for the cases it does not cover, and it exits on AuthError because restarting with a bad key would just loop. Sleeping and retrying a 403 forever is how you discover, a week later, that the demo expired.

Filtering: sport, threshold, kickoff window

The server filters by minimum percentage; everything else is yours. This script filters on three fields every SSE alert carries: sport_id, the derived percentage from from_price and to_price, and the Unix starts timestamp.

  • Sport. SPORT_IDS is a set of integer ids (1 soccer, 2 tennis, 3 basketball, 4 hockey, 5 American football, 6 baseball, 7 rugby, 8 MMA, 9 boxing, 11 esports, 12 golf, 13 cricket).
  • Threshold. MIN_DROP is passed to the server as min_drop and checked again locally, so you can raise it without reconnecting logic. Prematch moves are small; 3 to 5 percent is a sensible range, and 1 is the server floor.
  • Kickoff window. MIN_TO_START and MAX_TO_START bound how far from kickoff an event may be. A positive time to start also means the match has not begun, which is how this script stays prematch-only using the documented starts field rather than a mode flag.

If you use the Node SDK instead, streamDrops accepts mode: "prematch" and a recheck window that suppresses bounced prices server-side; the Node quickstart shows both. In Python, a poor man's recheck is to raise MIN_DROP and let dedupe absorb the noise.

Dedupe by event and market

A price that drifts from 2.10 to 1.90 over ten minutes can fire several alerts, each a valid drop from a new baseline. You want the first one. The script keys each alert by event id, market section, outcome, line and period, which is the same identity the REST buffer exposes as market_key, and suppresses repeats for DEDUPE_TTL seconds.

The dict is trimmed when it passes five thousand entries, which keeps memory flat through a busy Saturday. If you run more than one chat or more than one threshold, key the dict by chat as well.

Sending to Telegram without getting throttled

Telegram limits bots to roughly twenty messages a minute in a single group and returns 429 with a retry_after when you exceed it. The Sender class enforces a lower local cap of fifteen a minute with a sliding window, and if a 429 still arrives it sleeps for the advertised time and retries once.

Messages use parse_mode=HTML with escaped team names, because club names contain ampersands and the occasional angle bracket. If you want more than fifteen alerts a minute, you are almost certainly under-filtering rather than under-sending; tighten the window or the threshold first. For genuinely high volume, batch several drops into one message per ten seconds instead of raising the cap.

Run it under systemd

The stream is one long-lived connection, so the script must run as a service, not a cron job. systemd restarts it on crash, captures logs in the journal, and, with KillMode=mixed and a short stop timeout, makes sure the old instance is gone before a new one connects. That matters because pinnodds allows one concurrent SSE connection per key: an overlap during restart disconnects whichever instance connected first.

  1. The unit

    drop-alerts.service
    # /etc/systemd/system/drop-alerts.service
    [Unit]
    Description=Pinnacle odds-drop alerts to Telegram
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    User=odds
    WorkingDirectory=/opt/drop-alerts
    EnvironmentFile=/etc/drop-alerts.env
    ExecStart=/opt/drop-alerts/.venv/bin/python /opt/drop-alerts/drop_alerts.py
    Restart=always
    RestartSec=10
    # one SSE connection per key: never let two instances overlap
    KillMode=mixed
    TimeoutStopSec=15
    NoNewPrivileges=true
    ProtectSystem=strict
    ProtectHome=true
    PrivateTmp=true
    
    [Install]
    WantedBy=multi-user.target
  2. The environment file

    Secrets live in an environment file readable only by root; systemd injects them into the process as the unprivileged odds user.

    drop-alerts.env
    # /etc/drop-alerts.env  (chmod 600, owned by root)
    PINNODDS_KEY=...
    BOT_TOKEN=123456:ABC...
    CHAT_ID=-1001234567890
    MIN_DROP=4
    SPORT_IDS=1,2,3
    MIN_TO_START=900
    MAX_TO_START=172800
    DEDUPE_TTL=1800
  3. Install and start

    shell
    sudo useradd -r -s /usr/sbin/nologin odds
    sudo mkdir -p /opt/drop-alerts && sudo chown odds /opt/drop-alerts
    sudo -u odds python3 -m venv /opt/drop-alerts/.venv
    sudo -u odds /opt/drop-alerts/.venv/bin/pip install pinnodds requests
    sudo cp drop_alerts.py /opt/drop-alerts/
    sudo systemctl daemon-reload
    sudo systemctl enable --now drop-alerts
    journalctl -u drop-alerts -f

To change a threshold, edit the env file and run systemctl restart drop-alerts. Do not start a second copy on another machine with the same key to test; it will evict the production one. Use the 3-day demo key for experiments and keep the paid key on the server.

Tuning and next steps

Run it for a weekend before you trust it, then look at the journal and count how many alerts you would have acted on. Most people end up raising MIN_DROP and narrowing SPORT_IDS.

  • Use the no-vig price. Each alert includes nvp, the fair price implied by the new odds. Comparing it with the price at another book is the actual signal; pinnodds explains the maths in its guide to calculating EV from Pinnacle odds.
  • Backfill on start. Call api.drops(min_drop_pct=...) once at startup to seed seen with the last hours of REST rows, so a restart does not replay alerts you already sent. The SSE guide has the snippet.
  • Store what you alerted. Writing each alert to SQLite lets you measure hit rate against closing lines later. The SQLite recipe covers the schema.
  • Need every tick, not just drops? That is the raw WebSocket feed, an add-on on some plans.