Dynamic Odds: Track Real-Time Price Movement in Python (Free API)

Dynamic Odds - OddsPapi API Blog
How To Guides June 3, 2026

Search “dynamic odds” and you’ll get a dozen definitions that all say the same vague thing: “odds that change.” True, but useless if you’re a developer trying to actually capture that movement. The interesting question isn’t that odds move — it’s how fast, in which direction, why, and how you read that signal in code.

This guide answers that. We’ll define dynamic odds properly, explain the two forces that move them (pre-match money and in-play events), and then walk through tracking real price movement in Python against live data — including a real Roland Garros match we captured while it was being played. Every number below came straight off the OddsPapi API, no fabrication.

What Are Dynamic Odds?

Dynamic odds are bookmaker prices that update continuously in response to new information. A static odd is a single number frozen at one moment. A dynamic odd is that same market re-priced over and over — sometimes every few seconds during a live event — as the bookmaker reacts to money, news, and (in-play) the actual run of play.

Two distinct regimes drive the movement:

Regime What moves the price Typical pace
Pre-match Sharp money, team news, weather, lineup confirmation, market balancing (the book shading the line to even out liability) Minutes to hours
In-play (live) Goals, breaks of serve, red cards, momentum — the scoreboard itself Seconds

If you only ever pull a single snapshot, you see a frozen number and none of this. To work with dynamic odds you need two things the data must carry: a timestamp on every price, and access to the full history of prior prices. That’s the whole game.

The Problem: Most APIs Hand You a Frozen Snapshot

The generic odds APIs return a flat blob: bookmaker, market, price. No timestamp telling you when that price was set, and no history endpoint to see where it came from. You’re left polling blindly and diffing payloads yourself — and paying extra for historical data if you want to backfill.

OddsPapi treats odds as the time series they actually are. Every live price ships with a changedAt timestamp, and the full snapshot-by-snapshot price history is on the free tier — the same data competitors gate behind enterprise plans.

The Old Way OddsPapi
Single price, no timestamp Every price carries changedAt
Diff payloads yourself to detect a move Read movement straight from the timestamp
Historical data paywalled Full price history free (/historical-odds)
~40 bookmakers 350+ bookmakers, including Pinnacle & exchanges
Poll-only Real-time WebSocket push on paid tiers

The Two Timestamps That Make Odds “Dynamic”

Everything in this tutorial hangs on two fields:

  • changedAt — on the live /odds endpoint. The moment that exact price was last re-set by the bookmaker. Compare it across polls to know if a price is fresh or stale.
  • createdAt — on the historical /historical-odds endpoint. Each entry in the price history is a snapshot stamped with when it was recorded. Iterate them to reconstruct the entire movement.

Note the response shapes differ: on /odds the current price lives in players["0"] as a single dict; on /historical-odds that same players["0"] is a list of snapshots. Don’t mix them up.

Step 1 — Authenticate

import requests

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"

# apiKey is a QUERY PARAMETER, never a header
def get(path, **params):
    params["apiKey"] = API_KEY
    return requests.get(f"{BASE_URL}/{path}", params=params).json()

print(get("sports")[:1])  # smoke test → 200 + sport list

Step 2 — Find a Live Fixture

Pull fixtures for a sport in a date window, then keep the ones with hasOdds: true. A statusName of "Live" means the market is in-play — where odds move fastest.

# Tennis = sportId 12. Fixtures take from/to (max 10 days apart).
fixtures = get("fixtures", sportId=12, **{"from": "2026-05-29", "to": "2026-06-01"})
live = [f for f in fixtures if f.get("hasOdds") and f.get("statusName") == "Live"]

for f in live[:5]:
    print(f["fixtureId"], f["participant1Name"], "vs", f["participant2Name"])
# id1200257971563726 Fonseca, Joao vs Djokovic, Novak

Step 3 — Read the Live Price and Its Timestamp

Now pull live odds for the fixture and read the current price plus its changedAt. The Winner market for tennis is 121 (outcome 121 = player 1, 122 = player 2). Always resolve market and outcome IDs from /markets rather than hardcoding.

FIXTURE = "id1200257971563726"  # Fonseca vs Djokovic, French Open

# Build name lookups so IDs become human-readable
cat = get("markets", sportId=12)
market_names = {m["marketId"]: m["marketName"] for m in cat}

odds = get("odds", fixtureId=FIXTURE)
pin = odds["bookmakerOdds"]["pinnacle"]["markets"]["121"]["outcomes"]

for oid, o in pin.items():
    cur = o["players"]["0"]            # LIVE: players["0"] is a dict
    print(oid, cur["price"], "changed", cur["changedAt"])
# 121 4.58 changed 2026-05-29T14:45:44...   (Fonseca)
# 122 1.239 changed 2026-05-29T14:45:44...  (Djokovic)

Poll this every few seconds and watch changedAt: when it advances, the price moved. When it doesn’t, the price is unchanged — no need to act. That single field is the difference between a dumb poller and a movement-aware one.

Step 4 — Reconstruct the Full Movement (Free Historical)

This is where dynamic odds become visible. /historical-odds returns every recorded snapshot — for free. Here players["0"] is a list; iterate it to trace the line.

# Top-level key is "bookmakers" here (NOT "bookmakerOdds"). Max 3 books/call.
hist = get("historical-odds", fixtureId=FIXTURE, bookmakers="pinnacle")
djok = hist["bookmakers"]["pinnacle"]["markets"]["121"]["outcomes"]["122"]["players"]["0"]

print(f"{len(djok)} snapshots")
for snap in djok[::40]:              # every 40th snapshot
    flag = "" if snap["active"] else "  [SUSPENDED]"
    print(snap["createdAt"][:19], snap["price"], flag)

For Djokovic’s price across the day before and during the match, the real history looked like this:

Time (UTC) Djokovic price Phase
May 28, 08:09 1.483 Market open — a real contest priced in
May 29, 11:29 1.526 Pre-match drift (money on Fonseca)
May 29, 13:08 1.502 Tip-off
May 29, 14:09 1.312 In-play — Djokovic takes control
May 29, 14:25 1.184 In-play — pulling away
May 29, 14:45 1.239 Fonseca claws back; price drifts out again

Pinnacle logged 302 snapshots on this single market in about 30 hours. The pre-match price barely twitched (1.48–1.53). The moment the match went live, it swung hard — Djokovic shortened from 1.50 to 1.14 as he took control, then drifted back to 1.24 when Fonseca steadied. Fonseca’s side ranged from 2.49 all the way to 7.12. That is what “dynamic” actually means, rendered as data.

Step 5 — Measure a Move

Detecting and quantifying movement is a few lines. Compare consecutive active snapshots and flag anything past a threshold — the foundation of a steam-move detector.

def biggest_moves(snapshots, pct_threshold=5.0):
    moves, prev = [], None
    for s in snapshots:
        if not s["active"]:           # skip suspended ticks
            continue
        if prev is not None:
            change = (s["price"] - prev["price"]) / prev["price"] * 100
            if abs(change) >= pct_threshold:
                moves.append((s["createdAt"][:19], round(change, 1),
                              prev["price"], s["price"]))
        prev = s
    return moves

for t, pct, a, b in biggest_moves(djok):
    print(f"{t}  {pct:+.1f}%  {a} -> {b}")
# In-play ticks show repeated -5% to -8% steps as Djokovic pulls away

Step 6 — Handle Suspension

During live play, books briefly suspend a market (a point in progress, a goal under review). In the data that shows up as active: false — the price is stale and untradeable until it reopens. Pinnacle suspended this market 13 times in our capture. Always filter on active before you trust a live price:

cur = pin["122"]["players"]["0"]
if cur["active"]:
    use(cur["price"])
else:
    pass  # market suspended — do not act on this number

Pre-Match vs In-Play: Two Different Animals

The same fixture gives you two very different signals depending on the phase:

  • Pre-match movement is information. A line that drifts from 1.48 to 1.53 over hours is the market digesting money and news. Sharp books like Pinnacle lead; soft books follow. The lag between them is a tradeable edge — see our live betting API guide.
  • In-play movement is the scoreboard. A swing from 1.50 to 1.14 in twenty minutes isn’t a balancing act — it’s Djokovic breaking serve. The edge here is latency: catching a soft book that hasn’t re-priced after an event the sharp books already absorbed. That’s why the WebSocket feed exists.

Across this match, 15 bookmakers priced the Winner market simultaneously — Pinnacle, Bet365, DraftKings, FanDuel, BetMGM, Caesars, BetRivers, William Hill, plus Kalshi and Polymarket from the prediction-market side. Each carries its own changedAt, so you can see exactly which book reacted first and which lagged.

Why This Matters for What You’re Building

If you’re building… Dynamic-odds field you need
Steam / line-movement alerts changedAt deltas across polls
Closing-line value (CLV) tracking /historical-odds open → close
Backtesting a model Full snapshot history (free)
Live arbitrage / middling Per-book changedAt + active flag
In-play trading dashboards WebSocket push (paid tier)

You can export the whole history to a spreadsheet too — see exporting historical odds to CSV/Excel — or study a single book’s movement in depth in our Bet365 historical odds guide.

Get Started

Dynamic odds aren’t a feature you bolt on — they’re the native shape of bookmaker data once you have timestamps and history. Stop diffing blind payloads. Get your free OddsPapi API key, pull a live fixture, and watch the price move in real time across 350+ books.

FAQ

What are dynamic odds?

Dynamic odds are bookmaker prices that update continuously as new information arrives — money, team news, and (in-play) the events of the match itself. They’re the opposite of a static, one-time snapshot. With OddsPapi, every live price carries a changedAt timestamp and the full price history is available for free.

How do I track odds movement in Python?

Pull live odds from /odds and read the changedAt field on each price — when it advances between polls, the price moved. For the complete movement, call /historical-odds, which returns every recorded snapshot with a createdAt timestamp so you can reconstruct the entire line.

Is historical odds data free on OddsPapi?

Yes. The full snapshot-by-snapshot price history is available on the free tier via /historical-odds (max 3 bookmakers per call). Most competitors charge for this.

How fast do in-play odds change?

During live events, sharp books re-price within seconds of an event like a goal or break of serve. In our captured tennis match, Pinnacle logged 302 snapshots on one market in about 30 hours, with the steepest moves happening in-play.

What does active: false mean in the odds data?

It means the bookmaker has temporarily suspended that market — common during a live point or while an event is under review. The price is stale and untradeable until the market reopens, so always filter on active before acting on a live number.