Dropping odds API · SSE + REST

Dropping odds API: Pinnacle drops, pushed to your code.

PinnRadar finds falling Pinnacle prices on the server, for both live and prematch markets, and streams each one to your application over Server-Sent Events. Every alert carries the old price, the new price and the no-vig fair price, and live alerts add the match state.

Server-side detection · live + prematch · independent, not affiliated with Pinnacle

Streams
Live + prematch
Default threshold
5%
Lowest min_drop
1%
Poll buffer
About 3 h

01 · Definition

What counts as a drop.

Looking for the board rather than the API? The Pinnacle dropping odds page lists the latest drops in your browser.

A drop is recorded when the decimal price of an outcome falls by at least a threshold inside a set time window. Detection runs on the server for both the live and the prematch feed, so your client never has to hold a copy of the book or compare snapshots.

The engine records every fall above a floor of roughly 0.99%. What reaches you is filtered per connection by min_drop: 5% by default, 1% at the lowest, no maximum. ?min_drop=1 delivers every drop, small ones included.

Each drop includes nvp, the outcome's fair price with the bookmaker margin removed. That makes a first edge measure one division: edge = to / nvp - 1.

Scope worth knowing:

  • Main markets alert: moneyline, spreads, totals and team totals
  • Props never fire the SSE streams; of the specials only BTTS and Draw No Bet do
  • Prop moves still show in /api/drops, with participant_name and nvp: null
  • Golf drops only come from the Moneyline section
  • Prematch volume is low: usually under one drop a second over a day, against tens a second on the live stream at peak

02 · On the wire

One hello, then arrays of drops.

The first frame is {"type": "connected"}. Every frame after it is a JSON array of one or more drops, each with home, away, league, sport and sport_id, the event id and starts, the market section sect (Moneyline, Total and so on), period, outcome, the line in point where relevant, from_price, to_price, nvp, interval, alerted and the score.

On live drops the last key is live, a snapshot of the match when the price fell: status, score, soccer minute, cards and corners, basketball quarter and clock, tennis sets, games and points with the server, and the moneyline favorite. It is absent when no state is published for the event, as with most esports. The live object reference lists every key.

sse/odds-drop, one live soccer drop
data: {"type": "connected", "id": "5f0c1a92-7e3d-..."}

data: [{"home": "Hammarby", "away": "Djurgarden",
        "league": "Sweden - Allsvenskan",
        "from_price": 2.41, "to_price": 2.26,
        "outcome": "Home", "period": 0, "sect": "Moneyline",
        "id": 1632907731, "sport": "Soccer", "sport_id": 1,
        "nvp": 2.33, "home_score": 1, "away_score": 1, ...,
        "live": {"status": "2nd Half - 22'", "minute": 22,
                 "score": {"home": 1, "away": 1},
                 "red_cards": {"home": 0, "away": 1},
                 "favorite": "home", ...}}]

03 · Consume it

Python and Node, SDK optional.

Any HTTP client that can read a stream will do, or pip install pinnradar / npm install pinnradar for a client that reconnects on its own. Run the reader in a retry loop with backoff, and open one stream per account: a second connection on the same key closes the first. Live and prematch use separate slots, so one of each can run together.

pythonlive drops of 7% or more
import json, os, time
import requests

BASE = "https://pinnradar.com"
KEY = os.environ["PINNRADAR_KEY"]

def drops(path="/odds-drop", **params):
    params["key"] = KEY
    wait = 1
    while True:
        try:
            with requests.get(BASE + path, params=params,
                              stream=True, timeout=(5, None)) as r:
                r.raise_for_status()
                wait = 1
                for line in r.iter_lines(decode_unicode=True):
                    if not line or not line.startswith("data:"):
                        continue
                    frame = json.loads(line[5:])
                    if isinstance(frame, list):
                        yield from frame
                    elif frame.get("error") == "plan_lacks_sse":
                        raise SystemExit("this plan has no SSE")
                    elif frame.get("error") == "rate_limited":
                        wait = 60
                        break
        except requests.RequestException:
            pass
        time.sleep(wait)
        wait = min(wait * 2, 60)

for d in drops(min_drop=7):
    if d["sport_id"] != 1:          # soccer only
        continue
    edge = d["to_price"] / d["nvp"] - 1 if d.get("nvp") else None
    status = (d.get("live") or {}).get("status")
    print(d["home"], "v", d["away"], d["sect"], d["outcome"],
          d["from_price"], "->", d["to_price"], edge, status)
node 18+prematch drops that held 30 s
const BASE = "https://pinnradar.com";
const KEY = process.env.PINNRADAR_KEY;

async function follow(path, onDrop) {
  const resp = await fetch(`${BASE}${path}&key=${KEY}`, {
    headers: { accept: "text/event-stream" },
  });
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  const decoder = new TextDecoder();
  let pending = "";
  for await (const chunk of resp.body) {
    pending += decoder.decode(chunk, { stream: true });
    let cut;
    while ((cut = pending.indexOf("\n\n")) !== -1) {
      const block = pending.slice(0, cut);
      pending = pending.slice(cut + 2);
      const data = block.split("\n")
        .filter((l) => l.startsWith("data:"))
        .map((l) => l.slice(5).trim()).join("");
      if (!data) continue;
      const frame = JSON.parse(data);
      if (Array.isArray(frame)) frame.forEach(onDrop);
      else if (frame.type === "error") throw new Error(frame.error);
    }
  }
}

await follow("/odds-drop-prematch?min_drop=5&recheck=30", (d) => {
  console.log(d.sport, d.home, "v", d.away, d.sect, d.outcome,
    d.from_price, "->", d.to_price, `held ${d.rechecked_ms} ms`);
});
shellquick test
curl -N "https://pinnradar.com/odds-drop?key=$PINNRADAR_KEY&min_drop=3"

04 · Filters

Three endpoints, one detector.

The streams take a threshold; sport, league and market filtering happens in your handler. The polling buffer filters on the server.

EndpointDeliveryParameters
/odds-dropSSE push, in-playmin_drop (default 5, minimum 1)
/odds-drop-prematchSSE push, pre-gamemin_drop, recheck seconds (hold and confirm)
/api/dropsREST poll, last ~3 hmode, sport_id, min_drop_pct, max_drop_pct, max_age_sec, markets, periods, live, limit

Always send max_age_sec to /api/drops. Its rows add drop_pct, age_s and a per-drop ts in epoch ms, the most precise per-change timestamp in the API. The same buffer answers the price_moves tool on the MCP server, so an assistant can ask for recent steam in plain language.

shellprematch totals, 2%+ in the last hour
curl -sG https://pinnradar.com/api/drops \
  -H "x-portal-apikey: $PINNRADAR_KEY" \
  -d mode=prematch -d min_drop_pct=2 \
  -d max_age_sec=3600 -d markets=total

05 · Plans

Drop streams on Drops, Edge and Scale.

Free and REST keys are refused on the streams, except during the three-day full demo every new account starts with. /api/drops counts as an ordinary request on any plan.

PlanPriceRESTSSE drop streamsWebSocket
Free$020/min, 100/dayNoNo
Drops$99 / moFree limitsYesNo
REST$99 / mo10 req/sNo+$99 / mo add-on
Edge$149 / mo10 req/sYes+$99 / mo add-on
Scale$229 / mo30 req/sYes+$99 / mo add-on

Billing terms and payment methods are on the pricing page. For a step-by-step build, read streaming dropping odds over SSE; for why SSE fits alerts better than polling, see REST vs WebSocket vs SSE.

06 · Questions

About the drop feed.

Anything else goes to @PinnRadarSupport on Telegram or info@pinnradar.com, answered by the people who run the feed.

What is a dropping odds API?

An endpoint that tells your code when a price falls, instead of leaving you to diff whole boards yourself. PinnRadar runs the detection on its servers for Pinnacle's live and prematch markets and pushes each drop over Server-Sent Events, with a polling endpoint for batch jobs.

How does PinnRadar decide that a price dropped?

A drop is recorded when an outcome's decimal price falls by at least a threshold within a set time window. The engine's floor is roughly 0.99%. Each SSE connection then applies its own min_drop, 5% unless you set it, with 1% as the lowest value. Details are in the docs.

Which plans include the SSE drop streams?

Drops ($99 a month), Edge ($149) and Scale ($229). A Free or REST key gets a single plan_lacks_sse frame, except during the three-day full demo every new account starts with. The /api/drops buffer is a normal REST call. Plans are on the pricing page.

Can I get only prematch drops that held?

Yes. Add recheck=N to /odds-drop-prematch. Each drop is held for N seconds, the line is read again, and the alert goes out only if the lower price is still there and still meets the 5% detector threshold. Reverted drops are discarded silently.

Do live drops include the score?

Live drops end with a live object: status, score, the soccer minute, cards and corners, the basketball quarter and clock, tennis sets, games and points, and the moneyline favourite at that moment. It is absent when no state is published for the event, which is true of most sports other than soccer, tennis, basketball and hockey.

Are player props included?

Props never trigger the SSE streams; among specials only BTTS and Draw No Bet alert there. Prop moves do appear in /api/drops, named in participant_name and with nvp set to null, since a many-outcome ladder has no two-sided margin to remove.

Is a drop a tip to bet?

No. PinnRadar supplies data, not betting advice. A drop shows that the market moved; what it means is for your own model to decide. The closing line value guide shows one way to measure that.

Watch the drops, then stream them.

Browse the latest moves on the public board or the free hourly Telegram channel, then put the same alerts in your own code. Sign up and the first three days include the full streams, so you can test them right away.