DEV Community

Cover image for Building a Provider-Agnostic Sports Data Client: What 10 Different APIs Teach You About Design
orbistats
orbistats

Posted on

Building a Provider-Agnostic Sports Data Client: What 10 Different APIs Teach You About Design

Why you need a provider-agnostic client

Most sports apps start with one API and one hard-coded JSON shape. Six months later the vendor changes pricing, drops a league, or you need a second source for odds, and suddenly the vendor’s field names are spread across your whole codebase.

The fix is old and boring: put every vendor behind an adapter and make your app depend only on your own models. This tutorial builds that client in Python, using ten real providers as the design reference.

Read this first: I work with Orbistats, which is one of the ten providers. I did not run performance tests for this post, so there are no benchmark numbers here. Everything about vendors comes from their public pages as of 12 October 2026, and you should verify it before relying on it.

The ten providers and how they differ

The differences between vendors are exactly why an adapter layer is worth building:

Sportradar: enterprise, quote-based, claims 80+ sports and 500+ leagues, push on selected packages.
Stats Perform (Opta): premium analytics, tailored licensing.
Genius Sports: official data and integrity, custom quote.
Sportmonks: self-serve football, plans metered by number of leagues.
SportsDataIO: US leagues and fantasy, product-specific pricing.
Orbistats: 13 sports, odds, WebSocket, webhooks, history, access passes instead of monthly plans.
OddsPapi: odds-focused, details unverified, check the live page.
SportsGameOdds: odds-first, billed by returned objects.
API-Football: football only, billed by daily requests.
The Odds API: odds, quota scoped by sport, market and region.

Notice what varies: authentication style, what is metered (requests, objects, leagues, time), whether push delivery exists, and the shape of the JSON. Your client must hide all of that.

Architecture overview

The design has five small pieces:

Models: your own dataclasses, the only types the rest of the app sees.
Provider adapters: one class per vendor, converting vendor JSON into your models.
HTTP helper: retries, backoff and rate-limit handling in one place.
Cache and failover: protect your quota and survive an outage.
Streaming layer: WebSocket with reconnects, and a webhook receiver.
Step 1: Define your own models

Create models.py:

python
from dataclasses import dataclass
from typing import Optional

@dataclass
class LiveMatch:
provider: str
match_id: str
minute: Optional[int]
home_name: str
home_score: int
away_name: str
away_score: int

@dataclass
class OneXTwo:
home: float
draw: float
away: float

Your UI, your database and your analytics code import only these. Vendor field names never leave the adapter.

Step 2: One robust HTTP helper

Every vendor rate-limits differently, so handle it once. Create http_client.py:

python
import random
import time
import requests

RETRY_STATUS = {429, 500, 502, 503, 504}

def get_json(url, headers=None, params=None, retries=3, timeout=10):
last_error = None
for attempt in range(retries + 1):
delay = pow(2, attempt) / 2
try:
r = requests.get(url, headers=headers or {}, params=params or {}, timeout=timeout)
if r.status_code not in RETRY_STATUS:
r.raise_for_status()
return r.json()
retry_after = r.headers.get("Retry-After", "")
if retry_after.isdigit():
delay = float(retry_after)
last_error = "HTTP " + str(r.status_code)
except requests.RequestException as e:
last_error = str(e)
if attempt < retries:
time.sleep(delay + random.uniform(0, 0.3))
raise RuntimeError("request failed after retries: " + str(last_error))

Two details matter here. Exponential backoff with a little random jitter stops many clients retrying in lockstep, and honoring Retry-After respects whatever the vendor tells you.

Step 3: The provider interface

Create providers.py with a base class:

python
from abc import ABC, abstractmethod

class SportsProvider(ABC):
name = "base"

@abstractmethod
def live_matches(self, sport):
    pass
Enter fullscreen mode Exit fullscreen mode

The Orbistats adapter

Orbistats documents a base URL of https://api.orbistats.com/v1/ with bearer-token authentication, and a live endpoint shaped like GET /v1/football/matches/live. The live example on its homepage returns match_id, status, minute, and home and away objects with name and score. Check the API reference for the exact response shape before shipping, in particular whether the endpoint returns one object or a list.

python
from http_client import get_json
from models import LiveMatch

class OrbistatsProvider(SportsProvider):
name = "orbistats"
BASE = "https://api.orbistats.com/v1"

def __init__(self, api_key):
    self.headers = {"Authorization": "Bearer " + api_key}

def live_matches(self, sport="football"):
    data = get_json(self.BASE + "/" + sport + "/matches/live", headers=self.headers)
    items = data if isinstance(data, list) else [data]
    return [
        LiveMatch(
            provider=self.name,
            match_id=str(m["match_id"]),
            minute=m.get("minute"),
            home_name=m["home"]["name"],
            home_score=m["home"]["score"],
            away_name=m["away"]["name"],
            away_score=m["away"]["score"],
        )
        for m in items
    ]
Enter fullscreen mode Exit fullscreen mode

Writing the next adapter

For any other vendor, copy this class, change the URL and auth header from that vendor’s docs, and rewrite only the mapping inside live_matches. For example, API-Football authenticates with its own key header and The Odds API takes the key as a query parameter, but confirm both in their documentation (API-Football, The Odds API) before coding. That is the whole point of the pattern: the difference lives in one small class.

Step 4: Cache to protect your quota

Request-metered plans punish naive polling. A tiny time-based cache is often the cheapest optimization you will ever make.

python
import time

class TtlCache:
def init(self, ttl_seconds):
self.ttl = ttl_seconds
self.store = {}

def get(self, key):
    item = self.store.get(key)
    if item and time.monotonic() - item[0] < self.ttl:
        return item[1]
    return None

def set(self, key, value):
    self.store[key] = (time.monotonic(), value)
Enter fullscreen mode Exit fullscreen mode

Pick the TTL per data type: fixtures and standings can live for minutes, live scores for seconds.

Step 5: Failover between providers

Create client.py:

python
class SportsClient:
def init(self, providers, cache):
self.providers = providers
self.cache = cache

def live_matches(self, sport="football"):
    key = "live:" + sport
    cached = self.cache.get(key)
    if cached is not None:
        return cached

    errors = {}
    for provider in self.providers:
        try:
            result = provider.live_matches(sport)
            self.cache.set(key, result)
            return result
        except Exception as e:
            errors[provider.name] = str(e)
    raise RuntimeError("all providers failed: " + str(errors))
Enter fullscreen mode Exit fullscreen mode

Use it like this:

python
import os
from client import SportsClient
from providers import OrbistatsProvider
from cache import TtlCache

client = SportsClient(
providers=[OrbistatsProvider(os.getenv("ORBISTATS_KEY", ""))],
cache=TtlCache(ttl_seconds=5),
)

for match in client.live_matches("football"):
print(match.home_name, match.home_score, "-", match.away_score, match.away_name)

Add a second provider to the list and failover works with no other change. Remember that failing over between vendors can change data rights and coverage, so check the licence of each source before you mix them.

Step 6: Normalize odds across bookmakers

Odds are where schemas diverge most. Convert everything to decimal odds in your own model, then compute implied probability and the bookmaker margin. The Orbistats Odds API advertises normalized 1X2, moneyline, spreads, totals and props across books, and its docs example shows a 1X2 market like home 1.91, draw 3.40, away 4.20.

python
from models import OneXTwo

def implied_probabilities(odds):
return {
"home": 1 / odds.home,
"draw": 1 / odds.draw,
"away": 1 / odds.away,
}

def overround(odds):
return sum(implied_probabilities(odds).values())

example = OneXTwo(home=1.91, draw=3.40, away=4.20)
print(round(overround(example), 4))

For that example the three implied probabilities add up to roughly 1.056, meaning the book builds in about a 5.6 percent margin. Comparing that number across vendors for the same fixture is a quick sanity check on data quality. If you are new to these terms, the Orbistats glossary explains implied probability and more.

Step 7: Streaming with automatic reconnects

Polling wastes quota and adds delay for live data. Orbistats offers a WebSocket API, where the company cites sub-50ms, and webhooks. Take the exact connection URL and subscribe message from the documentation. Connections drop in the real world, so reconnect with backoff:

python
import json
import time
import websocket

def stream(url, subscribe_message, on_event):
attempt = 0
while True:
try:
ws = websocket.create_connection(url, timeout=30)
ws.send(json.dumps(subscribe_message))
attempt = 0
while True:
on_event(json.loads(ws.recv()))
except Exception:
attempt += 1
time.sleep(min(30, pow(2, attempt)))

Install the dependency with: pip install websocket-client. The subscribe message is vendor-specific, so never guess it.

Step 8: A safe webhook receiver

Webhooks can be delivered more than once, so make your handler idempotent. This example fingerprints the raw body so a repeated delivery is ignored. Check the vendor docs for whether deliveries are signed, and verify signatures if they are.

python
import hashlib
from flask import Flask, request

app = Flask(name)
seen = set()

@app.route("/webhook/sports", methods=["POST"])
def webhook():
fingerprint = hashlib.sha256(request.get_data()).hexdigest()
if fingerprint in seen:
return "", 200
seen.add(fingerprint)
print(request.get_json(silent=True))
return "", 200

if name == "main":
app.run(port=3000)

Install with: pip install flask. Acknowledge quickly and do heavy work in a background job. In production, replace the in-memory set with Redis or a database table.

Step 9: Test without calling real APIs

Because your app depends on the interface, you can test failover with fakes and spend zero quota:

python
from cache import TtlCache
from client import SportsClient
from models import LiveMatch

class BrokenProvider:
name = "broken"

def live_matches(self, sport):
    raise RuntimeError("down")
Enter fullscreen mode Exit fullscreen mode

class FakeProvider:
name = "fake"

def live_matches(self, sport):
    return [LiveMatch("fake", "1", 72, "Home", 2, "Away", 1)]
Enter fullscreen mode Exit fullscreen mode

def test_failover_uses_second_provider():
client = SportsClient([BrokenProvider(), FakeProvider()], TtlCache(0))
result = client.live_matches("football")
assert result[0].provider == "fake"

Run with: pip install pytest, then pytest.

Design lessons that apply to every vendor
Hide metering. Requests, objects, leagues and time windows are different units, so track your own usage and alert before you hit limits.
Never trust one schema. Parse defensively and fail loudly when a required field is missing.
Separate rights from technology. A fast feed you cannot legally redistribute is worthless, so read each licence.
Prefer push for live data. REST suits fixtures, standings and history, while WebSocket or webhooks suit live scores and odds.
Keep history behind its own interface. Backtesting data has different retention and cost rules. Orbistats describes a historical archive and says some seasons go back to 2010, but verify retention per sport.
Which vendor should sit behind your adapter?
Regulated sportsbook or broadcaster: Sportradar, Genius Sports or Stats Perform, because rights and SLAs justify a contract.
Football-only product: Sportmonks for production, API-Football for a cheap prototype.
US fantasy app: SportsDataIO, with a written quote.
Odds-focused app: SportsGameOdds, OddsPapi or The Odds API, judged by bookmaker coverage and billing model.
Multi-sport app with odds and streaming: Orbistats covers 13 sports (football, basketball, American football, cricket, tennis, baseball, esports, combat sports, volleyball, handball, ice hockey, golf and horse racing). Its pricing is pass-based, not monthly: Free is €0 for 1 hour, Starter €19 for 24 hours, Growth €79 for 5 days and Unlimited €1,999 per month for 30 days. Access starts after you request it, so try the sandbox and check the status page on your own leagues, for example cricket or basketball, before committing.

Because of the adapter layer, this choice is no longer a one-way door.

Common mistakes
Letting vendor JSON leak into your database schema.
Retrying on every error, including 401 and 403, which burns quota for nothing.
Polling live endpoints every second on a request-metered plan.
Forgetting that free passes and trials are time or quota limited.
Mixing providers without checking redistribution rights.
Next steps

Add a second adapter, add per-provider usage counters, and put a small dashboard on top of your logs. If you want to try the multi-sport feed, follow the quickstart, request an access pass and plug it in as one more provider. The whole point of this design is that trying a new vendor costs you one class, not a rewrite.

Top comments (0)