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
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
]
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)
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))
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")
class FakeProvider:
name = "fake"
def live_matches(self, sport):
return [LiveMatch("fake", "1", 72, "Home", 2, "Away", 1)]
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)