On this page
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.
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.
#!/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:
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_IDSis 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_DROPis passed to the server asmin_dropand 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_STARTandMAX_TO_STARTbound 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 documentedstartsfield 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.
-
The unit
# /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 -
The environment file
Secrets live in an environment file readable only by root; systemd injects them into the process as the unprivileged
oddsuser.# /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 -
Install and start
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 seedseenwith 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.