Odds Drops
Detecting dropping odds in real time with Server-Sent Events

How PinnRadar's /odds-drop SSE streams work: drop definition, min_drop and recheck, plus Python and Node.js clients with reconnect, backoff and sport filters.
A dropping odd is an outcome whose price has just shortened. You can find these by polling every board and comparing snapshots yourself. It works, but it costs requests and you only see a move at your next poll. PinnRadar runs the detection on the server and pushes each drop over a Server-Sent Events (SSE) stream. This post covers what counts as a drop, the two stream endpoints and their parameters, and complete Python and Node.js clients that reconnect correctly.
What counts as a drop
The docs define it this way: a drop is recorded when an outcome's decimal price falls by at least a threshold within a set time window. Detection runs on the server for both the live and the prematch feed. Each drop carries its old and new price, its event, and nvp, the no-vig fair price of the outcome with the bookmaker margin removed.
The docs suggest one simple measure: edge = to_price / nvp - 1. Read it as a description of the price relative to a margin-free reference, not as a prediction.
Some scope rules:
- Player and team prop markets never fire the SSE streams. Among the specials, only BTTS and Draw No Bet alert there. Prop moves do appear in the
/api/dropsbuffer. - Golf drops only come from the Moneyline section, because golf has no other markets upstream.
Two ways to get drops
GET /api/drops | SSE /odds-drop, /odds-drop-prematch | |
|---|---|---|
| Model | You poll a buffer | The server pushes |
| Look-back | About 3 hours | From the moment you connect |
| Filters | sport_id, min_drop_pct, max_drop_pct, max_age_sec, markets, periods, live, limit | min_drop (plus recheck on prematch) |
| Plans | Every plan, counts as REST requests | Drops, Edge, Scale |
Always send max_age_sec to /api/drops. A request without it returns no rows. The buffer is good for dashboards and batch jobs. For alerts, use the streams.
Which plan you need
The SSE streams are included in Drops ($99 / 30 days), Edge ($149 / 30 days, which adds REST at 10 req/s) and Scale ($229 / 30 days, REST at 30 req/s). Free keys and keys on the REST plan are refused with plan_lacks_sse. A 3-day full demo that opens the streams can be requested from support on Telegram (@PinnRadarSupport) or at [email protected]. See the pricing page or our plan guide.
The endpoints and parameters
GET /odds-drop: live (in-play) drops.
| Parameter | Notes |
|---|---|
| key | The API key, in the x-api-key or x-portal-apikey header, or as ?key= |
token | An older SSE-only credential. You won't need it with a key |
min_drop | Per-connection threshold in percent. Default 5, minimum 1, no maximum. ?min_drop=1 delivers every drop |
GET /odds-drop-prematch: drops on fixtures that have not started. It uses the same format and authentication, plus:
| Parameter | Notes |
|---|---|
min_drop | As above |
recheck | Seconds. The server holds each drop N seconds, reads the line again, and sends the alert only if the drop held. Survivors still have to meet the detector's 5% threshold and gain a rechecked_ms field. Reverted drops are discarded silently. 0 or no value means instant. The parameter is ignored on the live stream |
The prematch stream is quiet by design: usually under one drop per second over a day, against tens per second on the live stream at peak. A 90-second test showing nothing is normal.
Frames on the wire
data: {"type": "connected", "id": "5f0c1a92-7e3d-..."}
data: [{"home": "Perth Wildcats", "away": "Brisbane Bullets", "league": "Australia - NBL", "from_price": 1.95, "to_price": 1.83, "outcome": "Away", "period": 0, "sect": "Moneyline", "id": 1632488015, "sport": "Basketball", "interval": 30, "alerted": 1794212210, "sport_id": 3, "nvp": 1.88, "starts": 1794209400}]
The first frame is a connected object. Every frame after that is a JSON array of one or more drops. Fields include home, away, league, from_price, to_price, outcome, period, sect (market section such as Moneyline or Total), point (the line, where relevant), id (event id), sport, sport_id, nvp, starts, home_score and away_score. Live drops end with a live object holding the match state: score, minute, cards, quarter and clock, tennis sets and points. Prematch drops have no live object.
Error frames are objects too:
{"type":"error","error":"plan_lacks_sse"}: the plan has no SSE. Retrying won't help.{"type":"error","error":"rate_limited"}: you reconnected too fast. It is sent withRetry-After: 60. Back off.
A keepalive comment line arrives every 8 seconds, so a connection silent for much longer than that is dead.
Rules that shape your client
- One SSE connection per account, per stream. Opening a second
/odds-dropon the same key closes the first. The live and prematch streams use separate slots, so you can hold one of each. A normal reconnect after a network blip is fine, but two processes sharing one key will knock each other off. - No server-side sport filter. The streams carry every sport. Filter on
sport_idin your client. - No freshness headers.
X-Data-Statusexists on REST only. On a stream, silence is the warning sign. PollGET /healthalongside if you need to tell a quiet market from a feed problem.
A Python client with reconnect and backoff
This client uses requests. It parses SSE blocks properly, with multi-line data: fields and comment lines. A 30-second read timeout detects a dead connection because keepalives arrive every 8 seconds. Backoff is exponential with jitter. It resets only after a connection has stayed up for a minute, so a stream that connects and drops straight away can't turn into the rapid reconnect loop the server throttles. The client stops on errors that retrying can't fix.
import json
import os
import random
import time
import requests
BASE = "https://pinnradar.com"
KEY = os.environ["PINNRADAR_KEY"]
class Fatal(Exception):
"""Retrying will not help: bad key or a plan without SSE."""
class RetryAfter(Exception):
def __init__(self, seconds):
super().__init__(f"retry after {seconds}s")
self.seconds = seconds
def sse_frames(path, params, idle_timeout=30):
headers = {"x-api-key": KEY, "accept": "text/event-stream"}
with requests.get(BASE + path, params=params, headers=headers,
stream=True, timeout=(5, idle_timeout)) as resp:
if resp.status_code in (401, 403):
raise Fatal(f"HTTP {resp.status_code}: {resp.text[:200]}")
if resp.status_code == 429:
raise RetryAfter(float(resp.headers.get("Retry-After", 60)))
resp.raise_for_status()
data_lines = []
for line in resp.iter_lines(decode_unicode=True):
if line:
if line.startswith(":"):
continue # keepalive comment
if line.startswith("data:"):
data_lines.append(line[5:].lstrip())
continue
if data_lines: # blank line ends an event
yield json.loads("\n".join(data_lines))
data_lines = []
def follow(path, on_drop, sports=None, **params):
backoff = 1.0
while True:
started = time.monotonic()
try:
for frame in sse_frames(path, params):
if isinstance(frame, list):
for drop in frame:
if sports is None or drop.get("sport_id") in sports:
on_drop(drop)
elif frame.get("type") == "connected":
print("connected", frame.get("id"))
elif frame.get("type") == "error":
if frame.get("error") == "rate_limited":
raise RetryAfter(60)
raise Fatal(frame.get("error"))
print("stream closed by server")
except Fatal:
raise
except RetryAfter as exc:
print("throttled:", exc)
time.sleep(exc.seconds)
continue
except (requests.RequestException, ValueError) as exc:
print("connection problem:", exc)
if time.monotonic() - started > 60:
backoff = 1.0 # it was up for a while: start the ladder again
time.sleep(backoff + random.uniform(0, backoff / 2))
backoff = min(backoff * 2, 60)
def show(d):
fair = f"{d['to_price'] / d['nvp'] - 1:+.1%} vs nvp" if d.get("nvp") else "no nvp"
print(f"{d['sport']}: {d['home']} v {d['away']} {d['sect']} {d['outcome']} "
f"{d['from_price']} -> {d['to_price']} ({fair})")
if __name__ == "__main__":
# soccer (1) and basketball (3), drops of 7% or more
follow("/odds-drop", show, sports={1, 3}, min_drop=7)
For the prematch stream with confirmation, change only the call:
follow("/odds-drop-prematch", show, min_drop=5, recheck=30)
A Node.js client (Node 18+, no dependencies)
The same design in Node uses global fetch, an AbortController as the idle watchdog, and an async loop for reconnects. Save it as drops.mjs:
const BASE = "https://pinnradar.com";
const KEY = process.env.PINNRADAR_KEY;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
class Fatal extends Error {}
class RetryAfter extends Error {
constructor(ms) { super(`retry after ${ms} ms`); this.ms = ms; }
}
async function streamOnce(path, params, onFrame, idleMs = 30000) {
const url = new URL(path, BASE);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
const ctrl = new AbortController();
let idle = setTimeout(() => ctrl.abort(), idleMs);
try {
const resp = await fetch(url, {
headers: { "x-api-key": KEY, accept: "text/event-stream" },
signal: ctrl.signal,
});
if (resp.status === 401 || resp.status === 403) throw new Fatal(`HTTP ${resp.status}: ${await resp.text()}`);
if (resp.status === 429) throw new RetryAfter(Number(resp.headers.get("retry-after") || 60) * 1000);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const decoder = new TextDecoder();
let buf = "";
for await (const chunk of resp.body) {
clearTimeout(idle);
idle = setTimeout(() => ctrl.abort(), idleMs);
buf = (buf + decoder.decode(chunk, { stream: true })).replace(/\r\n/g, "\n");
let cut;
while ((cut = buf.indexOf("\n\n")) !== -1) {
const block = buf.slice(0, cut);
buf = buf.slice(cut + 2);
const data = block.split("\n")
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).trimStart())
.join("\n");
if (data) onFrame(JSON.parse(data));
}
}
} finally {
clearTimeout(idle);
}
}
async function follow(path, params, onDrop, sports = null) {
let backoff = 1000;
for (;;) {
const started = Date.now();
try {
await streamOnce(path, params, (frame) => {
if (Array.isArray(frame)) {
for (const d of frame) if (!sports || sports.has(d.sport_id)) onDrop(d);
} else if (frame.type === "connected") {
console.log("connected", frame.id);
} else if (frame.type === "error") {
if (frame.error === "rate_limited") throw new RetryAfter(60000);
throw new Fatal(frame.error);
}
});
console.log("stream closed by server");
} catch (err) {
if (err instanceof Fatal) throw err;
if (err instanceof RetryAfter) { await sleep(err.ms); continue; }
console.log("connection problem:", err.name, err.message);
}
if (Date.now() - started > 60000) backoff = 1000; // it was up for a while
await sleep(backoff + Math.random() * (backoff / 2));
backoff = Math.min(backoff * 2, 60000);
}
}
await follow("/odds-drop", { min_drop: 7 }, (d) => {
const vsFair = d.nvp ? `${((d.to_price / d.nvp - 1) * 100).toFixed(1)}% vs nvp` : "no nvp";
console.log(`${d.sport}: ${d.home} v ${d.away} ${d.sect} ${d.outcome} ${d.from_price} -> ${d.to_price} (${vsFair})`);
}, new Set([1, 3]));
In a browser, the built-in EventSource reconnects on its own. Because it cannot set headers, it passes the key as ?key=. That puts the key in a URL, so keep it to internal tools.
Filtering beyond sport
The stream gives you two server-side levers: min_drop, plus recheck on prematch. Everything else happens in your handler, and the fields make that easy:
- Market:
sect(for example"Moneyline"or"Total") andpointfor the line. - Period:
period(0is the full game). - Match state (live):
drop.live.minute,drop.live.score,drop.live.status. Soccerminutecounts within the current half and restarts at 0 for the second half. - Size: compute
(from_price - to_price) / from_priceyourself if you want a different cut thanmin_drop.
Don't open a second connection to get a different filter. It closes your first one. Open one stream at the lowest threshold you need and fan out inside your process.
Pairing alerts with REST
An alert names the event (id) but not the whole market. Edge and Scale include REST, so a common pattern is to fetch the full price ladder after an alert with GET /kit/v1/details?event_id=<id> (live) or /kit/v1/prematch/markets?event_id=<id>. The Python odds API guide covers those calls and rate-limit handling. If you need the full feed rather than alerts, compare the transports in REST vs WebSocket vs SSE.
To see drops before writing any code, open the live odds drops page. The docs list every field, including the full live object.
Questions readers ask
Which plans include the SSE drop streams?
Drops ($99 per 30 days), Edge ($149) and Scale ($229). Free and REST keys are refused with plan_lacks_sse. A 3-day full demo that opens the streams is available on request from support.
What does min_drop do?
It sets the smallest drop, in percent, that your connection receives. The default is 5 and the minimum is 1, with no maximum. ?min_drop=1 delivers every drop.
Can I filter the stream by sport on the server?
No. The streams carry every sport, so filter on the sport_id field in your client. Don't open a second connection with different settings, because a second stream on the same account closes the first.
What is recheck on the prematch stream?
recheck=N holds each prematch drop for N seconds, reads the line again, and sends the alert only if the drop held. Surviving alerts carry rechecked_ms. The parameter is ignored on the live stream.
How do I know the stream is still alive?
A keepalive comment arrives every 8 seconds, so set a read timeout well above that and reconnect when it fires. REST freshness headers don't exist on SSE. Poll /health alongside if you need to tell a quiet market from a feed problem.
Pull Pinnacle prices into your own code
The PinnRadar odds APIcarries Pinnacle’s live and prematch markets, drop alerts included, over REST, SSE and WebSocket. Compare the plans, or start on a free key: just an email, and no Pinnacle account needed.