Catching Live Odds Drops with SSE: A World Cup 2026 Build
Build a live odds-drop monitor for the 2026 World Cup with Server-Sent Events: connect, filter signal from noise, and survive reconnects.
Catching Live Odds Drops with SSE: A World Cup 2026 Build
During a live match, the interesting data isn't the current price — it's the change. This is a build guide for a monitor that watches Pinnacle drops over Server-Sent Events during the 2026 World Cup, keeps only the moves that matter, and doesn't fall over when the connection blips.
Polling is the wrong tool for live markets
A REST poll gives you a snapshot on a timer. If you poll every two seconds, your worst-case staleness is two seconds plus the round trip — and during a live match that's enough to miss the move entirely. SSE inverts the model: the server pushes the change the instant it happens, so your latency is network transit, not your loop interval. In our benchmark that's about 15–40 ms end-to-end (your region's number will differ).
What a "drop" actually is
A drop is a meaningful downward move in a price — the market repricing one outcome as more likely. Not every wiggle is signal; prices jitter constantly. The job is separating a real, directional move from noise, which is what the filtering below is for.
Step 1: get a key, confirm the feed
curl -i -H "x-portal-apikey: $PINNAPI_KEY" \
"https://pinnapi.com/health"
A 200 means you're good. Don't write stream logic until this passes — debugging a stream that was never authenticated wastes an evening.
Step 2: connect to the drop stream
import { EventSource } from "eventsource";
const es = new EventSource(`https://pinnapi.com/odds-drop?key=${process.env.PINNAPI_KEY}&min_drop=5`);
es.onopen = () => console.log("stream open");
es.onmessage = (e) => {
const payload = JSON.parse(e.data);
if (payload.type === "connected") return; // first frame is a handshake
for (const drop of payload) handleDrop(drop); // each frame is an array of drops
};
es.onerror = (err) => console.error("stream error", err);
Each drop looks like { sport, home, away, sect, outcome, from_price, to_price, nvp, dispatched_ms, ... }.
Step 3: cut the noise down to signal
Two filters do most of the work, and here's why these defaults:
- Magnitude ≥ 3%. Below ~3%, you're mostly catching normal market breathing — bid/ask churn and tiny re-centering. 3% is a reasonable floor for "something happened," but it's a starting point: tune it per sport and market, because a 3% move in a low-scoring soccer moneyline is more meaningful than 3% in a volatile in-play total.
- Velocity: ≥ 3% within a 10-second window. A slow 3% drift over five minutes is just the market updating; the same 3% inside ten seconds is a reaction to an event (a goal, a red card, a sharp coming in). The tight window is what isolates reactions from drift.
const WINDOW_MS = 10_000;
const MIN_MAGNITUDE = 0.03;
const recent = new Map(); // `${id}:${sect}` -> [{to, ts}]
function handleDrop(d) {
const key = `${d.id}:${d.sect}`;
const magnitude = (d.from_price - d.to_price) / d.from_price;
if (magnitude < MIN_MAGNITUDE) return;
const hist = (recent.get(key) || []).filter(p => d.dispatched_ms - p.ts <= WINDOW_MS);
hist.push({ to: d.to_price, ts: d.dispatched_ms });
recent.set(key, hist);
const oldest = hist[0];
const windowMove = (oldest.to - d.to_price) / oldest.to;
if (windowMove >= MIN_MAGNITUDE) {
onSignal(d, windowMove); // a fast, directional move — act
}
}
Treat these numbers as hypotheses to validate against your own logged data, not as laws. The next post in this series is about exactly that — measuring whether your thresholds actually predict anything.
Reconnects: stream for speed, REST for recovery
Streams drop. Phones change networks, load balancers cycle, matches run for two hours. The robust pattern is: stream for live speed, REST snapshot for recovery. On reconnect, don't try to replay missed events — pull one REST snapshot to re-sync state, then resume the stream.
es.onerror = async () => {
await backfillViaRest(); // GET /kit/v1/markets to re-sync
// EventSource auto-reconnects; state is now correct
};
Prematch and live are the same fixture, different phases
A useful simplification: a fixture doesn't change identity at kickoff, it changes phase. The same event_id carries from event_type=prematch to event_type=live, so you can subscribe before the match and keep the same handler when it goes live — no special-casing the transition.
The takeaway
The speed comes from SSE; the usefulness comes from filtering. A raw drop stream is firehose noise. Magnitude plus a tight velocity window turns it into a short list of moves worth acting on — and you should treat the exact thresholds as something to measure, not assume.
FAQ
How fast does pinnapi deliver World Cup odds? ~15–40 ms end-to-end in our benchmark; measure your own region before relying on it.
What is an odds drop and how do I detect it? A meaningful downward price move; detect it with a magnitude floor plus a short velocity window to filter out drift.
How do I detect odds drops programmatically? Subscribe to a push stream that watches every price server-side and emits an event the instant one falls past your threshold — polling snapshots and diffing them yourself is the slow, expensive version of the same job.
How do I set a threshold for odds drop alerts? Pass ?min_drop=N on the stream URL (percent of the pre-drop price; default 5, floor 1) and the server filters for you — then tighten further client-side if your strategy wants it. Think in probability terms, not raw decimals.
Can I use the SSE drop stream on the free trial? The free tier covers REST for learning; live drop streams are a paid capability.
How should I handle SSE disconnects during a match? Re-sync with one REST snapshot on reconnect rather than replaying the stream.
Get real-time Pinnacle odds in your code
Live & prematch markets with sub-second odds-drop alerts. Free trial key in seconds — no card.
Start free trial