Progress of current post

Build a World Cup 2026 Live Score App with Sportmonks Football API

Time to read 5 min
Published 12 January 2026
Last updated 12 January 2026
David Jaja
Build a World Cup 2026 Live Score App with Sportmonks Football API
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:

  1. Secure API Communications: Always use HTTPS to encrypt data between your services.
  2. Environment Variables: Store sensitive information, including API tokens, in environment variables or secure vaults, rather than hardcoding them.
  3. 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:

  1. GET All Livescores (/livescores): Returns fixtures 15 minutes before kickoff and 15 minutes after full-time
  2. GET Inplay Livescores (/livescores/inplay): Returns only currently in-play matches
  3. 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!

FAQs

Can I build this app using only frontend JavaScript?
You can for a demo, but you should not do it for production because your API token would be exposed. For production, put a small backend or proxy in front of Sportmonks so the browser never sees the token.
What is the safest way to handle my API token?
Store the token in environment variables on your server (or a secret manager) and call your own backend endpoint from the browser. Your backend then calls Sportmonks and returns only the data your UI needs.
Should I use the query parameter token or the Authorization header?
Both work. For server-side requests, the Authorization header is usually cleaner and easier to manage. If you use query parameters, be careful about logs and analytics tools that might capture URLs.

Written by David Jaja

David Jaja is a technical content manager at Sportmonks, where he makes complex football data easier to understand for developers and businesses. With a background in frontend development and technical writing, he helps bridge the gap between technology and sports data. Through clear, insightful content, he ensures Sportmonks' APIs are accessible and easy to use, empowering developers to build standout football applications