Contents
The 2026 FIFA World Cup is going to be one for the books. This is the first time ever with 48 teams and games played across the USA, Canada, and Mexico.
If you’re working on a fan app, a news site, or even a betting platform, you know how important it is to get live match updates as they happen. In this step-by-step guide, we’ll show you how to create a live score app using the Sportmonks Football API with plain JavaScript.
Why Sportmonks for the World Cup 2026?
Sportmonks Football API provides comprehensive coverage of over 2,300 football leagues worldwide, including major tournaments like the World Cup. Here’s what makes it ideal for your World Cup 2026 app:
– Real-time livescores with updates within seconds
– Comprehensive match data, including scores, events, statistics, and lineups
– Flexible data retrieval with customisable includes and filters
– 48-team tournament support with group stages and knockout brackets
– 3,000 API calls per entity per hour on the default plan
What We’ll Build
Our live score app will feature:
– Real-time score updates for all World Cup 2026 matches
– Live match events (goals, cards, substitutions)
– Group stage standings
Prerequisites
Before we start, you’ll need:
– Basic knowledge of JavaScript and the Fetch API
– A Sportmonks API account (with a paid plan that has the WC26 available)
– Your API token from MySportmonks
Security Note: Never expose your API token in frontend code. For production environments, always implement a middleware layer with robust security practices. Use a backend or proxy server to handle API requests in production, ensuring your tokens remain safe.
Production Readiness Checklist:
- Secure API Communications: Always use HTTPS to encrypt data between your services.
- Environment Variables: Store sensitive information, including API tokens, in environment variables or secure vaults, rather than hardcoding them.
- Backend Middleware: Implement backend services to handle sensitive operations, ensuring tokens are not exposed directly to the client.
Understanding the Sportmonks API Structure
Base URL and Authentication
All requests go through the Sportmonks API v3:
https://api.sportmonks.com/v3/football/
You can authenticate in two ways:
Query Parameter:
const url = https://api.sportmonks.com/v3/football/livescores?api_token=YOUR_TOKEN;
Authorization Header:
fetch(url, {
headers: {
'Authorization': 'YOUR_TOKEN'
}
});
Key Endpoints for Live Scores
The Sportmonks API offers three livescore endpoints:
- GET All Livescores (/livescores): Returns fixtures 15 minutes before kickoff and 15 minutes after full-time
- GET Inplay Livescores (/livescores/inplay): Returns only currently in-play matches
- GET Latest Updated Livescores (/livescores/latest): Returns matches updated within the last 10 seconds
For a live score app, we’ll primarily use the inplay endpoint for real-time matches and the latest endpoint for efficient polling.
Understanding Includes
Our API uses “includes” to enrich responses with related data. Instead of making multiple requests, you can get everything in one call:
// Basic fixture data only /livescores/inplay // With team info, scores, and events /livescores/inplay?include=participants,scores,events // With statistics and state information /livescores/inplay?include=participants,scores,events,statistics,state
Common includes for live scores:
– participants: Team information (replaces old localTeam/visitorTeam)
– scores: Detailed score breakdown by period
– events: Goals, cards, substitutions, etc.
– state: Match status (LIVE, HT, FT, etc.)
– statistics: Match statistics (shots, possession, etc.)
– league: League/tournament information
Step 1: Setting Up the Project
Create a simple HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>World Cup 2026 Live Scores</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>⚽ World Cup 2026 Live Scores</h1>
<div id="lastUpdate"></div>
</header>
<div class="filter-tabs">
<button class="tab active" data-view="live">Live Matches</button>
<button class="tab" data-view="today">All Today</button>
<button class="tab" data-view="groups">Group Standings</button>
</div>
<div id="matchesContainer"></div>
<div id="groupsContainer" style="display: none;"></div>
<div class="loading" id="loading">Loading matches...</div>
<div class="error" id="error" style="display: none;"></div>
</div>
<script src="app.js"></script>
</body>
</html>
Step 2: Core API Configuration
Create your main Javascript file with configuration and helper functions:
const CONFIG = {
API_BASE: "https://api.sportmonks.com/v3/football",
API_TOKEN: "YOUR_TOKEN_HERE", // Replace with your token
WORLD_CUP_LEAGUE_ID: 26618,
POLL_INTERVAL: 8_000, // Poll every 8 seconds (recommended)
RETRY_DELAY: 3_000,
};
// -----------------------------------------------------------------------------
// State management
// -----------------------------------------------------------------------------
const appState = {
currentView: "live",
fixtures: new Map(),
pollInterval: null,
isPolling: false,
};
// -----------------------------------------------------------------------------
// Helper function to make API requests
// -----------------------------------------------------------------------------
async function makeAPIRequest(endpoint, params = {}) {
const queryParams = new URLSearchParams({
api_token: CONFIG.API_TOKEN,
...params,
});
const url = `${CONFIG.API_BASE}${endpoint}?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 429) {
throw new Error(
"Rate limit exceeded. Please wait before retrying."
);
}
throw new Error(`API Error: ${response.status}`);
}
const data = await response.json();
// Log rate limit info for monitoring
if (data.rate_limit) {
console.log(
`Rate Limit - Remaining: ${data.rate_limit.remaining}, ` +
`Resets in: ${data.rate_limit.resets_in_seconds}s`
);
}
return data;
} catch (error) {
console.error("API Request failed:", error);
throw error;
}
}
// -----------------------------------------------------------------------------
// Format timestamp to readable time
// -----------------------------------------------------------------------------
function formatMatchTime(timestamp) {
const date = new Date(timestamp * 1_000);
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
// Status labels as a single source of truth (defined once)
const STATUS_LABELS = {
1: 'Not started', // NS
3: 'HT', // Half-time
4: 'Break', // Regular time finished, waiting for ET
5: 'FT', // Full-time
7: 'AET', // Finished after extra time
8: 'FT (pens)', // Full-time after penalties
9: 'Pens', // Penalty shootout in play
10: 'Postponed',
11: 'Suspended',
12: 'Cancelled',
13: 'TBA',
14: 'WO',
15: 'Abandoned',
16: 'Delayed',
17: 'Awarded',
18: 'Interrupted',
19: 'Awaiting updates',
20: 'Deleted',
21: 'ET break',
25: 'Pens break',
26: 'Pending'
};
// States where we may show a minute, otherwise a fallback label
const DYNAMIC_STATUS_LABELS = {
2: (minute) => (Number.isFinite(minute) ? `${minute}'` : '1st half'), // INPLAY_1ST_HALF
6: (minute) => (Number.isFinite(minute) ? `${minute}'` : 'ET'), // Extra time in play
22: (minute) => (Number.isFinite(minute) ? `${minute}'` : '2nd half') // INPLAY_2ND_HALF
};
// Get match status display text
// Works if you pass either `fixture.state` (object) or only `fixture.state_id` (number/string)
function getMatchStatus(stateOrStateId, minute) {
const rawId =
typeof stateOrStateId === 'number' || typeof stateOrStateId === 'string'
? stateOrStateId
: (stateOrStateId?.id ?? null);
const stateId = rawId == null ? null : Number(rawId);
if (!Number.isFinite(stateId)) return 'Unknown';
const dynamic = DYNAMIC_STATUS_LABELS[stateId];
if (dynamic) return dynamic(minute);
return STATUS_LABELS[stateId] || 'Unknown';
}
Step 3: Fetching Live Matches
Now let’s implement the core functionality to fetch and display live matches:
// Fetch live matches with efficient polling
async function fetchLiveMatches() {
try {
// Note: /livescores/latest only returns fixtures where ONE of these changed in the last 10s:
// state_id, venue_id, name, starting_at, starting_at_timestamp, result_info, leg, length.
// Changes to events/lineups/statistics do NOT trigger this endpoint.
const data = await makeAPIRequest('/livescores/latest', {
include: 'participants,scores,state,events,league',
filters: 'fixtureLeagues:' + CONFIG.WORLD_CUP_LEAGUE_ID
});
if (!data.data || data.data.length === 0) {
console.log('No tracked-field updates in the last 10 seconds');
return;
}
data.data.forEach(fixture => {
appState.fixtures.set(fixture.id, fixture);
});
renderMatches();
updateLastUpdateTime();
} catch (error) {
showError('Failed to fetch live matches. Retrying...');
console.error(error);
}
}
// Fetch all matches for today (for "All Today" view)
async function fetchTodayMatches() {
try {
const today = new Date().toISOString().split('T')[0]; // may differ from match timezone near midnight
const data = await makeAPIRequest(`/fixtures/date/${today}`, {
include: 'participants,scores,state,league',
filters: 'fixtureLeagues:' + CONFIG.WORLD_CUP_LEAGUE_ID
});
if (data.data) {
appState.fixtures.clear();
data.data.forEach(fixture => appState.fixtures.set(fixture.id, fixture));
}
renderMatches();
} catch (error) {
showError("Failed to fetch today's matches");
console.error(error);
}
}
// Initial load
async function initialLoad() {
try {
showLoading(true);
// /livescores only contains fixtures from ~15 minutes before KO to ~15 minutes after FT.
const data = await makeAPIRequest('/livescores', {
include: 'participants,scores;state;events;league',
filters: 'fixtureLeagues:' + CONFIG.WORLD_CUP_LEAGUE_ID
});
if (data.data) {
data.data.forEach(fixture => appState.fixtures.set(fixture.id, fixture));
}
renderMatches();
} catch (error) {
showError('Failed to load matches');
} finally {
showLoading(false);
}
}
Step 4: Rendering Matches
Create functions to display match data in the UI:
// Live phases + common live breaks (HT, ET break, penalties break, etc.)
const LIVE_STATE_IDS = new Set([2, 3, 4, 6, 9, 21, 22, 25, 11, 18, 19, 26]);
// Sort events safely (events array is not guaranteed to be returned in time order)
function sortEvents(events = []) {
return [...events].sort((a, b) => {
const aMin = a.minute ?? 0;
const bMin = b.minute ?? 0;
const aExtra = a.extra_minute ?? 0;
const bExtra = b.extra_minute ?? 0;
const aSort = a.sort_order ?? 0;
const bSort = b.sort_order ?? 0;
if (aMin !== bMin) return aMin - bMin;
if (aExtra !== bExtra) return aExtra - bExtra;
// Use sort_order as a tie-breaker (not as the only ordering field)
if (aSort !== bSort) return aSort - bSort;
// Final tie-breaker to keep ordering stable
return (a.id ?? 0) - (b.id ?? 0);
});
}
// Render all matches based on the current view
function renderMatches() {
const container = document.getElementById('matchesContainer');
if (!container) return;
const fixtures = Array.from(appState.fixtures.values());
if (fixtures.length === 0) {
container.innerHTML = '<div class="no-matches">No matches available</div>';
return;
}
// Filter based on current view
let filteredFixtures = fixtures;
if (appState.currentView === 'live') {
// Show only live matches (state ids that represent in-play or still ongoing)
filteredFixtures = fixtures.filter(f => LIVE_STATE_IDS.has(f.state_id));
}
// Sort by state and time
filteredFixtures.sort((a, b) => {
// Live matches first
const aLive = LIVE_STATE_IDS.has(a.state_id);
const bLive = LIVE_STATE_IDS.has(b.state_id);
if (aLive && !bLive) return -1;
if (!aLive && bLive) return 1;
// Then by the starting time
return (a.starting_at_timestamp ?? 0) - (b.starting_at_timestamp ?? 0);
});
container.innerHTML = filteredFixtures.map(fixture => renderMatchCard(fixture)).join('');
}
// Render individual match card
function renderMatchCard(fixture) {
const participants = fixture.participants || [];
const homeTeam = participants.find(p => p.meta?.location === 'home');
const awayTeam = participants.find(p => p.meta?.location === 'away');
if (!homeTeam || !awayTeam) return '';
// Get scores
const scores = fixture.scores || [];
const homeCurrentScore = scores.find(
s => s.participant_id === homeTeam.id && s.description === 'CURRENT'
);
const awayCurrentScore = scores.find(
s => s.participant_id === awayTeam.id && s.description === 'CURRENT'
);
const homeScore = homeCurrentScore?.score?.goals ?? 0;
const awayScore = awayCurrentScore?.score?.goals ?? 0;
// Live flag + status
const isLive = LIVE_STATE_IDS.has(fixture.state_id);
// Pass state id reliably (works even if `fixture.state` is not included)
const statusText = getMatchStatus(
fixture.state?.id ?? fixture.state_id,
getCurrentMinute(fixture)
);
// Get recent events (last 3) after sorting
const eventsSorted = sortEvents(fixture.events || []);
const recentEvents = eventsSorted.slice(-3).reverse();
return `
<div class="match-card ${isLive ? 'live' : ''}">
<div class="match-header">
<span class="match-status ${isLive ? 'status-live' : ''}">
${isLive ? '🔴 ' : ''}${statusText}
</span>
<span class="match-time">${formatMatchTime(fixture.starting_at_timestamp)}</span>
</div>
<div class="match-teams">
<div class="team ${homeTeam.meta?.winner ? 'winner' : ''}">
<img src="${homeTeam.image_path}" alt="${homeTeam.name}" class="team-logo">
<span class="team-name">${homeTeam.name}</span>
<span class="team-score">${homeScore}</span>
</div>
<div class="team ${awayTeam.meta?.winner ? 'winner' : ''}">
<img src="${awayTeam.image_path}" alt="${awayTeam.name}" class="team-logo">
<span class="team-name">${awayTeam.name}</span>
<span class="team-score">${awayScore}</span>
</div>
</div>
${recentEvents.length > 0 ? `
<div class="match-events">
${recentEvents.map(event => renderEvent(event)).join('')}
</div>
` : ''}
<button class="match-details-btn" onclick="showMatchDetails(${fixture.id})">
View Details
</button>
</div>
`;
}
// Render individual event
function renderEvent(event) {
// Sportmonks event type ids
const eventIcons = {
10: '📺', // VAR
14: '⚽', // Goal
15: '🥅', // Own goal
16: '⚽', // Penalty scored
17: '❌', // Missed penalty
18: '🔄', // Substitution
19: '🟨', // Yellow card
20: '🟥', // Red card
21: '🟥', // Second yellow -> red
22: '❌', // Penalty shootout miss
23: '⚽' // Penalty shootout goal
};
const icon = eventIcons[event.type_id] || '•';
const minuteText = `${event.minute ?? ''}${event.extra_minute ? `+${event.extra_minute}` : ''}`;
const playerName = (event.player_name || '').trim();
return `
<div class="event-item">
<span class="event-icon">${icon}</span>
<span class="event-minute">${minuteText ? `${minuteText}'` : ''}</span>
<span class="event-player">${playerName}</span>
</div>
`;
}
// Get current minute for live matches (based on sorted events)
function getCurrentMinute(fixture) {
const eventsSorted = sortEvents(fixture.events || []);
if (eventsSorted.length === 0) return null;
const latestEvent = eventsSorted[eventsSorted.length - 1];
if (!latestEvent?.minute) return null;
// Return "90+3" style when extra time exists, otherwise "67"
return `${latestEvent.minute}${latestEvent.extra_minute ? `+${latestEvent.extra_minute}` : ''}`;
}
// Show match details in a modal
function showMatchDetails(fixtureId) {
const fixture = appState.fixtures.get(fixtureId);
if (!fixture) return;
fetchMatchStatistics(fixtureId);
}
// Fetch detailed match statistics
async function fetchMatchStatistics(fixtureId) {
try {
const data = await makeAPIRequest(`/fixtures/${fixtureId}`, {
include: 'state,participants,scores,events,statistics.type,lineups'
});
if (data.data) {
displayMatchDetailsModal(data.data);
}
} catch (error) {
console.error('Failed to fetch match statistics:', error);
}
}
Step 5: Implementing Smart Polling
Efficient polling is crucial for real-time updates without hitting rate limits:
// Prevent overlapping network calls
appState.isFetchingLive = false;
// Start polling for live updates
function startPolling() {
if (appState.isPolling) return;
appState.isPolling = true;
appState.pollInterval = setInterval(async () => {
if (appState.currentView !== 'live') return;
if (appState.isFetchingLive) return;
appState.isFetchingLive = true;
try {
await fetchLiveMatches();
} finally {
appState.isFetchingLive = false;
}
}, CONFIG.POLL_INTERVAL);
console.log('Started polling for live updates');
}
// Stop polling
function stopPolling() {
if (appState.pollInterval) {
clearInterval(appState.pollInterval);
appState.pollInterval = null;
}
appState.isPolling = false;
appState.isFetchingLive = false;
console.log('Stopped polling');
}
// Update last update timestamp
function updateLastUpdateTime() {
const element = document.getElementById('lastUpdate');
if (!element) return;
const now = new Date().toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
element.textContent = `Last updated: ${now}`;
}
// Handle visibility change to pause/resume polling
document.addEventListener('visibilitychange', async () => {
if (document.hidden) {
stopPolling();
return;
}
startPolling();
// Refresh immediately when tab becomes visible
if (appState.currentView === 'live' && !appState.isFetchingLive) {
appState.isFetchingLive = true;
try {
await fetchLiveMatches();
} finally {
appState.isFetchingLive = false;
}
}
});
Step 6: Group Standings
For the World Cup, group standings are essential:
// Fetch group standings
async function fetchGroupStandings() {
try {
showLoading(true);
// Get league + current season (fallback to seasons if needed)
const leagueData = await makeAPIRequest(`/leagues/${CONFIG.WORLD_CUP_LEAGUE_ID}`, {
include: 'currentSeason,seasons'
});
const league = leagueData.data;
const seasonId =
league?.currentSeason?.id ??
league?.seasons?.find(s => s.is_current)?.id ??
null;
if (!seasonId) {
throw new Error('Could not find a season id for this league');
}
// Fetch standings with what you need
const standingsData = await makeAPIRequest(`/standings/seasons/${seasonId}`, {
// group + details are needed for WC-style group tables
include: 'participant,group,details.type'
});
renderGroupStandings(standingsData.data || []);
} catch (error) {
console.error(error);
showError('Failed to fetch group standings');
} finally {
showLoading(false);
}
}
// Helper to get a standings detail value by type code
function getStandingDetail(standing, typeCode, fallback = 0) {
const details = standing.details || [];
const item = details.find(d => d.type?.code === typeCode);
return item?.value ?? fallback;
}
// Render group standings
function renderGroupStandings(standings) {
const container = document.getElementById('groupsContainer');
if (!container) return;
// Group standings by group name (or group_id fallback)
const groupedStandings = standings.reduce((acc, standing) => {
const groupName =
standing.group?.name ??
(standing.group_id ? `Group ${standing.group_id}` : 'Unknown');
if (!acc[groupName]) acc[groupName] = [];
acc[groupName].push(standing);
return acc;
}, {});
// Sort each group by position
Object.keys(groupedStandings).forEach(group => {
groupedStandings[group].sort((a, b) => (a.position ?? 999) - (b.position ?? 999));
});
container.innerHTML = Object.keys(groupedStandings)
.sort()
.map(group => `
<div class="group-standings">
<h3>${group}</h3>
<table class="standings-table">
<thead>
<tr>
<th>Pos</th>
<th>Team</th>
<th>P</th>
<th>W</th>
<th>D</th>
<th>L</th>
<th>GD</th>
<th>Pts</th>
</tr>
</thead>
<tbody>
${groupedStandings[group].map(standing => {
const played = getStandingDetail(standing, 'overall-matches-played', 0);
const won = getStandingDetail(standing, 'overall-won', 0);
const draw = getStandingDetail(standing, 'overall-draw', 0);
const lost = getStandingDetail(standing, 'overall-lost', 0);
const gd = getStandingDetail(standing, 'overall-goal-difference', 0);
return `
<tr class="${(standing.position ?? 999) <= 2 ? 'qualified' : ''}">
<td>${standing.position ?? ''}</td>
<td class="team-cell">
<img src="${standing.participant?.image_path ?? ''}"
alt="${standing.participant?.name ?? ''}"
class="team-logo-small">
${standing.participant?.short_code || standing.participant?.name || ''}
</td>
<td>${played}</td>
<td>${won}</td>
<td>${draw}</td>
<td>${lost}</td>
<td>${gd}</td>
<td><strong>${standing.points ?? 0}</strong></td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`)
.join('');
}
Step 7: View Management and Event Handlers
Handle navigation between different views:
// Initialise the app
async function initApp() {
// Find World Cup league ID (best effort)
await findWorldCupLeague();
// Set up tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', (e) => {
const clickedTab = e.currentTarget;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
clickedTab.classList.add('active');
appState.currentView = clickedTab.dataset.view;
handleViewChange(appState.currentView);
});
});
// Initial load
await initialLoad();
// Start polling if on live view
if (appState.currentView === 'live') {
startPolling();
}
}
// Find World Cup league ID
async function findWorldCupLeague() {
try {
// Correct v3 endpoint: /leagues/search/{search_query}
const query = 'World Cup';
const data = await makeAPIRequest(`/leagues/search/${encodeURIComponent(query)}`, {
// Optional: include seasons if you want to pick a specific year from seasons
include: 'seasons'
});
const leagues = data.data || [];
// Prefer a FIFA World Cup league name if present
const worldCupLeague =
leagues.find(l => (l.name || '').toLowerCase() === 'fifa world cup') ||
leagues.find(l => (l.name || '').toLowerCase().includes('world cup')) ||
null;
if (worldCupLeague) {
CONFIG.WORLD_CUP_LEAGUE_ID = worldCupLeague.id;
console.log('World Cup League ID:', CONFIG.WORLD_CUP_LEAGUE_ID);
return;
}
throw new Error('World Cup league not found in search results');
} catch (error) {
console.error('Failed to find World Cup league:', error);
showError('Could not find World Cup data');
// Keep existing CONFIG.WORLD_CUP_LEAGUE_ID as fallback
}
}
// Handle view changes
function handleViewChange(view) {
const matchesContainer = document.getElementById('matchesContainer');
const groupsContainer = document.getElementById('groupsContainer');
// Stop polling when switching away from live
stopPolling();
if (!matchesContainer || !groupsContainer) return;
switch (view) {
case 'live':
matchesContainer.style.display = 'block';
groupsContainer.style.display = 'none';
fetchLiveMatches();
startPolling();
break;
case 'today':
matchesContainer.style.display = 'block';
groupsContainer.style.display = 'none';
fetchTodayMatches();
break;
case 'groups':
matchesContainer.style.display = 'none';
groupsContainer.style.display = 'block';
fetchGroupStandings();
break;
default:
// Fallback
matchesContainer.style.display = 'block';
groupsContainer.style.display = 'none';
break;
}
}
Step 8: Styling the App
Add some CSS to make it look professional:
/* styles.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
background: white;
padding: 30px;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
margin-bottom: 30px;
text-align: center;
}
h1 {
color: #333;
font-size: 2.5em;
margin-bottom: 10px;
}
#lastUpdate {
color: #666;
font-size: 0.9em;
}
.filter-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.tab {
flex: 1;
padding: 15px;
background: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 1em;
font-weight: 600;
transition: all 0.3s ease;
}
.tab:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
.tab.active {
background: #667eea;
color: white;
}
#matchesContainer {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.match-card {
background: white;
border-radius: 15px;
padding: 20px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transition: all 0.3s ease;
}
.match-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
}
.match-card.live {
border: 3px solid #ff4444;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 5px 15px rgba(255, 68, 68, 0.3);
}
50% {
box-shadow: 0 5px 25px rgba(255, 68, 68, 0.6);
}
}
.match-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #f0f0f0;
}
.match-status {
font-weight: 600;
color: #666;
}
.status-live {
color: #ff4444;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50%, 100% { opacity: 1; }
25%, 75% { opacity: 0.5; }
}
.match-time {
color: #999;
font-size: 0.9em;
}
.match-teams {
display: flex;
flex-direction: column;
gap: 15px;
}
.team {
display: flex;
align-items: center;
gap: 15px;
}
.team-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.team-name {
flex: 1;
font-weight: 600;
color: #333;
}
.team-score {
font-size: 2em;
font-weight: 700;
color: #667eea;
min-width: 40px;
text-align: center;
}
.team.winner .team-score {
color: #4CAF50;
}
.match-events {
margin-top: 15px;
padding-top: 15px;
border-top: 2px solid #f0f0f0;
}
.event-item {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 0;
font-size: 0.9em;
}
.event-icon {
font-size: 1.2em;
}
.event-minute {
color: #999;
font-weight: 600;
min-width: 35px;
}
.event-player {
color: #666;
}
.match-details-btn {
width: 100%;
margin-top: 15px;
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.match-details-btn:hover {
background: #5568d3;
}
.group-standings {
background: white;
border-radius: 15px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.group-standings h3 {
color: #667eea;
margin-bottom: 15px;
font-size: 1.5em;
}
.standings-table {
width: 100%;
border-collapse: collapse;
}
.standings-table th {
background: #f8f9fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #666;
}
.standings-table td {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
.standings-table tr:hover {
background: #f8f9fa;
}
.standings-table tr.qualified {
background: #e8f5e9;
}
.team-cell {
display: flex;
align-items: center;
gap: 10px;
}
.team-logo-small {
width: 24px;
height: 24px;
object-fit: contain;
}
.loading, .error {
background: white;
padding: 30px;
border-radius: 15px;
text-align: center;
color: #666;
font-size: 1.1em;
}
.error {
background: #ffebee;
color: #c62828;
}
.no-matches {
background: white;
padding: 50px;
border-radius: 15px;
text-align: center;
color: #999;
font-size: 1.2em;
}
@media (max-width: 768px) {
#matchesContainer {
grid-template-columns: 1fr;
}
h1 {
font-size: 1.8em;
}
.filter-tabs {
flex-direction: column;
}
}
Best Practices and Optimisation Tips
1. Rate Limit Management
The default plan provides 3,000 API calls per entity per hour. Here’s how to stay within limits:
// Rate limit + retry helpers
// Track polling interval changes safely
const DEFAULT_POLL_INTERVAL = CONFIG.POLL_INTERVAL;
let lastPollIntervalApplied = CONFIG.POLL_INTERVAL;
// Monitor rate limits and adjust polling
function checkRateLimit(rateLimitData) {
if (!rateLimitData) return;
const remaining = Number(rateLimitData.remaining ?? 0);
const resetsIn = Number(rateLimitData.resets_in_seconds ?? 0);
// If we are running low, slow down polling
if (remaining > 0 && remaining < 100) {
console.warn('Rate limit running low:', remaining);
// Increase interval to 15s
if (CONFIG.POLL_INTERVAL !== 15000) {
CONFIG.POLL_INTERVAL = 15000;
restartPollingIfNeeded();
}
return;
}
// If we have breathing room again (and a reset is near/has happened), restore the default
if (remaining >= 300 && CONFIG.POLL_INTERVAL !== DEFAULT_POLL_INTERVAL) {
CONFIG.POLL_INTERVAL = DEFAULT_POLL_INTERVAL;
restartPollingIfNeeded();
}
// Optional: if resetsIn is very high and remaining is low, slow further
if (remaining > 0 && remaining < 50 && resetsIn > 600) {
if (CONFIG.POLL_INTERVAL !== 20000) {
CONFIG.POLL_INTERVAL = 20000;
restartPollingIfNeeded();
}
}
}
// Restart polling so updated CONFIG.POLL_INTERVAL takes effect
function restartPollingIfNeeded() {
// Only restart if polling is active and we are on live view
if (!appState.isPolling || appState.currentView !== 'live') return;
// Avoid restarting repeatedly if nothing changed
if (lastPollIntervalApplied === CONFIG.POLL_INTERVAL) return;
lastPollIntervalApplied = CONFIG.POLL_INTERVAL;
stopPolling();
startPolling();
}
// Exponential backoff on rate-limit responses and transient errors
async function makeAPIRequestWithRetry(endpoint, params = {}, retries = 3) {
let lastError;
for (let i = 0; i < retries; i++) {
try {
const data = await makeAPIRequest(endpoint, params);
// If the API returns rate_limit info in body, monitor it
if (data?.rate_limit) checkRateLimit(data.rate_limit);
return data;
} catch (error) {
lastError = error;
const msg = String(error?.message || '');
const isRateLimit = msg.includes('Rate limit') || msg.includes('429');
// For rate limits, wait until reset if we have that info, otherwise exponential backoff
if (isRateLimit && i < retries - 1) {
const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// Optional: retry on transient fetch/network errors too
const isNetwork =
msg.includes('Failed to fetch') ||
msg.includes('NetworkError') ||
msg.includes('ECONN') ||
msg.includes('timeout');
if (isNetwork && i < retries - 1) {
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
throw lastError;
}
2. Efficient Polling Strategy
Use the /livescores/latest endpoint, which returns only matches updated in the last 10 seconds:
// Poll every 5-8 seconds for minimal latency
const OPTIMAL_POLL_INTERVAL = 8000;
// Cache unchanged data (optional, keep if you actually use it later)
const dataCache = {
teams: new Map(),
leagues: new Map()
};
// Build a stable key for the CURRENT score (goals) for home/away
function getCurrentScoreKey(fixture) {
const participants = fixture.participants || [];
const home = participants.find(p => p.meta?.location === 'home');
const away = participants.find(p => p.meta?.location === 'away');
const scores = fixture.scores || [];
const homeScore = scores.find(s => s.participant_id === home?.id && s.description === 'CURRENT')
?.score?.goals ?? 0;
const awayScore = scores.find(s => s.participant_id === away?.id && s.description === 'CURRENT')
?.score?.goals ?? 0;
return `${homeScore}-${awayScore}`;
}
// Sort events consistently, then build a key from the latest 1-2 events
function getLatestEventKey(fixture) {
const eventsSorted = sortEvents(fixture.events || []);
if (eventsSorted.length === 0) return '';
const last = eventsSorted[eventsSorted.length - 1];
const prev = eventsSorted[eventsSorted.length - 2];
const one = `${last.id}|${last.type_id}|${last.minute}|${last.extra_minute ?? 0}|${last.player_id ?? ''}|${last.related_player_id ?? ''}|${last.result ?? ''}|${last.rescinded ?? ''}`;
const two = prev
? `${prev.id}|${prev.type_id}|${prev.minute}|${prev.extra_minute ?? 0}|${prev.player_id ?? ''}|${prev.related_player_id ?? ''}|${prev.result ?? ''}|${prev.rescinded ?? ''}`
: '';
// Include two events to reduce edge cases where only the most recent event toggles
return `${two}||${one}`;
}
function hasChanged(oldFixture, newFixture) {
if (!oldFixture) return true;
// State changes
if (oldFixture.state_id !== newFixture.state_id) return true;
// Score changes (more reliable than scores.length)
if (getCurrentScoreKey(oldFixture) !== getCurrentScoreKey(newFixture)) return true;
// Event changes (more reliable than events.length)
if (getLatestEventKey(oldFixture) !== getLatestEventKey(newFixture)) return true;
// Optional: if you display these, include them too
if ((oldFixture.result_info ?? '') !== (newFixture.result_info ?? '')) return true;
return false;
}
// Update a single match card in the DOM
function upsertMatchCard(fixture) {
const container = document.getElementById('matchesContainer');
if (!container) return;
const id = `match-card-${fixture.id}`;
const html = renderMatchCard(fixture);
// If renderMatchCard can return '', skip
if (!html) return;
const existingEl = document.getElementById(id);
if (existingEl) {
existingEl.outerHTML = html;
} else {
// Append new match card
container.insertAdjacentHTML('beforeend', html);
}
}
// Only update changed fixtures
function updateFixtures(newData = []) {
newData.forEach(fixture => {
const existing = appState.fixtures.get(fixture.id);
if (!existing || hasChanged(existing, fixture)) {
appState.fixtures.set(fixture.id, fixture);
upsertMatchCard(fixture);
}
});
}
3. Caching Static Data
Team and league data rarely change, so cache it locally:
// Cache team data to reduce API calls
const TEAM_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// To offer a clearer picture of caching benefits, consider this: the first load might take approximately 1.2 seconds, but with cached data, the load time reduces to about 0.3 seconds. This tangible speed gain is one of the key advantages of using our TEAM_CACHE_TTL_MS method.
function nowMs() {
return Date.now();
}
async function getTeamData(teamId) {
if (!teamId) return null;
const cached = dataCache.teams.get(teamId);
if (cached && cached.expiresAt > nowMs()) {
return cached.value;
}
try {
const data = await makeAPIRequest(`/teams/${teamId}`);
if (data?.data) {
dataCache.teams.set(teamId, {
value: data.data,
expiresAt: nowMs() + TEAM_CACHE_TTL_MS
});
saveTeamsToLocalStorage();
return data.data;
}
} catch (err) {
console.error(`Failed to fetch team ${teamId}:`, err);
}
// Fallback: return stale cache if available
if (cached?.value) return cached.value;
return null;
}
// Store in localStorage for persistence
function saveTeamsToLocalStorage() {
try {
const serialised = Array.from(dataCache.teams.entries());
localStorage.setItem(TEAM_CACHE_KEY, JSON.stringify(serialised));
} catch (err) {
// localStorage can fail in private mode / quota
console.warn('Failed to save team cache:', err);
}
}
function loadTeamsFromLocalStorage() {
try {
const cached = localStorage.getItem(TEAM_CACHE_KEY);
if (!cached) return;
const entries = JSON.parse(cached);
const map = new Map(entries);
// Remove expired entries on load
const t = nowMs();
for (const [id, payload] of map.entries()) {
if (!payload || typeof payload !== 'object' || payload.expiresAt <= t) {
map.delete(id);
}
}
// Replace teams map
dataCache.teams = map;
} catch (err) {
console.warn('Failed to load team cache:', err);
}
}
4. Handle Match States Properly
The API provides 21 different match states. Handle them appropriately:
// Sportmonks v3 fixture states (correct IDs)
const MatchStates = {
NS: 1, // Not Started
INPLAY_1ST_HALF: 2, // 1st Half
HT: 3, // Half-Time
BREAK: 4, // Regular Time Finished (waiting for ET)
FT: 5, // Full-Time
INPLAY_ET: 6, // Extra Time in play
AET: 7, // Finished After Extra Time
FT_PEN: 8, // Full-Time After Penalties
INPLAY_PENALTIES: 9, // Penalty Shootout in play
POSTPONED: 10, // Postponed
SUSPENDED: 11, // Suspended
CANCELLED: 12, // Cancelled
TBA: 13, // To Be Announced
WO: 14, // Walk Over
ABANDONED: 15, // Abandoned
DELAYED: 16, // Delayed
AWARDED: 17, // Awarded
INTERRUPTED: 18, // Interrupted
AWAITING_UPDATES: 19, // Awaiting Updates
DELETED: 20, // Deleted
EXTRA_TIME_BREAK: 21, // Extra Time Break
INPLAY_2ND_HALF: 22, // 2nd Half
PEN_BREAK: 25, // Penalties Break
PENDING: 26 // Pending
};
// Matches you want to treat as "live / ongoing" (in play + common live breaks + still-in-progress issues)
const LIVE_STATE_IDS = new Set([
MatchStates.INPLAY_1ST_HALF,
MatchStates.HT,
MatchStates.BREAK,
MatchStates.INPLAY_2ND_HALF,
MatchStates.INPLAY_ET,
MatchStates.EXTRA_TIME_BREAK,
MatchStates.PEN_BREAK,
MatchStates.INPLAY_PENALTIES,
MatchStates.SUSPENDED,
MatchStates.INTERRUPTED,
MatchStates.AWAITING_UPDATES,
MatchStates.PENDING
]);
function shouldShowInLive(stateId) {
return LIVE_STATE_IDS.has(stateId);
}
// Do not rely on numeric comparisons like stateId <= AET.
// Explicitly list what you consider "needs polling".
function shouldPollForUpdates(stateId) {
return LIVE_STATE_IDS.has(stateId);
}
Conclusion
You’ve now built a fully functional World Cup 2026 live score application using the Sportmonks Football API! This app features real-time score updates, match events, group standings, and comprehensive match details.
Key Takeaways
– The Sportmonks API provides comprehensive football data with flexible includes and filters.
– Efficient polling using the /livescores/latest endpoint minimises API calls.
– Proper rate limit management ensures smooth operation.
– Caching static data reduces unnecessary requests.
– Match states must be handled properly for accurate displays.
Next Steps
To enhance your app further, consider:
– Adding knockout bracket visualisation
– Implementing user favorites and notifications
– Creating detailed player statistics pages
– Adding predictive analytics using the Sportmonks Prediction API
– Building a mobile app version using React Native
Resources
– Sportmonks Documentation
– Sportmonks API Postman Collection
– MySportmonks Dashboard
– Rate Limits Guide
– Best Practices
Ready to take your football app to the next level? Sign up for a Sportmonks account and start building today!

