How to Build a Live Cricket Score App with a Free Cricket API

Build a live cricket score app using Highlightly's free Cricket API. This tutorial covers live matches and scorecards with working JavaScript and Python examples.

Live cricket score app

Cricket is one of the most followed sports in the world. Tournaments like the IPL, T20 World Cup and Big Bash pull in millions of fans every season. That audience creates demand for apps, widgets and dashboards that show live scores and match data. When someone wants to build an app, they will need a reliable API.

In this tutorial, we'll build a live cricket score app using the Highlightly Cricket API. It displays scorecards with team logos, format badges and match state indicators. Users can browse any date, filter by team or league. The app refreshes automatically and will pause when the tab is hidden.

We'll also cover a Python CLI for server-side use with date selection, filtering and compact output. If you can write a basic fetch request and render some HTML, the tutorial shouldn't be a problem.

Prerequisites

Let's cover the prerequisites.

You'll need an API key from Highlightly. Head over to the login page and create an account, or sign up through RapidAPI if you prefer that platform. Both give you access to the same data, but Highlightly's direct platform offers custom plans and long-term discounts that RapidAPI doesn't. Accounts do not sync across platforms.

Once you have your key, every API request will need the following header:

x-rapidapi-key: YOUR_API_KEY

This header works on both platforms. If you're using RapidAPI, you'll also need:

x-rapidapi-host: cricket-highlights-api.p.rapidapi.com

The base URL for all requests through Highlightly is https://cricket.highlightly.net. Through RapidAPI, it's https://cricket-highlights-api.p.rapidapi.com.

Every API response includes x-ratelimit-requests-remaining in the headers. This will tell you how many requests you have left before hitting your daily limit. It's a good habit to log this during development, so you don't burn through your quota while testing.

The code examples in this tutorial use vanilla JavaScript. Everything can be translated directly to React, Vue, Svelte or anything else you're comfortable with.

Building a Live Dashboard

Before jumping into the code, it's worth covering what 100 requests per day is actually enough for. With smart caching and tab-hidden polling, this quota will support a surprising range of projects:

  • a live score widget embedded on a blog or portfolio site
  • a daily IPL digest that runs once in the morning and once at night
  • a Telegram or Discord bot that posts score updates to a group
  • a scorecard page on a cricket news site that refreshes during matches

Coverage includes IPL, BBL, PSL, CPL, SA20, The Hundred, County Championship and international matches across Test, ODI and T20 formats.

Understanding the API Response

Before writing any render-related code, we need to understand what the GET /matches endpoint returns. Each match object contains the teams, league, format, and a state object with scores and match progress. Here is a trimmed example from a real response:

{
  "format": "T20",
  "startTime": "2026-04-17T14:00:00.000Z",
  "homeTeam": {
    "name": "Gujarat Titans",
    "abbreviation": "GT",
    "logo": "https://highlightly.net/cricket/images/teams/45457057.png"
  },
  "awayTeam": {
    "name": "Kolkata Knight Riders",
    "abbreviation": "KKR",
    "logo": "https://highlightly.net/cricket/images/teams/11759127.png"
  },
  "league": { "name": "IPL", "season": 2026 },
  "state": {
    "description": "Finished",
    "report": "GT won by 5 wickets (with 2 balls remaining)",
    "teams": {
      "home": { "score": "181/5", "info": "19.4/20 ov, T:181" },
      "away": { "score": "180", "info": null }
    }
  }
}

Scores live inside state.teams.home and state.teams.away. The score field holds the runs and wickets. The info field shows overs and target when relevant. For Test matches with multiple innings, the score can look like "261 & 8/1". If a team hasn't batted yet, both fields are null.

The state.description field tells you the match status. The state.report field gives a human-readable result or day summary.

Project Structure

To keep everything simple and tidy, we will split the app into multiple modules. If you decide later that you want to render with React instead of plain DOM, only render.js needs to change.

live-score-app/
├── index.html       # Markup, date picker, search bar
├── styles.css       # Styling
├── config.js        # API key and configuration
├── api.js           # Highlightly API client
├── render.js        # DOM rendering, filtering, sorting
└── app.js           # Entry point, refresh loop, date/search wiring

The HTML Shell

The HTML file defines a date picker and search input above the score grid. The date picker lets users browse historical matches. The search field filters results client-side without using any extra API calls.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="color-scheme" content="dark" />
  <title>Live Cricket Scores</title>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <header>
    <h1>Live Cricket Scores</h1>
    <p id="status" class="status">Loading...</p>
    <div class="controls">
      <input type="date" id="date-picker" class="date-picker"
             aria-label="Select match date" />
      <input type="text" id="search" class="search"
             placeholder="Filter by team or league..."
             aria-label="Filter matches" />
    </div>
  </header>

  <main>
    <div id="scores" class="grid" aria-live="polite"></div>
  </main>

  <script type="module" src="app.js"></script>
</body>
</html>

The color-scheme meta tag tells the browser to render scrollbars and form controls in dark mode. The aria-live="polite" attribute announces score updates to screen readers without interrupting.

Configuration

// config.js
export const config = {
  apiKey: "YOUR_API_KEY",
  apiBase: "https://cricket.highlightly.net",
  refreshMs: 60_000,
  requestTimeoutMs: 10_000,
  maxMatchesPerPage: 50,
};

Never commit a real API key to a public repository. In production, call the Highlightly API from your backend and expose a thin proxy to the browser so the key stays on the server.

Building the API Client

The API module handles authentication, timeouts and errors in one place. The rest of the app just calls fetchMatches() and gets back an array.

// api.js
import { config } from "./config.js";

function buildUrl(path, params = {}) {
  const url = new URL(config.apiBase + path);
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined && value !== null && value !== "") {
      url.searchParams.set(key, value);
    }
  }
  return url.toString();
}

async function request(path, params = {}) {
  const url = buildUrl(path, params);

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

  try {
    const response = await fetch(url, {
      headers: { "x-rapidapi-key": config.apiKey },
      signal: controller.signal,
    });

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

    const remaining = response.headers.get("x-ratelimit-requests-remaining");
    if (remaining !== null) {
      console.debug(`Requests remaining today: ${remaining}`);
    }

    return await response.json();
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error("Request timed out. Check your connection and try again.");
    }
    throw error;
  } finally {
    clearTimeout(timeout);
  }
}

export async function fetchMatches({ date, leagueName, limit } = {}) {
  const body = await request("/matches", {
    date,
    leagueName,
    limit: limit ?? config.maxMatchesPerPage,
  });
  return body.data ?? [];
}

export function toIsoDate(dateObj) {
  const year = dateObj.getFullYear();
  const month = String(dateObj.getMonth() + 1).padStart(2, "0");
  const day = String(dateObj.getDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
}

export function todayIso() {
  return toIsoDate(new Date());
}

The AbortController provides a real request timeout. The todayIso helper builds the date in the user's local timezone, avoiding the common toISOString().slice(0, 10) shortcut which uses UTC and silently breaks near midnight for users ahead of UTC.

Rendering the Match Cards

The render module reads scores from state.teams.home and state.teams.away. Each team row shows the logo, name, and score with overs and target info when available. Match cards also display the format (TEST, T20, ODI), the round, the match result report, and a state badge with local time.

// render.js (key sections)
function renderTeamRow(team, teamState) {
  const name = team?.name ?? "TBD";
  const logo = team?.logo ?? "";
  const score = teamState?.score ?? null;
  const info = teamState?.info ?? null;

  let scoreDisplay = "";
  if (score) {
    scoreDisplay = score;
    if (info) scoreDisplay += ` (${info})`;
  }

  return el("div", {
    className: "card__team",
    children: [
      logo
        ? el("img", { className: "card__team-logo", src: logo, alt: name })
        : null,
      el("span", { className: "card__team-name", children: [name] }),
      scoreDisplay
        ? el("span", { className: "card__team-score", children: [scoreDisplay] })
        : null,
    ],
  });
}

The function takes two arguments. The first one is the team object (for name and logo), and the second one is the team state object (for score and info). When both score and info are present, they combine naturally: 181/5 (19.4/20 ov, T:181). For Test matches with multiple innings, the API returns the score as a single string like 261 & 8/1, which displays correctly without any extra parsing. When a team hasn't batted yet, score is null and no score element is rendered.

Match states are classified into multiple categories for visual treatment. In play gets a red live badge. Stumps, Tea and Lunch get a yellow break badge with the pulsing dot.

function classifyState(description) {
  if (!description) return "other";
  const lower = description.toLowerCase();
  if (["in play", "live", "innings break"].includes(lower)) return "live";
  if (["stumps", "tea", "lunch", "dinner", "drinks"].includes(lower)) return "break";
  if (["finished", "abandoned", "no result"].includes(lower)) return "finished";
  if (["not started", "scheduled"].includes(lower)) return "upcoming";
  return "other";
}

The full match card includes the league name, format badge, round, both team rows, the result report (if available) and a footer with the state badge and local time converted from UTC.

Filtering and sorting are also handled client-side in the render module. The filter matches on team names, abbreviations, league name, format, country and round. Sorting places live matches first, then break states (Stumps, Tea), then upcoming, then finished.

export function filterMatches(matches, query) {
  if (!query || query.trim() === "") return matches;
  const terms = query.toLowerCase().trim().split(/\s+/);

  return matches.filter((match) => {
    const searchable = [
      match.homeTeam?.name,
      match.homeTeam?.abbreviation,
      match.awayTeam?.name,
      match.awayTeam?.abbreviation,
      match.league?.name,
      match.round,
      match.format,
      match.country?.name,
    ]
      .filter(Boolean)
      .join(" ")
      .toLowerCase();

    return terms.every((term) => searchable.includes(term));
  });
}

The search supports multi-word queries. Typing county test will match County Championship Test matches. Typing GT will match Gujarat Titans by abbreviation.

Date Selection, Search and Refresh Logic

The entry point wires the date picker, search bar and refresh loop together.

// app.js
import { fetchMatches, todayIso, toIsoDate } from "./api.js";
import {
  renderMatches, renderError, filterMatches, sortMatches,
} from "./render.js";
import { config } from "./config.js";

const statusEl = document.getElementById("status");
const scoresEl = document.getElementById("scores");
const datePickerEl = document.getElementById("date-picker");
const searchEl = document.getElementById("search");

let refreshTimer = null;
let cachedMatches = [];
let selectedDate = todayIso();

datePickerEl.value = selectedDate;

function applyFilter() {
  const sorted = sortMatches(cachedMatches);
  const filtered = filterMatches(sorted, searchEl.value);
  renderMatches(scoresEl, filtered);
}

async function refresh() {
  try {
    cachedMatches = await fetchMatches({ date: selectedDate });
    applyFilter();

    const timestamp = new Date().toLocaleTimeString();
    const count = cachedMatches.length;
    const label = count === 1 ? "match" : "matches";
    setStatus(`Updated ${timestamp} · ${count} ${label}`);
  } catch (error) {
    console.error("Refresh failed:", error);
    renderError(scoresEl, error.message);
  }
}

function startRefresh() {
  if (refreshTimer) return;
  if (selectedDate !== todayIso()) return;
  refreshTimer = setInterval(refresh, config.refreshMs);
}

datePickerEl.addEventListener("change", () => {
  const newDate = datePickerEl.value;
  if (!newDate || newDate === selectedDate) return;
  selectedDate = newDate;
  stopRefresh();
  refresh();
  startRefresh();
});

let searchDebounce = null;
searchEl.addEventListener("input", () => {
  clearTimeout(searchDebounce);
  searchDebounce = setTimeout(applyFilter, 200);
});

refresh();
startRefresh();

The date picker triggers a fresh API call when changed, but auto-refresh only runs when viewing today's matches. Historical dates don't change, so polling would waste quota.

The search input filters from cached data with a 200ms debounce time. No API calls are needed since the full match list is already in memory.

The startRefresh() function guards against stacking multiple intervals on rapid tab switches. The visibilitychange handler (included in the full source) pauses polling when the tab is hidden.

ES modules require HTTP. Run python3 -m http.server 8000 from the project folder and open http://localhost:8000.

A Python Companion Script

The same API works server-side. The Python project follows the same module structure with a typed API client, a formatting module and a CLI entry point.

python-scorecard/
├── main.py                  # CLI entry point
├── highlightly/
│   ├── __init__.py
│   ├── client.py            # API client
│   └── formatting.py        # Output formatting
└── requirements.txt

The API client is built as a frozen dataclass so it can't be mutated after creation. It uses typed signatures and a custom exception type, which makes it easy to tell whether a failure came from the network or from the API itself. The response.json() call is wrapped in a try/except ValueError block to catch non-JSON responses from proxies or gateways.

# highlightly/client.py
from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date
from typing import Any

import requests

logger = logging.getLogger(__name__)


class HighlightlyError(Exception):
    """Raised when the Highlightly API returns an error."""


@dataclass(frozen=True)
class HighlightlyClient:
    api_key: str
    base_url: str = "https://cricket.highlightly.net"
    timeout_seconds: float = 10.0

    def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
        cleaned = {k: v for k, v in params.items() if v is not None}
        headers = {"x-rapidapi-key": self.api_key}

        try:
            response = requests.get(
                f"{self.base_url}{path}",
                params=cleaned,
                headers=headers,
                timeout=self.timeout_seconds,
            )
        except requests.Timeout as exc:
            raise HighlightlyError("Request timed out") from exc
        except requests.ConnectionError as exc:
            raise HighlightlyError("Could not reach Highlightly") from exc

        remaining = response.headers.get("x-ratelimit-requests-remaining")
        if remaining is not None:
            logger.debug("Requests remaining today: %s", remaining)

        if not response.ok:
            raise HighlightlyError(
                f"API returned {response.status_code}: {response.text[:200]}"
            )

        try:
            return response.json()
        except ValueError as exc:
            raise HighlightlyError(
                f"Invalid JSON in response: {response.text[:200]}"
            ) from exc

    def fetch_matches(
        self,
        match_date: date,
        league_name: str | None = None,
        limit: int = 50,
    ) -> list[dict[str, Any]]:
        body = self._get(
            "/matches",
            {
                "date": match_date.isoformat(),
                "leagueName": league_name,
                "limit": limit,
            },
        )
        return body.get("data", [])

The formatting module reads scores from state.teams, converts startTime to local time, and displays the match format alongside the league name. Break states like Stumps and Tea get distinct markers.

The CLI supports four flags beyond the basics. --date accepts YYYY-MM-DD or shortcuts like today, yesterday and tomorrow. --filter searches results client-side by team, league, abbreviation, format or country. --compact prints one line per match using team abbreviations. --league filters server-side before the data is returned.

# main.py (key sections)
def parse_date(value: str) -> date:
    shortcuts = {
        "today": date.today(),
        "yesterday": date.today() - timedelta(days=1),
        "tomorrow": date.today() + timedelta(days=1),
    }
    if value.lower() in shortcuts:
        return shortcuts[value.lower()]
    return date.fromisoformat(value)


def main(argv=None):
    # ... argument parsing ...

    matches = client.fetch_matches(args.date, league_name=args.league)

    if args.query:
        matches = [m for m in matches if matches_contain(m, args.query)]

    if args.compact:
        for match in matches:
            print(format_match_compact(match))
    else:
        for match in matches:
            print(format_match(match))

To run the script on Linux or macOS, type the following commands:

pip install -r requirements.txt
export HIGHLIGHTLY_API_KEY=your_key_here
echo $HIGHLIGHTLY_API_KEY
python main.py --league "Indian Premier League"

As for Windows via CMD:

pip install -r requirements.txt
set HIGHLIGHTLY_API_KEY=your_key_here
echo %HIGHLIGHTLY_API_KEY%
python main.py --league "Indian Premier League"

Or on Windows via PowerShell:

pip install -r requirements.txt
$env:HIGHLIGHTLY_API_KEY="your_key_here"
echo $env:HIGHLIGHTLY_API_KEY
python main.py --league "Indian Premier League"

The echo line should print your API key back. If it prints an empty line or the variable name itself, the key wasn't set correctly.

Sample detailed output:

Matches for 2026-04-17 (13 found):

IPL · T20
  Gujarat Titans: 181/5 (19.4/20 ov, T:181)
  Kolkata Knight Riders: 180
  → GT won by 5 wickets (with 2 balls remaining)
  ✓ Finished  17 Apr 14:00

County Championship Division Two · TEST
  Gloucestershire: 124/6 (44 ov)
  Lancashire
  → Day 1 - Lancashire chose to field.
  🟡 Stumps  17 Apr 10:00

Merwais Nika Regional 3-Day Trophy · TEST
  Band-e-Amir Region: 250
  Boost Region: 261 & 8/1 (2.3 ov)
  → Day 2 - Boost lead by 19 runs.
  🔴 LIVE  17 Apr 05:30

Sample compact output with --compact:

GT 181/5  vs  KKR 180  [Finished] T20
GLOUC 124/6  vs  LANCS  [Stumps] TEST
BEAR 250  vs  BOOST 261 & 8/1  [In play] TEST
LQ 134  vs  QG 138/4  [Finished] T20
SL-A 381  vs  NZ-A 326/6  [Tea] TEST

Filter by format and date:

python main.py --date yesterday --filter "test" --compact

Handling Rate Limits on the Free Tier

Every response includes x-ratelimit-requests-remaining in the headers. Both examples above log it at debug level.

Let's check what the best practices are to keep you under the 100 per day limit. Firstly, cache everything aggressively. A 60-second cache on live match data is almost invisible and cuts requests by a significant margin. Secondly, poll data smartly. The app only auto-refreshes when viewing today's matches. Historical dates are fetched once. The tab-hidden handler and double-interval guard prevent quota waste from background polling. Thirdly, only request the data that you actually need. Passing leagueName filters server-side and reduces payload size.

Switching to Pro Plan or Above

If your app has more than a handful of daily users, 100 requests per day will run out. If you need odds data for fantasy or prediction products, you will need to switch to a paid tier.

Pro plans start at $6.99 per month with up to 40% off on annual billing through Highlightly. Upgrading does not change any code. The same API key goes into config.js and everything keeps working with higher rate limits.

Wrapping Up

That covers the full build. You now have a live score app in JavaScript with date browsing, search and format-aware cards, plus a Python CLI with date selection, filtering and compact output.

A few ideas for what to build next. Start by showing head-to-head records between two teams. Then add a comparison tool using player stats. Finally, fetch detailed scorecards via GET /matches/{id}.

The full Cricket API documentation covers every endpoint with response schemas and examples. If you're evaluating providers, our Best Cricket APIs in 2026 comparison breaks down the main options. Highlightly's All Sports API also covers football, NFL, NBA, NHL, MLB, rugby, volleyball, basketball and handball under a single key. The same patterns from this tutorial apply if you branch into other sports.