Soccer API Tutorials

How to Build a Live Soccer Scores App Using a Soccer API

Learn how to build a live soccer scores application with fixtures, real-time scores, goals, cards, injury time, REST polling, WebSocket events, caching and safe reconnection.

A live soccer scores app looks simple on the surface: show today’s matches, display the current score and update the page when something happens. The production version is more demanding. It must preserve stable match and team identifiers, understand match periods, process goals and cards only once, recover after connection failures and avoid exposing private API credentials.

This tutorial explains how to build a practical live soccer scores application using a Soccer API. It covers fixtures, live match state, event timelines, REST polling, WebSocket streaming, frontend rendering, caching, duplicate-event protection, reconnection, notifications and production deployment.

Implementation note: The endpoint paths, response fields, authentication method and event payloads in this guide are illustrative. Replace them with the confirmed production Soccer API specification before launching your application.

What You Will Build

The example application will provide a match list and a detailed live match view with the following features:

  • Today’s scheduled, live and completed soccer matches
  • Home and away teams
  • Current score
  • Match period and minute
  • Injury or stoppage time
  • Goals, cards, substitutions, penalties and VAR events
  • Last-updated indicator
  • Automatic refresh through REST or WebSocket
  • Recovery after network interruptions

Recommended Architecture

Soccer API
   |
   +-- Fixtures endpoint
   +-- Live matches endpoint
   +-- Match details endpoint
   +-- Match events endpoint
   +-- WebSocket stream
   |
   v
Application backend
   |
   +-- API authentication
   +-- Match cache
   +-- Event validation
   +-- Duplicate protection
   +-- Internal live updates
   |
   v
Web or mobile frontend

The frontend should normally communicate with your own backend rather than calling the provider directly with a permanent private API key. The backend can protect credentials, share cached responses across users and centralise event validation.

Choose REST, WebSocket or Both

Requirement REST API WebSocket API
Upcoming fixtures Recommended Usually unnecessary
Initial match state Recommended Subscribe after loading state
Live goals and cards Requires polling Server pushes supported events
Historical results Recommended Not required
Recovery after disconnect Reload complete state Reconnect and resubscribe

A strong production pattern uses both. REST provides the authoritative complete state, while WebSocket provides incremental live updates.

Step 1: Set Up the Project

The frontend example can be built with plain HTML, CSS and JavaScript. A larger application may use React, Vue, Next.js or another framework. The backend can be implemented with Node.js, PHP, Python or another server technology.

Suggested Project Structure

soccer-live-scores/
  backend/
    server.js
    soccer-api.js
    cache.js
    events.js
  frontend/
    index.html
    app.js
    styles.css
  .env
  package.json

Environment Variables

SOCCER_API_BASE_URL=https://api.example.com/v1
SOCCER_API_KEY=YOUR_PRIVATE_API_KEY
PORT=3000
Never commit the real .env file to a public source repository.

Step 2: Create a Server-Side Soccer API Client

The backend client should add authentication, set timeouts and handle non-2xx responses consistently.

Node.js Request Helper

const API_BASE_URL = process.env.SOCCER_API_BASE_URL;
const API_KEY = process.env.SOCCER_API_KEY;

async function requestSoccerApi(path, params = {}) {
  const url = new URL(`${API_BASE_URL}${path}`);

  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null) {
      url.searchParams.set(key, String(value));
    }
  }

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 15000);

  try {
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        Accept: 'application/json'
      },
      signal: controller.signal
    });

    if (!response.ok) {
      const body = await response.text();

      throw new Error(
        `Soccer API request failed: ${response.status} ${body}`
      );
    }

    return await response.json();
  } finally {
    clearTimeout(timeout);
  }
}

module.exports = { requestSoccerApi };

Why Use One Request Helper?

  • Authentication is handled in one place
  • Timeout behaviour is consistent
  • Error logging is easier
  • Future API version changes require fewer edits
  • Retries and monitoring can be added centrally

Step 3: Retrieve Today’s Soccer Fixtures

A live-score home screen usually begins with the fixtures scheduled for the selected date.

Illustrative Endpoint

GET /soccer/matches?date=2026-08-06

Backend Route

const express = require('express');
const { requestSoccerApi } = require('./soccer-api');

const app = express();

app.get('/api/matches', async (request, response) => {
  try {
    const date = request.query.date;

    if (!date) {
      return response.status(400).json({
        error: 'The date query parameter is required.'
      });
    }

    const data = await requestSoccerApi('/soccer/matches', {
      date
    });

    return response.json(data);
  } catch (error) {
    console.error(error);

    return response.status(502).json({
      error: 'Unable to retrieve soccer matches.'
    });
  }
});

Illustrative Fixture Response

{
  "data": [
    {
      "match_id": "match_74021",
      "competition": {
        "id": "competition_24",
        "name": "Example Premier League"
      },
      "starts_at": "2026-08-06T18:00:00Z",
      "status": "scheduled",
      "home_team": {
        "id": "team_18",
        "name": "North City"
      },
      "away_team": {
        "id": "team_29",
        "name": "United Athletic"
      }
    }
  ]
}

Step 4: Retrieve Live Match State

The live-match endpoint should return the complete current state required to render the interface.

Illustrative Endpoint

GET /soccer/matches/live

Example Live Match Response

{
  "data": [
    {
      "match_id": "match_74021",
      "status": "live",
      "period": "second_half",
      "minute": 67,
      "injury_time": 0,
      "home_team": {
        "id": "team_18",
        "name": "North City",
        "score": 2
      },
      "away_team": {
        "id": "team_29",
        "name": "United Athletic",
        "score": 1
      },
      "updated_at": "2026-08-06T10:31:22Z"
    }
  ]
}

Step 5: Normalise the API Response

Avoid letting provider-specific response shapes spread across every frontend component. Convert the response into a stable internal model.

function normaliseMatch(match) {
  return {
    id: match.match_id,
    status: match.status,
    period: match.period ?? null,
    minute: match.minute ?? null,
    injuryTime: match.injury_time ?? null,
    startsAt: match.starts_at ?? null,
    updatedAt: match.updated_at ?? null,
    competition: {
      id: match.competition?.id ?? null,
      name: match.competition?.name ?? 'Unknown competition'
    },
    homeTeam: {
      id: match.home_team.id,
      name: match.home_team.name,
      score: match.home_team.score ?? null
    },
    awayTeam: {
      id: match.away_team.id,
      name: match.away_team.name,
      score: match.away_team.score ?? null
    }
  };
}

Benefits of a Normalised Model

  • Frontend components remain stable
  • Provider changes are easier to manage
  • Testing requires fewer provider-specific fixtures
  • Multiple providers can be mapped into one application model
  • Missing values can be handled consistently

Step 6: Build the Match List Interface

HTML

<main class="scores-app">
  <header class="scores-header">
    <div>
      <p class="eyebrow">Live Soccer</p>
      <h1>Today’s Matches</h1>
    </div>

    <button id="refresh-button" type="button">
      Refresh
    </button>
  </header>

  <div id="connection-status" class="connection-status">
    Loading matches…
  </div>

  <section id="match-list" class="match-list"></section>
</main>

CSS

.scores-app {
  width: min(1100px, calc(100% - 32px));
  margin: 40px auto;
  font-family: Inter, Arial, sans-serif;
}

.scores-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  margin-bottom: 24px;
}

.match-list {
  display: grid;
  gap: 16px;
}

.match-card {
  padding: 20px;
  border: 1px solid #dfe5ec;
  border-radius: 16px;
  background: #ffffff;
}

.match-card__meta,
.match-card__footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}

.match-card__teams {
  display: grid;
  gap: 12px;
  margin: 22px 0;
}

.match-team {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: 20px;
  font-size: 18px;
}

.match-team__score {
  min-width: 34px;
  text-align: center;
  font-size: 26px;
  font-weight: 800;
}

.match-status--live {
  font-weight: 800;
}

.connection-status {
  margin-bottom: 16px;
  font-size: 14px;
}

Step 7: Render Soccer Matches With JavaScript

const matchList = document.querySelector('#match-list');
const connectionStatus = document.querySelector('#connection-status');
const refreshButton = document.querySelector('#refresh-button');

function formatClock(match) {
  if (match.status === 'live') {
    const addedTime = match.injuryTime
      ? `+${match.injuryTime}`
      : '';

    return `${match.minute ?? 0}${addedTime}′`;
  }

  if (match.status === 'completed') {
    return 'FT';
  }

  if (match.startsAt) {
    return new Intl.DateTimeFormat(undefined, {
      hour: '2-digit',
      minute: '2-digit'
    }).format(new Date(match.startsAt));
  }

  return match.status;
}

function createMatchCard(match) {
  const article = document.createElement('article');
  article.className = 'match-card';
  article.dataset.matchId = match.id;

  article.innerHTML = `
    <div class="match-card__meta">
      <span>${escapeHtml(match.competition.name)}</span>
      <span class="${match.status === 'live'
        ? 'match-status--live'
        : ''}">
        ${escapeHtml(formatClock(match))}
      </span>
    </div>

    <div class="match-card__teams">
      <div class="match-team">
        <span>${escapeHtml(match.homeTeam.name)}</span>
        <strong class="match-team__score">
          ${match.homeTeam.score ?? '–'}
        </strong>
      </div>

      <div class="match-team">
        <span>${escapeHtml(match.awayTeam.name)}</span>
        <strong class="match-team__score">
          ${match.awayTeam.score ?? '–'}
        </strong>
      </div>
    </div>

    <div class="match-card__footer">
      <span>${escapeHtml(match.status)}</span>
      <span>
        Updated ${formatUpdatedTime(match.updatedAt)}
      </span>
    </div>
  `;

  return article;
}

function renderMatches(matches) {
  matchList.replaceChildren(
    ...matches.map(createMatchCard)
  );
}

function escapeHtml(value) {
  const element = document.createElement('div');
  element.textContent = String(value);
  return element.innerHTML;
}

function formatUpdatedTime(value) {
  if (!value) {
    return 'not available';
  }

  return new Intl.DateTimeFormat(undefined, {
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit'
  }).format(new Date(value));
}
Escape or safely insert all external text. Team and competition names should not be inserted into raw HTML without sanitisation.

Step 8: Load Matches From Your Backend

async function loadMatches() {
  connectionStatus.textContent = 'Loading matches…';
  refreshButton.disabled = true;

  try {
    const date = new Date().toISOString().slice(0, 10);

    const response = await fetch(
      `/api/matches?date=${encodeURIComponent(date)}`
    );

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    const payload = await response.json();
    const matches = payload.data.map(normaliseMatch);

    renderMatches(matches);
    connectionStatus.textContent =
      `Updated at ${new Date().toLocaleTimeString()}`;
  } catch (error) {
    console.error(error);
    connectionStatus.textContent =
      'Live scores are temporarily unavailable.';
  } finally {
    refreshButton.disabled = false;
  }
}

refreshButton.addEventListener('click', loadMatches);
loadMatches();

Step 9: Add Controlled REST Polling

For a basic application, poll live matches at a controlled interval. Do not request every match separately for every user.

let refreshTimer;

function startPolling() {
  stopPolling();

  refreshTimer = setInterval(() => {
    if (document.visibilityState === 'visible') {
      loadMatches();
    }
  }, 30000);
}

function stopPolling() {
  if (refreshTimer) {
    clearInterval(refreshTimer);
    refreshTimer = undefined;
  }
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    loadMatches();
  }
});

startPolling();

Polling Best Practices

  • Use the provider’s documented rate limits
  • Share responses through a backend cache
  • Pause unnecessary polling when the page is hidden
  • Poll live matches more often than scheduled fixtures
  • Stop high-frequency polling after full time
  • Show the last successful update time

Step 10: Add WebSocket Live Events

WebSocket can update the interface as supported events arrive instead of waiting for the next polling cycle.

Illustrative Subscription

const socket = new WebSocket(
  'wss://stream.example.com/v1/soccer'
);

socket.addEventListener('open', () => {
  socket.send(JSON.stringify({
    action: 'authenticate',
    token: 'SHORT_LIVED_ACCESS_TOKEN'
  }));

  socket.send(JSON.stringify({
    action: 'subscribe',
    channel: 'match_events',
    match_ids: [
      'match_74021'
    ]
  }));
});

Process Incoming Events

const processedEventIds = new Set();

socket.addEventListener('message', (message) => {
  const event = JSON.parse(message.data);

  if (event.type === 'ping') {
    socket.send(JSON.stringify({
      type: 'pong',
      timestamp: event.timestamp
    }));
    return;
  }

  if (!event.event_id || processedEventIds.has(event.event_id)) {
    return;
  }

  processedEventIds.add(event.event_id);
  applySoccerEvent(event);
});

function applySoccerEvent(event) {
  switch (event.type) {
    case 'goal':
      updateScore(event);
      addTimelineEvent(event);
      break;

    case 'yellow_card':
    case 'red_card':
    case 'substitution':
    case 'var_decision':
    case 'penalty':
      addTimelineEvent(event);
      break;

    case 'match_status':
      updateMatchStatus(event);
      break;

    default:
      console.debug('Unhandled soccer event:', event.type);
  }
}

Step 11: Update the Score Safely

function updateScore(event) {
  const matchCard = document.querySelector(
    `[data-match-id="${CSS.escape(event.match_id)}"]`
  );

  if (!matchCard || !event.score) {
    return;
  }

  const scoreElements = matchCard.querySelectorAll(
    '.match-team__score'
  );

  if (scoreElements.length !== 2) {
    return;
  }

  scoreElements[0].textContent = event.score.home;
  scoreElements[1].textContent = event.score.away;
}

Use the score supplied by the authoritative event or reload the current match state. Avoid calculating the official score only by incrementing a local number, because goals can be disallowed or corrected.

Step 12: Build a Match Event Timeline

Illustrative Event Payload

{
  "event_id": "event_991827",
  "match_id": "match_74021",
  "type": "goal",
  "sequence": 184,
  "minute": 67,
  "injury_time": 0,
  "team_id": "team_18",
  "player": {
    "id": "player_301",
    "name": "A. Morgan"
  },
  "score": {
    "home": 2,
    "away": 1
  }
}

Timeline Rendering

function addTimelineEvent(event) {
  const timeline = document.querySelector(
    `[data-timeline-match-id="${CSS.escape(event.match_id)}"]`
  );

  if (!timeline) {
    return;
  }

  const item = document.createElement('li');
  const minute = event.injury_time
    ? `${event.minute}+${event.injury_time}′`
    : `${event.minute ?? 0}′`;

  const playerName = event.player?.name
    ? ` — ${event.player.name}`
    : '';

  item.textContent =
    `${minute} ${formatEventType(event.type)}${playerName}`;

  timeline.prepend(item);
}

function formatEventType(type) {
  return type
    .split('_')
    .map(word => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

Step 13: Handle Match Status Correctly

Status Recommended display
scheduled Show kickoff time
delayed Show delay notice and revised time where available
first_half Show live minute and score
halftime Show HT and halftime score
second_half Show live minute and score
extra_time Show the active extra-time period
penalty_shootout Show shootout state and penalty score
completed Show FT and final result
postponed Show the official postponed status
cancelled Show the official cancelled status

Step 14: Display Injury Time

Do not treat the match minute as a simple stopwatch. Injury time should be displayed using the period and added-time fields returned by the provider.

function formatMatchMinute(minute, injuryTime) {
  if (minute === null || minute === undefined) {
    return '';
  }

  if (injuryTime && injuryTime > 0) {
    return `${minute}+${injuryTime}′`;
  }

  return `${minute}′`;
}

Step 15: Add Reconnection Logic

let socket;
let reconnectAttempts = 0;
let reconnectTimer;

function connectSocket() {
  socket = new WebSocket(
    'wss://stream.example.com/v1/soccer'
  );

  socket.addEventListener('open', () => {
    reconnectAttempts = 0;
    connectionStatus.textContent = 'Live connection active';
    subscribeToRequiredMatches();
  });

  socket.addEventListener('close', () => {
    connectionStatus.textContent =
      'Live connection interrupted. Reconnecting…';

    scheduleReconnect();
  });

  socket.addEventListener('error', () => {
    socket.close();
  });
}

function scheduleReconnect() {
  clearTimeout(reconnectTimer);

  const baseDelay = Math.min(
    1000 * Math.pow(2, reconnectAttempts),
    30000
  );

  const jitter = Math.floor(Math.random() * 1000);
  reconnectAttempts += 1;

  reconnectTimer = setTimeout(async () => {
    await loadMatches();
    connectSocket();
  }, baseDelay + jitter);
}

Why Reload REST State After Reconnecting?

One or more events may have occurred while the socket was disconnected. Reloading complete state prevents the interface from continuing with an incorrect score or match status.

Step 16: Prevent Duplicate Events

Duplicate protection should exist in the backend database, not only in a temporary browser set.

processed_soccer_events

event_id          unique
match_id
sequence
event_type
provider_time
processed_at
payload_hash

Safe Processing Workflow

1. Receive the event
2. Validate match_id and event_id
3. Check whether event_id already exists
4. Compare the event sequence
5. Begin a database transaction
6. Store the unique event
7. Update the match state
8. Commit the transaction
9. Notify connected users

Step 17: Add Backend Caching

Data type Suggested cache strategy
Competitions and teams Longer cache with periodic refresh
Upcoming fixtures Moderate cache
Scheduled match state Moderate cache until kickoff approaches
Live match state Short cache or event-driven update
Completed results Long cache after final reconciliation

A shared cache prevents every visitor from triggering the same upstream API request.

Step 18: Add Goal Notifications

Notifications should be triggered only after event validation and duplicate protection.

async function handleConfirmedGoal(event) {
  const inserted = await saveUniqueEvent(event);

  if (!inserted) {
    return;
  }

  await updateStoredMatchScore(event.match_id, event.score);

  await publishLiveUpdate({
    matchId: event.match_id,
    type: 'goal',
    event
  });

  await queueUserNotifications({
    matchId: event.match_id,
    eventId: event.event_id,
    title: 'Goal',
    body: buildGoalNotificationText(event)
  });
}

Notification Best Practices

  • Respect the user’s selected teams and competitions
  • Prevent duplicate alerts
  • Handle disallowed or corrected goals
  • Do not expose private account data in notification text
  • Allow users to disable selected event types
  • Follow platform permission and rate-limit rules

Step 19: Handle Corrections and VAR Decisions

A goal can be reviewed and disallowed. A card or scorer can also be corrected. Your data model should support reversal or replacement events.

Correction workflow

1. Identify the original event
2. Mark the original event as corrected or reversed
3. Apply the new authoritative event
4. Reload the current match score
5. Update the timeline
6. Correct internal points or alerts
7. Preserve the audit history
Avoid deleting the original event without history. An audit trail helps explain why the score or timeline changed.

Step 20: Add Accessibility

  • Use semantic headings and lists
  • Do not communicate live status through colour alone
  • Use an ARIA live region for important score changes
  • Allow keyboard access to match controls
  • Provide sufficient text contrast
  • Avoid excessive animation during frequent updates

Live Score Announcement Region

<div
  id="score-announcer"
  class="screen-reader-text"
  aria-live="polite"
  aria-atomic="true"
></div>
function announceScoreChange(text) {
  const announcer = document.querySelector('#score-announcer');

  announcer.textContent = '';

  window.setTimeout(() => {
    announcer.textContent = text;
  }, 50);
}

Step 21: Add Loading, Empty and Error States

State Recommended message
Loading Loading today’s soccer matches…
No matches No supported matches are scheduled for this date.
Temporary error Scores are temporarily unavailable. Please try again.
Stale connection Live updates are delayed. Reconnecting…
Recovered Live connection restored.

Step 22: Test Important Match Scenarios

  • A scheduled match begins
  • The first goal arrives
  • A goal is disallowed after VAR
  • A yellow card becomes a second-yellow dismissal
  • The match enters injury time
  • The match reaches halftime
  • A knockout match enters extra time
  • A match enters a penalty shootout
  • The WebSocket connection drops
  • Duplicate events arrive after reconnecting
  • The final result is corrected
  • A match is postponed or abandoned

Step 23: Monitor the Production Application

Recommended Metrics

  • Soccer API request success rate
  • API response duration
  • Rate-limit responses
  • WebSocket connection count
  • Heartbeat age
  • Reconnect attempts
  • Event-processing failures
  • Duplicate-event count
  • Sequence gaps
  • Time from provider event to frontend display
  • Cache hit rate

Measure the complete delivery path before publishing any numerical latency or update-frequency claim.

Common Live Soccer App Mistakes

Exposing the API Key in Frontend Code

Keep permanent private credentials on the server.

Opening One Provider Connection Per User

Use a shared backend ingestion layer when the provider’s model and terms support it.

Counting Events Twice

Store unique event IDs and process updates idempotently.

Using Team Names as Primary Keys

Store stable team and match identifiers.

Ignoring Match Status

Handle halftime, injury time, extra time, penalties, postponements and abandonments explicitly.

Trusting the Stream Without Reconciliation

Reload authoritative REST state after reconnecting and at full time.

Claiming Unverified Real-Time Performance

Publish latency, update-frequency and uptime figures only when supported by measurement or service terms.

Live Soccer Scores App FAQs

Which API endpoints are needed for a live soccer scores app?

A typical application needs fixtures, live matches, individual match state, match events and competition information. WebSocket streaming can provide incremental live updates where supported.

Should I use REST or WebSocket for live soccer scores?

Use REST for initial and complete match state. Use WebSocket for supported live events. Combining both provides a stronger recovery model.

How often should a live scores app poll?

Follow the provider’s documented quotas and update guidance. Use server-side caching and stop frequent polling after the match is completed.

How do I prevent duplicate goal notifications?

Store every unique event ID in persistent storage and create the notification only after the event is inserted successfully.

How should injury time be displayed?

Use the match minute, period and injury-time fields returned by the API rather than calculating official time from a local stopwatch.

What happens when the WebSocket disconnects?

Mark the interface as stale, reconnect with controlled backoff, reload current state through REST and then resubscribe.

Can I build the app with WordPress?

Yes. WordPress can provide content, account and frontend pages, while a custom plugin or external backend handles protected API calls, caching and live event processing.

Can I send push notifications for goals?

Yes, after validating the event, preventing duplicates and obtaining the required user permission for the selected notification platform.

Start Building Your Live Soccer Scores App

Begin with fixtures and complete match state, then add safe polling or WebSocket events. Preserve stable IDs, process every event once and reconcile after connection failures.

Build Cricket Products With Reliable API Data

Access live scores, fixtures, ball-by-ball updates, statistics, odds, predictions and historical cricket data through one developer-friendly API.

Get API Access
Written By

James

Chat on WhatsApp