Overview
Every sports API provider has its own data model. API-Sports calls it a fixture, SportMonks calls it a match, and SportsDataIO calls it a game. Field names differ, nested structures vary, and status codes use different abbreviations. If you support multiple providers for failover or migration, you need a normalization layer.
Normalization is the process of transforming provider-specific responses into a common internal schema. This lets your application code work with a single data model regardless of which provider served the request, making failover, migration, and multi-provider architectures possible.
Architecture
A normalization layer sits between the API client and your application logic:
Normalization Flow
Provider A API ──┐
Provider B API ──┤── Adapter Layer ── Common Schema ── App Logic
Provider C API ──┘
Each adapter:
1. Receives raw provider response
2. Maps fields to common schema
3. Coerces types (string → number, etc.)
4. Normalizes enums (status codes, positions)
5. Resolves IDs to internal IDs
6. Returns a typed, predictable objectThe common schema should be designed for your application's needs, not any single provider. Define it once, and each adapter is responsible for conforming to it.
Implementation
Common Schema Definition
Start by defining your common schema as TypeScript types or Python dataclasses. This is the contract every adapter must satisfy:
// schema.js — Common schema types
/**
* Common fixture schema — all adapters must produce this shape.
*/
const FixtureStatus = {
SCHEDULED: "SCHEDULED",
LIVE: "LIVE",
HALFTIME: "HALFTIME",
FINISHED: "FINISHED",
POSTPONED: "POSTPONED",
CANCELLED: "CANCELLED",
} as const;
/**
* @typedef {Object} NormalizedFixture
* @property {string} id - Internal fixture ID
* @property {string} externalId - Provider's fixture ID
* @property {string} provider - Provider name
* @property {string} leagueId - Internal league ID
* @property {string} homeTeamId - Internal team ID
* @property {string} awayTeamId - Internal team ID
* @property {string} homeTeamName
* @property {string} awayTeamName
* @property {string} date - ISO 8601 UTC
* @property {string} status - One of FixtureStatus
* @property {number|null} minute - Current minute (if live)
* @property {number} homeScore
* @property {number} awayScore
* @property {string|null} venue
*/
export { FixtureStatus };Provider Adapters (JavaScript)
Each adapter maps a provider's response to the common schema. Here are adapters for API-Sports and SportMonks:
// adapters.js
import { FixtureStatus } from "./schema.js";
// Status code mapping for different providers
const STATUS_MAP_API_SPORTS = {
"NS": FixtureStatus.SCHEDULED, // Not Started
"1H": FixtureStatus.LIVE, // First Half
"2H": FixtureStatus.LIVE, // Second Half
"HT": FixtureStatus.HALFTIME, // Halftime
"FT": FixtureStatus.FINISHED, // Full Time
"PST": FixtureStatus.POSTPONED,
"CANC": FixtureStatus.CANCELLED,
"ABD": FixtureStatus.CANCELLED, // Abandoned
};
const STATUS_MAP_SPORTMONKS = {
"NS": FixtureStatus.SCHEDULED,
"LIVE": FixtureStatus.LIVE,
"HT": FixtureStatus.HALFTIME,
"FT": FixtureStatus.FINISHED,
"PST": FixtureStatus.POSTPONED,
"CANC": FixtureStatus.CANCELLED,
"ABD": FixtureStatus.CANCELLED,
};
/**
* API-Sports adapter
*/
export function normalizeApiSports(data) {
return data.response.map((item) => ({
id: `apisports-${item.fixture.id}`,
externalId: String(item.fixture.id),
provider: "api-sports",
leagueId: String(item.league.id),
homeTeamId: String(item.teams.home.id),
awayTeamId: String(item.teams.away.id),
homeTeamName: item.teams.home.name,
awayTeamName: item.teams.away.name,
date: item.fixture.date, // Already ISO 8601 UTC
status: STATUS_MAP_API_SPORTS[item.fixture.status.short]
|| FixtureStatus.SCHEDULED,
minute: item.fixture.status.elapsed ?? null,
homeScore: item.goals.home ?? 0,
awayScore: item.goals.away ?? 0,
venue: item.fixture.venue?.name ?? null,
}));
}
/**
* SportMonks adapter
*/
export function normalizeSportMonks(data) {
const fixtures = Array.isArray(data.data) ? data.data : [data.data];
return fixtures.map((item) => ({
id: `sportmonks-${item.id}`,
externalId: String(item.id),
provider: "sportmonks",
leagueId: String(item.league_id),
homeTeamId: String(item.participants[0].id),
awayTeamId: String(item.participants[1].id),
homeTeamName: item.participants[0].name,
awayTeamName: item.participants[1].name,
date: item.starting_at, // ISO 8601
status: STATUS_MAP_SPORTMONKS[item.state?.short]
|| FixtureStatus.SCHEDULED,
minute: item.scores?.localteam_score_90 !== undefined
? 90 // SportMonks doesn't expose minute directly
: null,
homeScore: item.scores?.localteam_score ?? 0,
awayScore: item.scores?.visitorteam_score ?? 0,
venue: item.venue?.name ?? null,
}));
}Python Dataclass Schema with Adapter
# normalizer.py
from dataclasses import dataclass, asdict
from enum import Enum
from datetime import datetime
from typing import Optional
class FixtureStatus(Enum):
SCHEDULED = "SCHEDULED"
LIVE = "LIVE"
HALFTIME = "HALFTIME"
FINISHED = "FINISHED"
POSTPONED = "POSTPONED"
CANCELLED = "CANCELLED"
@dataclass
class NormalizedFixture:
id: str
external_id: str
provider: str
league_id: str
home_team_id: str
away_team_id: str
home_team_name: str
away_team_name: str
date: str # ISO 8601 UTC
status: str # FixtureStatus value
minute: Optional[int]
home_score: int
away_score: int
venue: Optional[str]
# Provider-specific status maps
_STATUS_MAPS = {
"api-sports": {
"NS": FixtureStatus.SCHEDULED,
"1H": FixtureStatus.LIVE, "2H": FixtureStatus.LIVE,
"HT": FixtureStatus.HALFTIME, "FT": FixtureStatus.FINISHED,
"PST": FixtureStatus.POSTPONED, "CANC": FixtureStatus.CANCELLED,
},
"apifootball": {
"NS": FixtureStatus.SCHEDULED,
"LIVE": FixtureStatus.LIVE, "HT": FixtureStatus.HALFTIME,
"FT": FixtureStatus.FINISHED, "Postp.": FixtureStatus.POSTPONED,
"CANC": FixtureStatus.CANCELLED,
},
}
def normalize(data: dict, provider: str) -> list[NormalizedFixture]:
"""Normalize a provider response to the common schema."""
status_map = _STATUS_MAPS.get(provider, {})
if provider == "api-sports":
items = data.get("response", [])
return [
NormalizedFixture(
id=f"apisports-{item['fixture']['id']}",
external_id=str(item["fixture"]["id"]),
provider=provider,
league_id=str(item["league"]["id"]),
home_team_id=str(item["teams"]["home"]["id"]),
away_team_id=str(item["teams"]["away"]["id"]),
home_team_name=item["teams"]["home"]["name"],
away_team_name=item["teams"]["away"]["name"],
date=item["fixture"]["date"],
status=status_map.get(
item["fixture"]["status"]["short"],
FixtureStatus.SCHEDULED
).value,
minute=item["fixture"]["status"].get("elapsed"),
home_score=item["goals"].get("home") or 0,
away_score=item["goals"].get("away") or 0,
venue=item["fixture"].get("venue", {}).get("name"),
)
for item in items
]
elif provider == "apifootball":
items = data if isinstance(data, list) else []
return [
NormalizedFixture(
id=f"apifootball-{item['match_id']}",
external_id=str(item["match_id"]),
provider=provider,
league_id=str(item.get("league_id", "")),
home_team_id=str(item.get("match_hometeam_id", "")),
away_team_id=str(item.get("match_awayteam_id", "")),
home_team_name=item["match_hometeam_name"],
away_team_name=item["match_awayteam_name"],
date=item["match_start"],
status=status_map.get(
item.get("match_status", "NS"),
FixtureStatus.SCHEDULED
).value,
minute=None,
home_score=int(item.get("match_hometeam_score") or 0),
away_score=int(item.get("match_awayteam_score") or 0),
venue=item.get("stadium"),
)
for item in items
]
raise ValueError(f"Unknown provider: {provider}")
# Usage
raw = fetch_from_api("api-sports", endpoint="fixtures", league=39)
fixtures = normalize(raw, "api-sports")
for f in fixtures:
print(f"{f.home_team_name} {f.home_score}-{f.away_score} {f.away_team_name}")Best Practices
Design the common schema for your app, not the API
Your common schema should reflect what your application needs, not mirror any single provider. Include only the fields you actually use, and make the schema provider-agnostic.
Always coerce types defensively
Providers sometimes return null, empty strings, or strings where you expect numbers. Use parseInt() with defaults, nullish coalescing, and optional chaining to handle these inconsistencies gracefully.
Use prefixed IDs to avoid collisions
Provider IDs can collide (both API-Sports and SportMonks might have a fixture with ID 12345). Prefix internal IDs with the provider name (e.g., apisports-12345) to keep them unique across providers.
Test adapters with real API responses
Save real API responses as fixtures (test data files) and write unit tests that verify each adapter produces the correct common schema. This catches breaking changes when providers update their response formats.
Related Guides
How to Design a Sports Database
15 min readREST vs GraphQL for Sports APIs
10 min readBuilding Failover for Sports APIs
12 min readCompare data formats across providers
See how different sports APIs structure their responses.