On this page
  1. What the stream sends
  2. SDK streaming in Python and Node
  3. Raw SSE with sseclient-py
  4. Filtering: min drop, mode, recheck
  5. Computing drop_pct
  6. Backfill and cold start from REST
  7. Alert fields: limit, nvp, price_x/y/z
  8. One connection per key

Detecting odds drops yourself means polling a whole board, diffing it, and deciding what counts as a move. The pinnodds feed does that server-side against Pinnacle's prices and pushes the result over Server-Sent Events. This guide covers the stream from both ends: the two-line SDK version in Python and Node, the raw HTTP version for everything else, and the operational details (filters, percentage maths, backfill, the single-connection rule) that decide whether an alert bot is useful or noisy. For why drops matter at all, pinnodds has a primer on how to detect odds drops.

What the stream sends

There are two SSE endpoints: /odds-drop for the live board and /odds-drop-prematch for upcoming fixtures. Both authenticate with your API key and require a plan with SSE enabled (Stream, Pro + SSE, Scale, or the 3-day full demo; a fresh trial key is REST only). The first frame is a handshake; every later frame is a JSON array of one or more alerts.

text/event-stream
data: {"type": "connected", "id": "uuid-of-this-subscription"}

data: [{"home": "Sunshine Coast Phoenix", "away": "Cairns Dolphins",
        "league": "Australia - NBL1 Women", "from_price": 2.86, "to_price": 2.7,
        "outcome": "Home", "period": 4, "sect": "Moneyline", "id": 1629729400,
        "sport": "Basketball", "sport_id": 3, "nvp": 3.04, "starts": 1777624200,
        "interval": 25, "alerted": 1777625196}]

Live drops arrive at tens per second in prime hours. Prematch drops are far rarer, under one a second on a normal day, and a short sample can legitimately contain none. If you need to see alerts in a hurry while testing, use the live endpoint and a low threshold.

SDK streaming in Python and Node

Both official clients wrap the stream as an iterator that parses frames, unwraps the arrays into single alerts, and reconnects with backoff when the connection drops. Python's stream_drops is a blocking generator; Node's streamDrops is an async iterator that accepts an AbortSignal.

stream.py
import os
from pinnodds import Client

api = Client(os.environ["PINNODDS_KEY"])

for d in api.stream_drops(min_drop=5):          # blocks; reconnects on its own
    pct = (1 - d["to_price"] / d["from_price"]) * 100
    print(f'{d["sport"]:10} {d["home"]} v {d["away"]}  {d["sect"]} {d["outcome"]} p{d["period"]}  '
          f'{d["from_price"]} -> {d["to_price"]}  -{pct:.1f}%  nvp {d["nvp"]}')
stream.mjs
import { Client } from "pinnodds";

const api = new Client(process.env.PINNODDS_KEY);
const ac = new AbortController();
process.on("SIGINT", () => ac.abort());

for await (const d of api.streamDrops({ mode: "prematch", minDrop: 3, recheck: 30, signal: ac.signal })) {
  const pct = (1 - d.to_price / d.from_price) * 100;
  console.log(d.league, "|", d.home, "v", d.away, "|", d.sect, d.outcome, d.point ?? "",
              "|", d.from_price, "->", d.to_price, `-${pct.toFixed(1)}%`, "nvp", d.nvp);
}
Output
Basketball Sunshine Coast Phoenix v Cairns Dolphins  Moneyline Home p4  2.86 -> 2.7  -5.6%  nvp 3.04
Soccer     Bentleigh Greens v St Albans Saints  Spread Home p0  2.37 -> 2.25  -5.1%  nvp 2.31

The Node call above also shows mode: "prematch" and recheck: 30, explained under filtering. In Python, filter prematch alerts client-side with the Unix starts field (an event whose start time is still in the future has not kicked off); the Telegram recipe does exactly that.

Raw SSE with sseclient-py

Any HTTP client that can read a chunked response can consume SSE. In Python, requests with stream=True plus sseclient-py is the usual pair. The points that trip people up: the handshake frame is an object while alert frames are arrays, and an auth failure returns 401 or 403 with an SSE-framed body rather than JSON.

raw_sse.py
import json, os, time, requests
from sseclient import SSEClient                 # pip install sseclient-py requests

KEY = os.environ["PINNODDS_KEY"]
URL = "https://pinnodds.com/odds-drop-prematch"   # or /odds-drop for the live board

def stream(min_drop=3, recheck=0):
    params = {"min_drop": min_drop}
    if recheck: params["recheck"] = recheck
    r = requests.get(URL, params=params, headers={"x-api-key": KEY, "Accept": "text/event-stream"},
                     stream=True, timeout=(10, 90))
    if r.status_code in (401, 403):
        # error bodies are SSE-framed too: data: {"type":"error","error":"...","message":"..."}
        raise SystemExit(f"{r.status_code}: {r.text.strip()}")
    r.raise_for_status()
    for ev in SSEClient(r).events():
        if not ev.data:
            continue
        payload = json.loads(ev.data)
        if isinstance(payload, dict):            # {"type":"connected", ...} handshake
            print("connected", payload.get("id")); continue
        for d in payload:                        # frames are arrays of one or more alerts
            yield d

backoff = 1
while True:
    try:
        for d in stream(min_drop=3, recheck=30):
            print(d["home"], "v", d["away"], d["sect"], d["outcome"], d["from_price"], "->", d["to_price"])
        backoff = 1
    except (requests.RequestException, json.JSONDecodeError) as e:
        print("stream error:", e)
    time.sleep(backoff); backoff = min(backoff * 2, 30)

Set a read timeout longer than the gap between frames you expect (90 seconds is generous for live, but a quiet prematch night can exceed it, so treat a timeout as a reconnect, not an error). In the browser or Deno, the native EventSource works with ?key= on the URL because it cannot set headers; do not ship your key to a browser you do not control.

Filtering: min drop, mode, recheck

Three server-side filters keep the stream relevant. min_drop sets the percentage threshold; the default is 5, the floor is 1 and there is no cap. mode (in the SDKs) picks the live or prematch endpoint. recheck, prematch only, holds each alert for that many seconds, fetches the current price again, and emits only if the fall still clears your threshold against the original from_price.

Scroll for more

Odds-drop stream filters: SDK option, raw query parameter and notes
FilterSDK optionRaw query paramNotes
Threshold %min_drop / minDropmin_dropDefault 5, floor 1. Lower it for prematch, where moves are small.
Boardmode (Node)endpoint path/odds-drop live, /odds-drop-prematch upcoming.
Stable-price confirmrecheck (Node)recheck secondsPrematch only. Suppresses bounces; adds rechecked_ms to alerts.
Sport, league, time to startclient-sideclient-sideUse sport_id, league and Unix starts on each alert.

Recheck is the single most effective noise filter for prematch alerting. A price that falls 4% and returns within 30 seconds is usually a limit test or a stale-line correction, not information. The cost is a delay equal to the recheck window.

Computing drop_pct

SSE alerts carry the old and new decimal price but no percentage; the REST buffer's rows carry a precomputed drop_pct. To make the two comparable, compute the same number from the stream. Consider also tracking the implied-probability shift, which weights favourites and long shots more fairly than a raw percentage of the price.

drop_pct.py
def drop_pct(from_price: float, to_price: float) -> float:
    """Percentage fall in decimal odds, matching the REST buffer's drop_pct."""
    return round((1 - to_price / from_price) * 100, 2)

def implied_prob_shift(from_price: float, to_price: float) -> float:
    """Percentage points of implied probability gained, often the better signal."""
    return round((1 / to_price - 1 / from_price) * 100, 2)

# 2.86 -> 2.70: drop_pct 5.59, implied +2.07 pp
# 1.30 -> 1.20: drop_pct 7.69, implied +6.41 pp

Whichever measure you use, apply it consistently between your live stream and your REST backfill, or your dedupe logic will disagree with itself. The nvp field, the no-vig price implied by the new odds, is the number to compare against other books; pinnodds explains the arithmetic in its guide to calculating EV from Pinnacle odds.

Backfill and cold start from REST

Streams do not replay. When your process restarts you have missed whatever happened while it was down, and the first minutes after reconnecting may re-alert markets you already handled. The REST drops buffer, GET /api/drops, keeps roughly the last three hours and is the right tool for both problems.

backfill.py
import os, time
from pinnodds import Client, RateLimitError

api = Client(os.environ["PINNODDS_KEY"])
seen: dict[str, float] = {}                      # market_key -> last alerted ts

def key(event_id, market, designation, points, period):
    return f"{event_id}:{market}:{designation}:{points}:{period}"

# 1. cold start: what dropped in the last hour while we were down?
try:
    buf = api.drops(min_drop_pct=3)              # REST rows: from/to/drop_pct/market_key
    for r in buf["drops"]:
        seen[r["market_key"]] = r["ts"]
    print("backfilled", len(buf["drops"]), "drops")
except RateLimitError as e:
    time.sleep(e.retry_after)

# 2. steady state: SSE alerts, deduped against the backfill
for d in api.stream_drops(min_drop=3):
    k = key(d["id"], d["sect"].lower(), d["outcome"].lower(), d.get("point"), d["period"])
    if k in seen and time.time() - seen[k] < 600:
        continue                                 # same market alerted in the last 10 min
    seen[k] = time.time()
    print("NEW", d["home"], "v", d["away"], d["sect"], d["outcome"], d["from_price"], "->", d["to_price"])

Two shapes are in play, and it is worth being explicit about it. REST rows have event_id, market, designation, points, from, to, drop_pct, is_live, a ts and a ready-made market_key. SSE alerts have id, sect, outcome, point, from_price, to_price and no key. The snippet builds its own key from the SSE fields; if you store both, normalise case and naming once at the boundary and never let the two vocabularies meet in a query. The SQLite recipe shows one such boundary.

Alert fields: limit, nvp, price_x/y/z

Beyond the identifying fields, each alert carries context that a plain price diff cannot give you. nvp is the no-vig fair price for the new odds. interval is how many seconds of price history the detector had when it fired. limit reports the market's limit alongside the alert, and price_x, price_y and price_z are additional price points the detector recorded around the move; the docs give their precise definitions, so read those before building logic on them.

The full field list, including alerted, dispatched_ms and flushed_ms for latency accounting, is in the pinnodds docs. Do not depend on fields that are not listed there; the payload has grown over time and unlisted keys may not stay.

One connection per key

Each account gets one concurrent SSE connection. Opening a second one disconnects the first, so two services sharing a key will take turns going silent. SSE and the raw WebSocket are separate slots and may run together, but two SSE clients may not.

The fix is architectural: one process owns the connection and fans alerts out to everything else. In Python that can be a thread reading the blocking iterator and pushing into queues; in Node an async iterator feeding an EventEmitter. If consumers live in other processes, publish to Redis, NATS or a Unix socket from the one owner.

fanout.py
import asyncio, os
from pinnodds import Client

api = Client(os.environ["PINNODDS_KEY"])

async def producer(queues: list[asyncio.Queue]):
    loop = asyncio.get_running_loop()
    it = api.stream_drops(min_drop=3)
    while True:
        d = await loop.run_in_executor(None, next, it)   # the SDK iterator is blocking
        for q in queues:
            q.put_nowait(d)

async def telegram(q):     # one consumer
    while True: d = await q.get(); ...
async def database(q):     # another consumer, same connection
    while True: d = await q.get(); ...

async def main():
    qs = [asyncio.Queue(), asyncio.Queue()]
    await asyncio.gather(producer(qs), telegram(qs[0]), database(qs[1]))

asyncio.run(main())

If a deploy replaces the owner process, start the new one after the old one has exited, or accept a brief gap and rely on the REST backfill to cover it. Rate limits also apply per key, by plan: Trial 20 requests a minute and 100 a day, Pro 10 a second, Scale 30 a second, so keep the backfill call rare. Plans and what each includes are compared on the pricing page.