Feed liveChannel BlogSports 12
Transport MQTT / SSE / RESTPush 30–150 msV1
Guides

Build a Betting Odds Alert Bot: Telegram + SSE

Build a betting odds alert bot with Telegram and SSE in ~60 lines of Python: stream odds drops, filter on limit, and format a message you can act on.

A betting odds alert bot is about sixty lines of Python: hold one Server-Sent Events connection open, filter the events you care about, POST a formatted string to Telegram's sendMessage. No cron. No polling loop. No queue, no database, no worker pool.

The code is the easy half. The hard half is deciding what deserves to vibrate a phone, and writing a message body you can read and act on in four seconds while standing in a supermarket queue. This guide covers both, against the pinnodds SSE feed, including the field-shape trap that catches nearly everyone on their first run.

What an odds-drop alert bot actually is

It listens to a live price feed, notices when a market's price falls against its own recent history, and pushes a readable message to a chat client. "Drop" means the price moved down — Team A from 2.10 to 1.92. That's the direction carrying information, because money went in and the book responded.

With pinnodds, detection already happened server-side. You subscribe to /odds-drop for live or /odds-drop-prematch for prematch, and every event that lands is a drop. Your bot is transport plus filter, not a detector. That's the whole reason this runs on a $5 VPS with no persistence layer.

Auth is one API key. On streams it goes in the query string, because EventSource cannot set headers:

GET https://pinnodds.com/odds-drop?key=YOUR_KEY

On REST it's the header form, x-api-key: YOUR_KEY. Same key both ways — it's also your login. Full endpoint surface is in the docs.

Why not just poll a REST endpoint and diff it

That's the thing this gets confused with: a scheduled job hitting an odds endpoint every thirty seconds, storing the last snapshot, comparing. It works. Against most odds APIs it's the only option. For alerting it has two structural problems.

Resolution is the first. A drop that opens and closes inside your interval never existed as far as your bot is concerned. Live soccer prices move on shot events and cards; a thirty-second window eats exactly the moves you built the bot for.

The second is that you become the detector. Previous prices, a lookback window, suspended markets that reopen at a different number, the decision about whether a reopen counts as a drop at all. That's state, and state is what turns a Saturday project into something with a maintenance burden.

pinnodds ships both models and they are honestly for different jobs. SSE (/odds-drop, /odds-drop-prematch) is pushed and sub-second, firing the moment a drop is detected — that's what a bot uses. REST (/api/drops?mode=live|prematch) is an enriched snapshot of recent drops, which is what you call after a restart to answer "what did I miss", and what you point a dashboard at.

There's also a raw WebSocket passthrough at /ws/feed carrying every price tick. For a Telegram bot that's the wrong tool — you'd be reimplementing drop detection on a firehose to end up where the SSE stream already is. Take the stream.

The field-shape gotcha

Internalise this before you write a line. The SSE payload and the REST /api/drops payload are not the same shape. They describe the same phenomenon with different field names, and each carries something the other doesn't.

ConceptSSE eventREST /api/drops row
Marketsectmarket
Moving selectionoutcomedesignation
Old pricefrom_pricefrom
New priceto_priceto
Event referenceidevent_id
Drop sizenot presentdrop_pct
Max stakelimit
No-vig fair pricenvp

Two consequences follow.

You compute the drop percentage yourself on the stream. There is no drop_pct in an SSE alert. It's one line — (from_price - to_price) / from_price — but write your filter assuming the field is there and every alert evaluates as zero, your threshold rejects all of them, and the bot goes quiet with no error. I've debugged this in someone else's repo more than once.

In exchange the stream hands you limit and nvp, which are the two best numbers in the payload. limit is Pinnacle's max stake on that selection and the strongest available proxy for conviction behind the price. A drop on a selection with a healthy limit is a different animal from a drop on one throttled to pocket change. nvp is the no-vig fair price — what the market thinks once the margin is stripped. Both belong in the message.

The message body is the product

Everyone gets the plumbing right and the formatting wrong. "Odds dropped" is a worthless notification. On a phone, in reading order, you want: which market, which side, the price move, the implied drop, the limit, the no-vig number. Six facts.

Here's the bot. Python, the official pinnodds SDK, no framework.

import os, time, requests
from pinnodds import Client

BOT  = os.environ["TELEGRAM_BOT_TOKEN"]
CHAT = os.environ["TELEGRAM_CHAT_ID"]
TG   = f"https://api.telegram.org/bot{BOT}/sendMessage"

MIN_DROP  = 0.04     # 4% — below this it's noise
MIN_LIMIT = 500      # ignore throttled selections
COOLDOWN  = 90       # seconds, per selection

client = Client(api_key=os.environ["PINNODDS_API_KEY"])
last_sent = {}

def drop_pct(e):
    f, t = float(e["from_price"]), float(e["to_price"])
    return (f - t) / f if f else 0.0

def send(text):
    requests.post(TG, json={
        "chat_id": CHAT,
        "text": text,
        "parse_mode": "Markdown",
        "disable_web_page_preview": True,
    }, timeout=10)

for e in client.odds_drops(mode="live"):
    pct = drop_pct(e)
    lim = float(e.get("limit") or 0)
    if pct < MIN_DROP or lim < MIN_LIMIT:
        continue

    key, now = (e["id"], e["sect"], e["outcome"]), time.time()
    if now - last_sent.get(key, 0) < COOLDOWN:
        continue
    last_sent[key] = now

    send(
        f"*{e['sect']}* — {e['outcome']}\n"
        f"{e['from_price']} → {e['to_price']}  (−{pct*100:.1f}%)\n"
        f"limit {lim:.0f}  ·  nvp {e.get('nvp', '?')}\n"
        f"event `{e['id']}`"
    )

Swap mode="live" for mode="prematch" to listen on /odds-drop-prematch. Prefer raw HTTP? Point any SSE client at /odds-drop?key=YOUR_KEY and JSON-parse the data: lines — the SDK is convenience, not a requirement. npm install pinnodds gets you the same surface if your bot is Node.

Notice what it doesn't do: store anything. The event_id in the message is your handle. When you tap through, hit /kit/v1/details for context on demand rather than caching fixture metadata you'll mostly never read.

The two filters that carry the bot

Thresholding on percentage is the obvious one and the least important.

The limit floor is the highest-leverage line in the file. Pinnacle throttles max stake on markets it doesn't trust — thin liquidity, 89th minute, a number that's gone stale. Filtering on limit removes a large share of technically-real drops nobody would ever bet, and it does it without you tuning anything.

Per-event cooldown is second. A market repricing in three steps fires three alerts in forty seconds. The dict lookup above suppresses repeats inside ninety seconds. That's the difference between a bot you keep and a bot you mute on day two.

The honest caveats

What I'd want to know before spending a Saturday on this.

One SSE connection per account. Plan on a single process owning the stream. Want alerts fanned out to five Telegram groups with different filters? That's one consumer doing the routing, not five subscribers. And push is a plan feature: Pro is $99/mo, Pro + SSE $149/mo, Scale $229/mo, with quarterly and semi-annual options. Line depth is identical on every plan — what differs is rate limit and push access. See pricing.

No historical odds archive. The feed is real-time and only real-time. You cannot ask what the closing line was last Tuesday, and you cannot backtest MIN_DROP against history unless you recorded it yourself. Append every raw event to a JSONL file from day one. Future you will want that file and there is no retroactive way to get it.

Pinnacle only. Every price is Pinnacle's. That's the point — sharpest book, reference market — but this feed will never tell you a soft book is fifteen cents off. If your edge is beating a recreational book against a fair line, you need Pinnacle plus a multi-book aggregator, and pinnodds is the first half of that.

Reconnects lose events. SSE drops on network blips and deploys. Reconnect with backoff, and on reconnect call GET /api/drops?mode=live once to backfill — those rows arrive in the REST shape, so your formatter has to handle both field sets. Write one normaliser upfront. Retrofitting it after you've got Markdown templates hard-coded against sect is miserable.

A drop is not a bet. The feed reports that a price moved. It does not know whether that's information, a limit adjustment, a correction, or an artefact of a market reopening. Treating every alert as a signal is how people lose money faster with a faster feed.

Telegram will rate-limit you. Roughly twenty messages a minute into one group before it pushes back. Without the limit floor and the cooldown, a busy Saturday of live soccer trips it well before half-time.

Why prematch is often the better stream

Most people wire live up first because it feels more exciting. For a phone-notification workflow I'd start with /odds-drop-prematch instead. Moves are slower, you have minutes rather than seconds to act, and the drops that actually matter — steam on an opener, a lineup leak, weather — show up cleanly instead of buried under in-play noise.

Prematch also gives you a cleaner path to context. /kit/v1/prematch/fixtures, /kit/v1/prematch/markets and /kit/v1/prematch/lines pull the full board around an alerted event, quarter lines included: soccer totals at 1.75 / 2.25 / 2.75 / 3.25, quarter-ball handicaps at −0.75 / −1.25 / −1.75 / −2.25. A drop on the 2.25 total means very little until you see where 2.0 and 2.5 sit.

One warning if you enrich alerts. Passing include_specials=1 picks up player props, exact scores, futures and outrights, and the payload grows hard — soccer prematch is roughly 1,500 events without the flag and roughly 12,400 with it. Specials arrive as their own event rows carrying special, special_category, special_markets and a parent_id pointing back at the fixture. Fetch them for one parent when a user taps through. Don't pull the whole specials board on every alert.

Takeaway

Write the normaliser before you write the bot. One function that accepts either an SSE alert (sect, outcome, from_price/to_price, id, plus limit and nvp) or a REST /api/drops row (market, designation, from/to, event_id, drop_pct) and returns one internal dict. That single function makes reconnect-backfill trivial, lets you unit-test filters against logged fixtures, and kills the missing-drop_pct bug before it silences your bot. Then add the limit floor and the per-event cooldown — those two do more for alert quality than any amount of tuning on the percentage threshold. Payload examples and the full endpoint list are in the docs.

Frequently asked questions

How do I build a betting odds alert bot for Telegram?

Open one Server-Sent Events connection to an odds-drop stream such as /odds-drop, filter the events on drop percentage and Pinnacle's max stake (limit), and POST a formatted string to Telegram's sendMessage. It's roughly sixty lines of Python with the official pinnodds SDK and needs no database.

Why is drop_pct missing from my SSE odds alerts?

Because SSE alerts don't carry it. Only REST /api/drops rows include a precomputed drop_pct; on the stream you calculate it yourself as (from_price - to_price) / from_price.

Should my odds bot use SSE or poll the REST endpoint?

Use SSE for alerting — it's pushed sub-second and fires the moment a drop is detected, so short-lived moves aren't lost between polls. Use GET /api/drops after a restart to backfill the gap, and for dashboards.

Do I need a paid plan to use the odds-drop SSE stream?

Yes. Push access is a plan feature: Pro is $99/mo, Pro + SSE is $149/mo and Scale is $229/mo. Line depth is identical across plans; what differs is rate limit and push access.

Can I run more than one SSE connection on the same API key?

No — it's one SSE connection per account. Run a single consumer process that owns the stream and fans messages out to multiple Telegram groups with per-group filters.

Can I backtest my alert thresholds against historical odds?

Not from the feed — there's no historical odds archive, it's real-time only. Log every raw event to a file from the first day you run the bot if you want to tune thresholds later.

Get real-time Pinnacle odds in your code

Live & prematch markets with instant odds-drop alerts over SSE and WebSocket. Free trial key in seconds — no card.

Start free trial