Fixture Mapping for Compliance: Join OddsPapi, Betradar & Genius IDs (Python)

Fixture Mapping for Compliance - OddsPapi API Blog
How To Guides July 13, 2026

Two Feeds, One Match: The Fixture Mapping Problem

If you run a regulated book or a trading desk, you almost certainly consume more than one data feed. A licensed stats provider (Betradar, Genius Sports) handles official scores and settlement, because your regulator requires an audited source of truth. Then you pull pricing from somewhere cheaper and broader, because a single licensed feed only carries the books its owner wants you to see.

The moment you run two feeds, you inherit a join problem: which row in feed A is the same real-world match as which row in feed B? Betradar calls Spain vs Saudi Arabia event 66456998. Genius calls it 15457158. Your pricing layer calls it something else entirely. Get the join wrong and you settle bets against the wrong result, which in a compliance audit is not a bug, it is a liability.

OddsPapi aggregates pricing from 350+ bookmakers, and every fixture it returns ships with a pre-computed cross-provider ID map. You do not fuzzy-match team names. You read a key. This post shows the mapping table, the fill rates across providers, and a tested Python join that reconciles OddsPapi pricing back to your Betradar or Genius settlement feed.

The Old Way: Fuzzy Matching on Names and Kickoff Time

Without shared IDs, teams reconcile feeds by matching on participant name plus kickoff timestamp. It works until it doesn’t, and it fails in exactly the situations that matter most for compliance.

Approach Name + Kickoff Fuzzy Match externalProviders ID Join (OddsPapi)
“Man Utd” vs “Manchester United” Breaks on naming variants Exact ID, no string logic
Kickoff rescheduled 30 min Timestamp drifts, match fails ID is stable through reschedules
Two same-name fixtures same day Ambiguous, picks wrong one Unique per event
Audit defensibility “We think it matched” Deterministic key, logged
Maintenance Hand-tuned alias tables Read one JSON field

The fuzzy approach also degrades silently. A name-normaliser that worked for the Premier League quietly mismatches a Brazilian Série B fixture at 2am, and you don’t find out until a settlement dispute. An ID join either resolves or returns null, and a null is something you can alert on.

The externalProviders Block

Every fixture from GET /v4/fixtures carries an externalProviders object. It is the same event, expressed in ten different providers’ ID systems:

"externalProviders": {
  "betradarId": 66456998,
  "betgeniusId": 15457158,
  "pinnacleId": 1620858188,
  "opticoddsId": "20260621440DD3DB",
  "sofascoreId": 15186840,
  "flashscoreId": "CASh7QGF",
  "lsportsId": 18487380,
  "mollybetId": "2026-06-21,458,4834",
  "txoddsId": null,
  "oddinId": null
}

That is the real block for Spain vs Saudi Arabia (World Cup, OddsPapi id1000001666456998), pulled live. The OddsPapi fixtureId is your primary key; the ten provider fields are foreign keys into everyone else’s world.

Coverage: Which IDs Are Actually Populated

Not every provider stamps every fixture. We sampled a five-day soccer window (1,011 fixtures) and measured how often each ID is present. Betradar is the workhorse, populated on 100% of fixtures, which makes it the safest universal join key:

Provider Field Fill Rate (soccer) Type
Betradar (Sportradar) betradarId 100% Integer
Pinnacle pinnacleId 57% Integer
Flashscore flashscoreId 51% String hash
OpticOdds opticoddsId 35% String hash
Genius Sports (BetGenius) betgeniusId 25% Integer
LSports lsportsId 18% Integer
Sofascore sofascoreId 16% Integer
Mollybet mollybetId 8% Composite string
TXOdds txoddsId rare Integer
Oddin oddinId rare (esports) Integer

The practical takeaway: build your join on betradarId if your settlement feed is Betradar, and keep betgeniusId as the fallback if you are a Genius shop. Don’t assume the long-tail providers are present, code defensively for null.

The Tutorial: Build a Cross-Provider Mapping Table

Step 1 — Authenticate

OddsPapi auth is a query parameter, never a header. One key, every endpoint.

import requests

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

# Smoke test
r = requests.get(f"{BASE}/sports", params={"apiKey": API_KEY})
print(r.status_code)  # 200

Step 2 — Pull Fixtures and Extract the Map

Hit /fixtures for a sport and date range (max 10 days apart), then flatten externalProviders into a clean row per match. This is your raw mapping table.

def fixture_map(sport_id, frm, to):
    r = requests.get(f"{BASE}/fixtures", params={
        "apiKey": API_KEY, "sportId": sport_id, "from": frm, "to": to,
    })
    r.raise_for_status()
    rows = []
    for f in r.json():
        ep = f.get("externalProviders") or {}
        rows.append({
            "oddspapi_id": f["fixtureId"],
            "match": f"{f['participant1Name']} v {f['participant2Name']}",
            "kickoff": f["startTime"],
            "status": f.get("statusName"),
            "betradar": ep.get("betradarId"),
            "genius": ep.get("betgeniusId"),
            "pinnacle": ep.get("pinnacleId"),
            "opticodds": ep.get("opticoddsId"),
            "sofascore": ep.get("sofascoreId"),
        })
    return rows

rows = fixture_map(10, "2026-06-21", "2026-06-24")
print(len(rows), "fixtures mapped")  # 685

Step 3 — Build the Reverse Index (Their ID → OddsPapi ID)

Your settlement feed pushes you a Betradar event ID. You need to go the other way: from their ID back to OddsPapi’s fixtureId so you can attach pricing. Build a dictionary keyed by the provider you settle against.

# Reverse index: betradar event id -> oddspapi fixture id
by_betradar = {r["betradar"]: r["oddspapi_id"]
               for r in rows if r["betradar"]}

print(len(by_betradar), "fixtures keyed by Betradar id")  # 685

# A Betradar event id arrives from your settlement feed:
betradar_event = 66456998
opi_id = by_betradar.get(betradar_event)
print(opi_id)  # id1000001666456998

Because betradarId fills at 100%, this reverse index never silently drops a fixture. If you key on a sparser provider, guard the .get() and route misses to a manual-review queue rather than dropping them.

Step 4 — Join Pricing onto the Mapped Fixture

One detail that trips people up: the /odds endpoint does not return externalProviders. Pricing and mapping live in different responses. The shared key between them is the OddsPapi fixtureId. So the workflow is: map on /fixtures, then fetch pricing on /odds by the fixture id you resolved.

def pricing_for_external(provider_index, external_id):
    opi_id = provider_index.get(external_id)
    if opi_id is None:
        return None  # send to manual-review queue
    o = requests.get(f"{BASE}/odds", params={
        "apiKey": API_KEY, "fixtureId": opi_id,
    }).json()
    return o.get("bookmakerOdds", {})

books = pricing_for_external(by_betradar, 66456998)
print(len(books), "books priced via the Betradar -> OddsPapi join")  # 18
print(sorted(books)[:6])
# ['ballybet', 'bet365', 'betmgm', 'betparx', 'betrivers', 'borgata']

That is the whole reconciliation loop: your regulator-facing feed says “event 66456998 finished,” you resolve it to id1000001666456998, and you have 18 books of pricing history attached to the same row your settlement engine just closed.

Step 5 — Persist It for the Audit Trail

Compliance is not just about getting the join right today, it is about proving you got it right last March. Write the mapping table to a store with a captured timestamp, so every settled bet can be traced back to the exact ID resolution that produced it.

import csv

with open("fixture_map.csv", "w", newline="") as fh:
    w = csv.DictWriter(fh, fieldnames=rows[0].keys())
    w.writeheader()
    w.writerows(rows)
# Re-run on a schedule; keep dated snapshots as your reconciliation ledger.

Pair this with OddsPapi’s free historical odds endpoint and your audit trail covers both the mapping and the price history behind every graded market, at no extra cost.

Worked Example: Spain vs Saudi Arabia

Here is the full resolution for one live World Cup fixture, every value pulled from the API:

System Identifier
OddsPapi fixtureId id1000001666456998
Betradar 66456998
Genius Sports 15457158
Pinnacle 1620858188
OpticOdds 20260621440DD3DB
Sofascore 15186840
Flashscore CASh7QGF
LSports 18487380
Books priced 18 (Pinnacle, Bet365, FanDuel, DraftKings, Caesars, Kalshi, Polymarket, …)

One /fixtures call gave us the seven-way ID map. One /odds call on the resolved fixtureId gave us 18 books, including Pinnacle’s sharp line and two prediction markets. No name normalisation, no timestamp matching.

Honest Caveats

  • Pricing and mapping are separate responses. /odds returns pricing keyed by fixtureId but no externalProviders. Always resolve the map from /fixtures first.
  • Long-tail IDs are sparse. Betradar is universal on soccer; Genius, LSports, Sofascore and Mollybet are partial. Build on the provider you actually settle against, and null-guard the rest.
  • Virtual and simulated fixtures exist. In our 685-fixture window, 34 were simulated-reality-league (“SRL”) events. They still carry a betradarId, but they settle on a different clock, so filter them out of regulated-market reconciliation by tournament.
  • Fill rates vary by sport and season. The numbers above are a June soccer snapshot. Re-measure for your sport mix; esports leans on oddinId, US majors on opticoddsId.

Why This Beats a Single Licensed Feed

A licensed stats provider gives you one authoritative result feed and the books it chooses to expose. OddsPapi sits next to it as the pricing layer: 350+ bookmakers, sharps (Pinnacle, SBOBet), exchanges, prediction markets and crypto books, with native market structures (Asian handicaps, player props) carried as first-class IDs rather than flattened strings. The externalProviders map is what lets the two coexist, your regulator-approved feed stays the source of truth for settlement, and OddsPapi feeds the price discovery, CLV audit and risk signals around it, joined on a deterministic key.

Get Your Free API Key

The /fixtures endpoint, the externalProviders map, and free historical odds for your audit trail are all on the free tier. Grab a free OddsPapi API key and wire the join into your reconciliation pipeline today.

Frequently Asked Questions

How do I map an OddsPapi fixture to a Betradar event ID?

Call GET /v4/fixtures and read the externalProviders.betradarId field on each fixture. It is populated on 100% of soccer fixtures we sampled, so it works as a universal join key between OddsPapi pricing and a Betradar settlement feed.

Does the OddsPapi odds endpoint include external provider IDs?

No. /v4/odds returns pricing keyed by the OddsPapi fixtureId but does not carry the externalProviders block. Resolve the ID map from /v4/fixtures first, then fetch pricing by fixtureId.

Which external provider IDs does OddsPapi expose?

Ten: Betradar, Genius Sports (BetGenius), Pinnacle, OpticOdds, Sofascore, Flashscore, LSports, Mollybet, TXOdds and Oddin. Betradar has the highest fill rate; the others are partial and should be null-guarded.

Why not just match on team name and kickoff time?

Name plus timestamp matching breaks on naming variants, rescheduled kickoffs and same-name fixtures on the same day, and it fails silently. An ID join is deterministic and auditable: it either resolves or returns null, which you can alert on.

Is the externalProviders mapping free?

Yes. It ships on every /v4/fixtures response on the free tier, alongside free historical odds you can use as a reconciliation ledger.