Disclosure: I work with the Orbistats team. The tutorial is hands-on and the code is yours to reuse, but you should know where I'm coming from.
If you've ever built a sports app, you know how it starts: "We'll just add football first." Then someone asks for basketball. Then cricket. Then tennis, because the CEO watches Wimbledon. Six months later you maintain five different data providers, five different JSON shapes, and five different ways to say "this match is live."
In this tutorial we'll build a multi-sport dashboard that covers 13 sports from a single API, with one auth method, one base URL, and one consistent way to ask for fixtures, live scores, standings and odds.
By the end you'll have:
A Node.js and Express backend that keeps your API key safe and caches responses
A single-page dashboard with a tab for each of the 13 sports
Live scores that refresh automatically
Standings and odds panels
A clear path from polling to WebSockets and webhooks
A plan for historical data (charts, backtesting, ML)
No framework required. Just Node 18+ and plain JavaScript, so you can port it to React, Vue or Svelte later.
What is Orbistats, and why use one API for many sports?
Orbistats is a sports data and odds API platform. Instead of selling you one league or one sport, it exposes 13 sports through one REST API: Football, Basketball, American Football, Cricket, Tennis, Baseball, Esports, Combat Sports, Volleyball, Handball, Ice Hockey, Golf and Horse Racing.
For a multi-sport dashboard, that matters for three reasons.
First, one integration. One API key, one Authorization header, one base URL.
Second, one mental model. Fixtures, results, standings, odds and statistics work the same way across sports. Only the sport segment of the URL changes.
Third, one bill. You scale with traffic instead of with the number of vendors.
The platform is split into products you can mix and match:
The Sports Data API covers fixtures, results, standings, teams and competitions.
The Live Scores API covers in-play scores and match events.
The Sports Statistics API covers team and player stats.
The Odds API gives normalized odds from multiple bookmakers.
The Historical Sports Data API gives multi-season archives.
The WebSocket API and Webhooks give push-based delivery.
Widgets are drop-in UI components.
The architecture
Before writing code, here's the shape of what we're building:
text
Browser (dashboard)
| fetch /api/...
v
Your Express server --(cache)--> Orbistats API
- hides API key https://api.orbistats.com/v1/
- validates sport slug
- normalizes JSON
Never call a paid API directly from the browser. Anyone can open DevTools and steal your key. A tiny proxy server fixes that, and it also gives us a place to add caching, which matters a lot on a free tier (more on that later).
Step 0: Get your API key
First, create a free account and copy your API key.
Second, skim the documentation and the quickstart. Authentication is a standard bearer token:
http
Authorization: Bearer YOUR_API_KEY
Third, the base URL is:
text
https://api.orbistats.com/v1/
Fourth, if you want to poke at responses before writing code, use the Sandbox to fire a request and inspect the JSON.
The free plan is enough for this tutorial. At the time of writing (October 2026), the pricing page lists Free at $0, Starter at $19/month for real-time production use, Growth at $79/month (adds historical data, widgets and webhooks), and a custom Enterprise tier.
Step 1: Project setup
bash
mkdir multi-sport-dashboard
cd multi-sport-dashboard
npm init -y
npm install express dotenv
Open package.json and add "type": "module" so we can use import statements.
Create a .env file (and add it to .gitignore):
text
ORBISTATS_API_KEY=your_key_here
PORT=3000
Project structure:
text
multi-sport-dashboard/
server.js
sports.js
normalize.js
public/
index.html
.env
package.json
Step 2: A registry of all 13 sports
Rather than hard-coding sports all over the app, keep one registry. Adding or removing a sport becomes a one-line change.
js
// sports.js
export const SPORTS = [
{ slug: "football", label: "Football", icon: "⚽" },
{ slug: "basketball", label: "Basketball", icon: "🏀" },
{ slug: "american-football", label: "American Football", icon: "🏈" },
{ slug: "cricket", label: "Cricket", icon: "🏏" },
{ slug: "tennis", label: "Tennis", icon: "🎾" },
{ slug: "baseball", label: "Baseball", icon: "⚾" },
{ slug: "esports", label: "Esports", icon: "🎮" },
{ slug: "combat-sports", label: "Combat Sports", icon: "🥊" },
{ slug: "volleyball", label: "Volleyball", icon: "🏐" },
{ slug: "handball", label: "Handball", icon: "🤾" },
{ slug: "ice-hockey", label: "Ice Hockey", icon: "🏒" },
{ slug: "golf", label: "Golf", icon: "⛳" },
{ slug: "horse-racing", label: "Horse Racing", icon: "🏇" },
];
export const SPORT_SLUGS = new Set(SPORTS.map((s) => s.slug));
These slugs mirror the sport pages on the site, such as football, cricket and horse racing. Confirm each slug against the API reference when you wire it up.
Step 3: The proxy server with caching
This is the heart of the app. It does four jobs: attach the bearer token, validate the sport, cache responses, and return clean errors.
js
// server.js
import "dotenv/config";
import express from "express";
import { SPORTS, SPORT_SLUGS } from "./sports.js";
import { normalizeMatches } from "./normalize.js";
const app = express();
const BASE = "https://api.orbistats.com/v1";
const KEY = process.env.ORBISTATS_API_KEY;
if (!KEY) {
console.error("Missing ORBISTATS_API_KEY in .env");
process.exit(1);
}
// tiny in-memory cache
const cache = new Map();
async function orbistats(path, ttlMs = 30_000) {
const hit = cache.get(path);
if (hit && Date.now() - hit.at < ttlMs) return hit.data;
const res = await fetch(${BASE}${path}, {
headers: { Authorization: Bearer ${KEY}, Accept: "application/json" },
});
if (!res.ok) {
const err = new Error(Orbistats ${res.status} on ${path});
err.status = res.status;
throw err;
}
const data = await res.json();
cache.set(path, { at: Date.now(), data });
return data;
}
// validate :sport on every route
app.param("sport", (req, res, next, sport) => {
if (!SPORT_SLUGS.has(sport)) {
return res.status(404).json({ error: Unsupported sport: ${sport} });
}
next();
});
// routes
app.get("/api/sports", (_req, res) => res.json(SPORTS));
app.get("/api/:sport/live", async (req, res, next) => {
try {
const raw = await orbistats(/${req.params.sport}/matches/live, 15_000);
res.json(normalizeMatches(raw));
} catch (e) { next(e); }
});
app.get("/api/:sport/fixtures", async (req, res, next) => {
try {
const raw = await orbistats(/${req.params.sport}/fixtures, 300_000);
res.json(normalizeMatches(raw));
} catch (e) { next(e); }
});
app.get("/api/:sport/standings", async (req, res, next) => {
try {
res.json(await orbistats(/${req.params.sport}/standings, 600_000));
} catch (e) { next(e); }
});
// NOTE: confirm the exact odds path and params in the API reference.
app.get("/api/:sport/odds", async (req, res, next) => {
try {
const q = new URLSearchParams({ match_id: String(req.query.match_id || "") });
res.json(await orbistats(/${req.params.sport}/odds?${q}, 20_000));
} catch (e) { next(e); }
});
// error handler
app.use((err, _req, res, _next) => {
const status = err.status || 500;
const message =
status === 429 ? "Rate limit reached. Try again shortly." : err.message;
res.status(status).json({ error: message });
});
app.use(express.static("public"));
app.listen(process.env.PORT || 3000, () =>
console.log(Dashboard running on http://localhost:${process.env.PORT || 3000})
);
Notice the different cache lifetimes. Live scores get 15 seconds, fixtures get 5 minutes, standings get 10 minutes. Match your cache to how fast the data actually changes. This single habit will save you most of your request budget.
Step 4: One normalizer for 13 very different sports
Here's the part of multi-sport apps that usually hurts. A football match is Home vs Away. A tennis match is Player vs Player. A golf tournament has a field of 150 players. A horse race has runners. If your UI assumes every event has a home and an away, it will break on golf and horse racing on day one.
So we translate everything into a small UI-friendly shape in one place:
js
// normalize.js
// The live-scores sample on the Orbistats homepage looks like:
// { match_id, status, minute, home: { name, score }, away: { name, score } }
// Adjust the field names below to match the API reference for each sport.
function unwrap(raw) {
if (Array.isArray(raw)) return raw;
return raw?.data ?? raw?.matches ?? raw?.fixtures ?? raw?.results ?? [];
}
export function normalizeMatches(raw) {
return unwrap(raw).map((m) => {
const hasSides = m.home && m.away;
return {
id: m.match_id ?? m.id,
status: m.status ?? "scheduled",
clock: m.minute != null ? `${m.minute}'` : (m.period ?? ""),
kickoff: m.start_time ?? m.kickoff ?? null,
title: hasSides
? `${m.home.name} vs ${m.away.name}`
: (m.name ?? m.event_name ?? "Event"),
home: hasSides ? { name: m.home.name, score: m.home.score ?? "-" } : null,
away: hasSides ? { name: m.away.name, score: m.away.score ?? "-" } : null,
};
});
}
The UI can now render a "versus" card when home and away exist, and a simple event card when they don't (golf, horse racing, some combat sports cards). Every new sport becomes a small mapping change instead of a rewrite.
Step 5: The front-end dashboard
One HTML file. No build step.
html
<!doctype html>
<br> :root { color-scheme: dark; }<br> body { margin: 0; font-family: system-ui, sans-serif;<br> background: rgb(11,18,32); color: rgb(232,238,252); }<br> header { padding: 16px 24px; border-bottom: 1px solid rgb(31,42,68); }<br> nav { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 24px; }<br> nav button { background: rgb(20,29,51); color: inherit;<br> border: 1px solid rgb(37,51,90);<br> padding: 8px 12px; border-radius: 999px; cursor: pointer; }<br> nav button.active { background: rgb(47,107,255); border-color: rgb(47,107,255); }<br> main { display: grid; gap: 16px; padding: 24px;<br> grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }<br> .card { background: rgb(20,29,51); border: 1px solid rgb(37,51,90);<br> border-radius: 12px; padding: 14px; }<br> .status { font-size: 12px; opacity: .7; text-transform: uppercase; }<br> .live { color: rgb(255,91,91); opacity: 1; }<br> .row { display: flex; justify-content: space-between; margin-top: 6px; }<br> .note { padding: 0 24px; opacity: .7; }<br>
🌍 Multi-Sport Dashboard
const tabs = document.getElementById("tabs"); const grid = document.getElementById("grid"); const msg = document.getElementById("msg"); let current = "football"; let timer; async function getJSON(url) { const r = await fetch(url); const body = await r.json(); if (!r.ok) throw new Error(body.error || "Request failed"); return body; } function card(m) { const el = document.createElement("div"); el.className = "card"; const s = document.createElement("div"); s.className = "status" + (m.status === "live" ? " live" : ""); s.textContent = m.status === "live" ? `● LIVE ${m.clock}` : m.status; el.append(s); if (m.home && m.away) { for (const side of [m.home, m.away]) { const row = document.createElement("div"); row.className = "row"; const name = document.createElement("span"); name.textContent = side.name; const score = document.createElement("strong"); score.textContent = side.score; row.append(name, score); el.append(row); } } else { const t = document.createElement("div"); t.className = "row"; t.textContent = m.title; el.append(t); } return el; } async function load() { msg.textContent = "Loading..."; try { let matches = await getJSON(`/api/${current}/live`); let label = "Live now"; if (!matches.length) { matches = await getJSON(`/api/${current}/fixtures`); label = "Upcoming"; } msg.textContent = `${label} - ${matches.length} items`; grid.replaceChildren(...matches.slice(0, 24).map(card)); } catch (e) { msg.textContent = e.message; grid.replaceChildren(); } } function select(slug) { current = slug; for (const b of tabs.children) b.classList.toggle("active", b.dataset.slug === slug); clearInterval(timer); load(); timer = setInterval(load, 30_000); } (async () => { const sports = await getJSON("/api/sports"); for (const s of sports) { const b = document.createElement("button"); b.dataset.slug = s.slug; b.textContent = `${s.icon} ${s.label}`; b.onclick = () => select(s.slug); tabs.append(b); } select(current); })();
Run it:
bash
node server.js
Then open http://localhost:3000 in your browser.
Two details worth copying into your own projects.
We only poll the active tab. If you polled all 13 sports every 30 seconds you'd burn through a free tier in minutes. Fetch what the user is looking at, nothing more.
We use textContent, not innerHTML. Team names come from an external source. Treating them as text means you never have to think about injection.
Step 6: Add standings and odds
Standings are one more route away (we already built /api/:sport/standings). A simple loader:
js
async function showStandings(slug) {
const data = await getJSON(/api/${slug}/standings);
const rows = Array.isArray(data) ? data : (data.data ?? []);
console.table(rows.slice(0, 10)); // replace with your own table component
}
Odds are where a multi-sport API really earns its keep. Every bookmaker formats odds differently, and normally you'd write a separate parser for each one. The Odds API returns normalized markets in one consistent schema (1X2, moneyline, spreads, totals and more), so a market looks like this:
json
{
"market": "1X2",
"odds": { "home": 1.91, "draw": 3.40, "away": 4.20 }
}
A tiny helper turns those decimal odds into an implied probability, which is far more useful on a dashboard than a raw number:
js
// decimal odds to implied probability (percent)
export const implied = (odds) => +(100 / odds).toFixed(1);
const m = { home: 1.91, draw: 3.40, away: 4.20 };
const probs = Object.fromEntries(
Object.entries(m).map(([k, v]) => [k, implied(v)])
);
// { home: 52.4, draw: 29.4, away: 23.8 }
const overround = Object.values(probs).reduce((a, b) => a + b, 0);
// about 105.6, which is the bookmaker margin baked into the market
If the three probabilities add up to more than 100 percent, the extra is the bookmaker's margin. Showing that number is a cheap way to make your dashboard feel genuinely analytical.
A responsible note: odds data is for analytics, comparison and research. If you build anything that touches betting, check the licensing and legal rules for every market you serve.
Step 7: From polling to real time
Polling every 15 to 30 seconds is fine for a prototype. For genuinely live experiences, you want the server to push updates to you. Orbistats offers two ways.
Option A: WebSockets
A WebSocket is one persistent connection that stays open, so updates arrive the moment they happen instead of waiting for your next poll:
text
REST: request, response, request, response, ...
WebSocket: open once, update, update, update, ...
A minimal Node client skeleton (get the exact URL, subscription message and format from the WebSocket docs):
js
// realtime.js
import "dotenv/config";
import WebSocket from "ws"; // npm install ws
const ws = new WebSocket(process.env.ORBISTATS_WS_URL, {
headers: { Authorization: Bearer ${process.env.ORBISTATS_API_KEY} },
});
ws.on("open", () => {
// Subscribe using the message format from the WebSocket docs
ws.send(JSON.stringify({ action: "subscribe", sport: "football" }));
});
ws.on("message", (buf) => {
const event = JSON.parse(buf.toString());
console.log("update:", event);
// forward to browsers (see Server-Sent Events below)
});
ws.on("close", () => setTimeout(() => process.exit(1), 1000)); // let a supervisor restart
In production, add automatic reconnect with exponential backoff and re-subscribe on reconnect.
Option B: Webhooks
With Webhooks, Orbistats calls your server when something happens, for example a goal:
text
Goal scored -> Orbistats -> POST /webhook/sports -> your server -> your UI
js
app.post("/webhook/sports", express.json(), (req, res) => {
// 1. Verify the request is really from Orbistats (see the webhook docs)
// 2. Update your store or notify clients
console.log("event:", req.body);
res.sendStatus(200); // respond fast, do heavy work asynchronously
});
Webhooks are ideal for notifications and background jobs. WebSockets are ideal for live UI.
Getting pushes into the browser
Once your server receives real-time events, forward them to the dashboard with Server-Sent Events, which are much simpler than running a second WebSocket layer for the browser:
js
const clients = new Set();
app.get("/stream", (req, res) => {
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.flushHeaders();
clients.add(res);
req.on("close", () => clients.delete(res));
});
export function broadcast(event) {
for (const c of clients) c.write(data: ${JSON.stringify(event)}\n\n);
}
js
// in index.html
const es = new EventSource("/stream");
es.onmessage = (e) => { /* update the matching card */ };
Now each live card updates the moment a score changes, with no polling.
Step 8: Add history for charts and models
A dashboard that only shows "now" is a scoreboard. One that shows how we got here is a product. The Historical Sports Data API provides multi-season archives (fixtures, results, statistics, lineups, events and closing odds, with seasons going back as far as 2010 for covered data), which unlocks things like season form charts per team, head-to-head history on every match card, closing-odds comparisons, and datasets for backtesting and machine learning.
text
Historical data -> feature engineering -> model -> predictions -> your dashboard
If you want team and player numbers like possession, shots, goals, assists or xG on each match page, pair this with the Sports Statistics API.
Step 9: The shortcut, drop-in widgets
Sometimes you don't want to build the UI at all. If you're a publisher who just needs a live score box or a match center on an article page, Orbistats Widgets let you embed ready-made components instead of writing the front-end yourself. Build the custom dashboard where it differentiates your product, and use widgets everywhere else.
Staying inside your rate limits
This is where good multi-sport apps and sad multi-sport apps part ways. The docs list 150 requests per day on the Free plan, so treat requests as a budget.
Cache by data type (15 seconds for live, 5 to 10 minutes for standings), because most data doesn't change every second.
Poll only the active tab: one sport at a time, not 13.
Pause polling when the browser tab is hidden (document.hidden), so you don't make requests for nobody.
Use WebSockets or webhooks for live data, because one connection beats thousands of polls.
Handle 429 responses gracefully: show a friendly message, back off and retry.
The in-memory cache above also means that if ten users open the football tab at once, you make one upstream request, not ten.
Production checklist
Before you ship this beyond localhost:
Keep the API key in environment variables, never in front-end code.
Replace the in-memory cache with Redis if you run more than one server.
Add retry with exponential backoff for 5xx and 429 responses.
Verify webhook signatures.
Reconnect WebSockets automatically.
Pin to /v1/. Orbistats uses versioned paths, so breaking changes would ship under a new version rather than silently changing yours.
Check the status page and changelog as part of your monitoring routine.
Read the data licensing terms if you'll display data commercially.
Where to go next
Browse the full API reference and the SDKs if you'd rather not hand-roll fetch calls. Copy patterns from the examples page. Compare plans on the pricing page when you're ready for real-time production traffic.
A few ideas to extend this project:
Favorites: let users pin teams across sports and build a personal "my teams" feed.
Match center page: click a card to see events, lineups and stats.
Odds movement chart: poll odds and plot line movement over time.
Alerts: use webhooks to send a Telegram or email notification on goals and final results.
Sport-specific layouts: a leaderboard view for golf, a runner list for horse racing, a set-by-set view for tennis.
Wrapping up
The biggest lesson from building multi-sport products is that the hard part isn't the sports, it's the plumbing: auth, schemas, caching, rate limits and real-time delivery. When you get 13 sports from one API, one auth method and one schema, most of that plumbing disappears and you get to spend your time on the product.
You can grab a free API key here and have your first live scoreboard running in under an hour.
Over to you: which sport would you add first, and what's the weirdest edge case you've hit when mixing sports in one UI (I'm looking at you, golf)? Drop it in the comments. 👇
Top comments (0)