Skip to content

Odds Drops

Detecting dropping odds in real time with Server-Sent Events

9 min readPinnRadar team

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/drops buffer.
  • Golf drops only come from the Moneyline section, because golf has no other markets upstream.

Two ways to get drops

GET /api/dropsSSE /odds-drop, /odds-drop-prematch
ModelYou poll a bufferThe server pushes
Look-backAbout 3 hoursFrom the moment you connect
Filterssport_id, min_drop_pct, max_drop_pct, max_age_sec, markets, periods, live, limitmin_drop (plus recheck on prematch)
PlansEvery plan, counts as REST requestsDrops, 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.

ParameterNotes
keyThe API key, in the x-api-key or x-portal-apikey header, or as ?key=
tokenAn older SSE-only credential. You won't need it with a key
min_dropPer-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:

ParameterNotes
min_dropAs above
recheckSeconds. 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 with Retry-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

  1. One SSE connection per account, per stream. Opening a second /odds-drop on 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.
  2. No server-side sport filter. The streams carry every sport. Filter on sport_id in your client.
  3. No freshness headers. X-Data-Status exists on REST only. On a stream, silence is the warning sign. Poll GET /health alongside 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") and point for the line.
  • Period: period (0 is the full game).
  • Match state (live): drop.live.minute, drop.live.score, drop.live.status. Soccer minute counts within the current half and restarts at 0 for the second half.
  • Size: compute (from_price - to_price) / from_price yourself if you want a different cut than min_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.

Get a free key →