A strongly-typed, dependency-light unofficial TypeScript SDK for the public EuroLeague and EuroCup APIs used by the official web experience. Not affiliated with or endorsed by Euroleague Basketball; endpoint availability can change without notice.
Works in Node.js ≥ 20 and the browser (CORS-friendly, no proxy). Ships ESM + CommonJS, runtime-validates responses with Zod, and keeps Zod as the only runtime dependency.
| Live demo | aimon7.github.io/euroleague-api-demo — typed dashboard built with this SDK |
| Docs & playground | /docs — install guide, recipes, try every method in your browser |
| Source | github.com/aimon7/euroleague-api |
This package is inspired by and credits the original Python package,
giasemidis/euroleague_api.
npm install euroleague-apiRequires Node.js >= 20 (uses the built-in fetch).
import { EuroleagueClient } from "euroleague-api";
const client = new EuroleagueClient({ competition: "euroleague" });
const stats = await client.players.getStats({ season: 2025, type: "traditional", mode: "PerGame" });
console.log(stats[0]);
// → { player: "…", team: "…", gamesPlayed: 28, points: 14.2, … } (camelCase NormalizedRow)Next steps: open the live playground or browse task recipes for standings, rosters, game feeds, and safe aggregation patterns.
For quick scripts there is a preconfigured Euroleague singleton:
import { euroleague } from "euroleague-api";
const standings = await euroleague.standings.getRound({ season: 2025, round: 10 });Typing note: stat rows are validated
Record<string, string | number | boolean | null>with camelCased keys. Map them into your view shape withselect(TanStack Query) or a small mapper — see framework sections below.
euroleague-api-demo is a fully-typed, client-side showcase of this SDK — no backend or proxy; it calls the EuroLeague API directly from the browser. Source: github.com/aimon7/euroleague-api-demo.
- Landing — pick EuroLeague or EuroCup and a season (kept in the URL); browse the clubs grid and sort the standings table.
- Team pages (
/team/$clubCode) — club info, full roster (players + staff), and team stats including advanced metrics from the API plus ones computed in the app (labeledFrom APIvsCalculated). - Player pages (
/player/$personCode) — profile, season stat cards, a per-game trend chart, and computed advanced stats with their formulas. - SDK docs (
/docs) — install guide, browsable method reference, and a live playground to try every resource against the real API from your browser.
The TanStack Query sections below show the core data-fetching patterns the demo uses.
const client = new EuroleagueClient({
competition: "euroleague", // "euroleague" -> "E" | "eurocup" -> "U" (default: "euroleague")
timeoutMs: 60_000, // optional per-attempt request timeout (default 60s)
retries: 0, // additional attempts after the first request (default 0)
retry: { baseDelayMs: 500, maxDelayMs: 10_000, jitter: true }, // optional backoff tuning
liveFeedIntervalMs: 250, // min spacing between live-feed requests (default 250ms, 0 disables)
fetch: customFetch // optional injectable fetch (handy for tests/runtime overrides)
});- Competition is a friendly union
"euroleague" | "eurocup", mapped toE/Uinternally. - Season is the start year as a
number(e.g.2023); the seasoncode (E2023/U2023) is built internally.
Retries are off by default (retries: 0). retries counts additional attempts after the initial
request, so retries: 2 issues at most 3 requests. It is a shorthand for retry.retries; when both are
set, retry.retries wins.
What is retried: HTTP 429, HTTP 5xx, and transport failures (EuroleagueNetworkError /
EuroleagueTimeoutError). Ordinary 4xx responses and deterministic failures (JSON parse, schema
validation, invalid input) are never retried.
Backoff: each retry waits baseDelayMs * 2^attempt (default 500ms → 1s → 2s → …), capped at
maxDelayMs (default 10s). With jitter enabled (the default) the delay is drawn uniformly from
[delay / 2, delay] to avoid synchronized retry storms. No delay is added after the final failed attempt.
Retry-After: when a 429/5xx response carries a Retry-After header (delta-seconds or HTTP-date),
the SDK waits at least that long — never less than the computed backoff — still capped at maxDelayMs.
Malformed values are ignored and plain backoff applies. The parsed value is also exposed as
EuroleagueApiError.retryAfterMs.
Timeouts: timeoutMs applies per attempt; each retry gets a fresh timeout. Backoff sleeps are not
counted against it.
// A resilient browser client:
const client = new EuroleagueClient({
retries: 3,
retry: { baseDelayMs: 1000, maxDelayMs: 15_000 }
});The game feeds (shots, playByPlay, boxscore, gameMetadata, and the per-game live endpoints) share
the rate-limited live.euroleague.net origin. To keep season-wide aggregations (getRound / getSeason /
getSeasons) from tripping upstream rate limits (Cloudflare error 1015 / HTTP 429), the SDK:
- spaces requests to the live-feed origin at least
liveFeedIntervalMsapart (default 250ms; set0to disable, e.g. against your own proxy or cache); - fans out per-game loads with at most 4 requests in flight, preserving result order and failing fast on the first error.
Requests to the standard API hosts (api-live.euroleague.net) are never paced or serialized.
When the upstream rate limiter rejects a request, the error response may omit CORS headers. Browsers then
surface it as an opaque network failure, so the SDK sees an EuroleagueNetworkError instead of the real
429 — and cannot read Retry-After. Such failures are still retried with full exponential backoff (never
immediately), which prevents a hidden 429 from turning into a burst of instant retries.
Each domain is a namespaced resource. Aggregation uses an explicit, discoverable verb scheme with typed param objects:
getGame({ season, gameCode })— a single gamegetRound({ season, round })— every game in a roundgetSeason({ season })— every game in a seasongetSeasons({ from, to })— across a range of seasons
Stats resources (players, teams) additionally take typed enums and offer getStats / getStatsRange /
getStatsAllSeasons plus getLeaders variants.
All output field names are normalized to camelCase. Only the inferred TypeScript types are exported; the Zod schemas stay internal.
| Resource | Key methods |
|---|---|
client.players |
getStats, getStatsRange, getStatsAllSeasons, getLeaders, getLeadersRange, getLeadersAllSeasons |
client.teams |
getStats, getStatsRange, getStatsAllSeasons, getLeaders, getLeadersRange, getLeadersAllSeasons |
client.seasons |
list, get |
client.clubs |
list, get, getRoster, getLogo |
client.people |
getProfile, getCareer, getSeasonRegistration, getCareerStats, getSeasonStats, getRecords |
client.competitions |
list, get |
client.phases |
list, get |
client.rounds |
list, get |
client.standings |
getRound |
client.schedule |
getSeason, getRound, getSeasons |
client.games |
getGame, getReport*, getStats*, getTeamsComparison*, getPointsBreakdown, … (single + round/season/seasons) |
client.shots |
getGame, getRound, getSeason, getSeasons |
client.boxscore |
getGame, getGameStats, getGameRoster, getQuarterScores, getPlayerStats, … |
client.playByPlay |
getGame, getLineups, … |
client.gameMetadata |
getGame, getRound, getSeason, getSeasons |
const players = await client.players.getStats({
type: "advanced", // "traditional" | "advanced" | "misc" | "scoring"
mode: "PerGame", // "PerGame" | "Accumulated"
phase: "RS", // optional: "RS" | "PO" | "FF"
season: 2023
});
const leaders = await client.teams.getLeaders({ season: 2023, type: "traditional" });
const range = await client.players.getStatsRange({ from: 2021, to: 2023, type: "scoring" });getStats returns totals scoped to the requested season. The v3 statistics
endpoint otherwise aggregates across all seasons (career/all-time rows), so
the SDK sends seasonMode=Single by default. To opt into the all-time aggregate
pass seasonMode: "All" (for multi-season queries use getStatsRange):
// Career/all-time totals for the whole stats list:
const allTime = await client.players.getStats({ season: 2025, seasonMode: "All", mode: "Accumulated" });minutesPlayed stays in decimal minutes, matching the v3 rows.
Stats and leaders calls also send limit=400 by default (the API caps list
length). Pass a higher limit when using seasonMode: "All" or other
all-time queries so rows are not silently truncated:
const allTime = await client.players.getStats({
season: 2025,
seasonMode: "All",
mode: "Accumulated",
limit: 1000
});// type: "basicstandings" | "calendarstandings" | "streaks" | "aheadbehind" | "margins"
const table = await client.standings.getRound({ season: 2023, round: 15, type: "basicstandings" });const games = await client.schedule.getSeason({ season: 2023 });
const round = await client.schedule.getRound({ season: 2023, round: 1 });const seasons = await client.seasons.list();
const clubs = await client.clubs.list({ season: 2023 });
const olympiacos = await client.clubs.get({ season: 2023, clubCode: "OLY" });
const roster = await client.clubs.getRoster({ season: 2023, clubCode: "OLY" });
const profile = await client.people.getProfile({ personCode: "013380" });
const seasonStats = await client.people.getSeasonStats({ season: 2024, personCode: "013380", phase: "RS" });const report = await client.games.getReport({ season: 2023, gameCode: 1 });
const shots = await client.shots.getGame({ season: 2023, gameCode: 1 });
const quarters = await client.boxscore.getQuarterScores({ season: 2023, gameCode: 1 });
const playerStats = await client.boxscore.getPlayerStats({ season: 2023, gameCode: 1 });
const events = await client.playByPlay.getGame({ season: 2023, gameCode: 1 });
const lineups = await client.playByPlay.getLineups({ season: 2023, gameCode: 1 });
const metadata = await client.gameMetadata.getGame({ season: 2023, gameCode: 1 });Round/season/range variants of the game-based feeds resolve the relevant game codes from the schedule and
aggregate automatically (e.g. client.shots.getRound({ season: 2023, round: 1 })).
Every response is validated against an internal Zod schema by default. For the very large live feeds
(shots, playByPlay) you can skip per-row validation for a performance win — the output is still normalized
to camelCase:
const shots = await client.shots.getGame({ season: 2023, gameCode: 1, validate: false });import {
EuroleagueApiError,
EuroleagueNetworkError,
EuroleagueParseError,
EuroleagueSchemaError,
EuroleagueTimeoutError,
EuroleagueValidationError
} from "euroleague-api";EuroleagueApiError— a non-2xx HTTP response (status,url,body, andretryAfterMsparsed from aRetry-Afterheader when present).429and5xxare retried with backoff per the retry options; other statuses are not.EuroleagueParseError— a 2xx response whose body is not valid JSON (url,status,bodySnippet, original error ascause). Deterministic, so it is never retried.EuroleagueNetworkError— a transport-level failure such as a refused connection or DNS error (url, original error ascause). Retried with backoff per the retry options.EuroleagueTimeoutError— the request was aborted aftertimeoutMs(url, original error ascause). Subclass ofEuroleagueNetworkError; retried with backoff per the retry options.EuroleagueSchemaError— the response failed validation (endpoint, Zodissues).EuroleagueValidationError— invalid input params (e.g. a bad season/competition).
The SDK calls the API with the standard fetch, and both Euroleague hosts send Access-Control-Allow-Origin: *,
so it runs directly in the browser — no proxy or backend required. The API is read-only, so everything is a
useQuery; there are no mutations.
Install the peer dependency alongside the SDK:
npm install euroleague-api @tanstack/react-query// src/main.tsx (Vite / CRA entry)
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "./App";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
);EuroleagueClient is stateless, so create it once and import it everywhere.
// src/lib/euroleague.ts
import { EuroleagueClient, type Competition } from "euroleague-api";
export const COMPETITION: Competition = "euroleague";
export const euroleagueClient = new EuroleagueClient({
competition: COMPETITION,
retries: 2
});Keep keys in one place and always include the competition, so Euroleague and EuroCup never collide in the cache.
// src/lib/euroleague-keys.ts
import type { PlayerStatsParams, ShotGameParams, StandingsRoundParams } from "euroleague-api";
import { COMPETITION } from "./euroleague";
export const euroleagueKeys = {
root: ["euroleague", COMPETITION] as const,
playerStats: (params: PlayerStatsParams) => [...euroleagueKeys.root, "players", "stats", params] as const,
standingsRound: (params: StandingsRoundParams) => [...euroleagueKeys.root, "standings", "round", params] as const,
gameShots: (params: ShotGameParams) => [...euroleagueKeys.root, "shots", "game", params] as const
};Responses are normalized records (Record<string, string | number | boolean | null>) whose keys are the
camelCased upstream fields. Use TanStack Query's select to map them into a shape your component owns:
// src/hooks/usePlayerStats.ts
import { useQuery } from "@tanstack/react-query";
import type { PlayerStat, PlayerStatsParams } from "euroleague-api";
import { euroleagueClient } from "../lib/euroleague";
import { euroleagueKeys } from "../lib/euroleague-keys";
export interface PlayerRow {
player: string;
team: string;
gamesPlayed: number;
points: number;
assists: number;
}
function toPlayerRow(row: PlayerStat): PlayerRow {
return {
player: String(row.player ?? ""),
team: String(row.team ?? ""),
gamesPlayed: Number(row.gamesPlayed ?? 0),
points: Number(row.points ?? 0),
assists: Number(row.assists ?? 0)
};
}
export function usePlayerStats(params: PlayerStatsParams) {
return useQuery({
queryKey: euroleagueKeys.playerStats(params),
queryFn: () => euroleagueClient.players.getStats(params),
select: (rows) => rows.map(toPlayerRow),
staleTime: 1000 * 60 * 60 // season stats change a few times a week at most
});
}// src/components/PlayerStatsTable.tsx
import { EuroleagueApiError, EuroleagueSchemaError } from "euroleague-api";
import { usePlayerStats } from "../hooks/usePlayerStats";
export function PlayerStatsTable() {
const { data, isPending, isError, error } = usePlayerStats({
season: 2023,
type: "traditional",
mode: "PerGame"
});
if (isPending) {
return <p>Loading player stats…</p>;
}
if (isError) {
const message =
error instanceof EuroleagueApiError
? `Euroleague API responded ${error.status}`
: error instanceof EuroleagueSchemaError
? "The API returned an unexpected shape"
: "Something went wrong";
return <p role="alert">{message}</p>;
}
return (
<table>
<thead>
<tr>
<th>Player</th>
<th>Team</th>
<th>GP</th>
<th>PPG</th>
<th>APG</th>
</tr>
</thead>
<tbody>
{data.map((p) => (
<tr key={`${p.player}-${p.team}`}>
<td>{p.player}</td>
<td>{p.team}</td>
<td>{p.gamesPlayed}</td>
<td>{p.points}</td>
<td>{p.assists}</td>
</tr>
))}
</tbody>
</table>
);
}data is fully typed as PlayerRow[] thanks to select, and errors narrow to the SDK's typed error classes.
In the App Router you can run the SDK on the server and hand a warm cache to the client; the same
usePlayerStats hook works unchanged.
// app/providers.tsx
"use client";
import { type ReactNode, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}// app/players/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { euroleagueClient } from "@/lib/euroleague";
import { euroleagueKeys } from "@/lib/euroleague-keys";
import { PlayerStatsTable } from "@/components/PlayerStatsTable";
const params = { season: 2023, type: "traditional", mode: "PerGame" } as const;
export default async function PlayersPage() {
const queryClient = new QueryClient();
// Runs server-side; the dehydrated cache is streamed to the client.
await queryClient.prefetchQuery({
queryKey: euroleagueKeys.playerStats(params),
queryFn: () => euroleagueClient.players.getStats(params)
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PlayerStatsTable />
</HydrationBoundary>
);
}Wrap your app/layout.tsx body in <Providers>. The prefetched query key must match the hook's key exactly
(same params) so the client mounts with data already in cache — no loading flash. The game-feed aggregations
(getSeason, getSeasons) fan out into many requests, so prefer prefetching those on the server with a longer
staleTime.
The same browser/CORS and read-only notes apply. The lib/euroleague.ts client, the lib/euroleague-keys.ts
factory, and the PlayerRow interface + toPlayerRow mapper from the React section above are plain TypeScript —
extract the mapper into lib/player-row.ts and reuse all three across frameworks.
npm install euroleague-api @tanstack/vue-query// src/main.ts
import { createApp } from "vue";
import { VueQueryPlugin } from "@tanstack/vue-query";
import App from "./App.vue";
createApp(App).use(VueQueryPlugin).mount("#app");// src/composables/usePlayerStats.ts
import { useQuery } from "@tanstack/vue-query";
import type { PlayerStatsParams } from "euroleague-api";
import { euroleagueClient } from "../lib/euroleague";
import { euroleagueKeys } from "../lib/euroleague-keys";
import { toPlayerRow } from "../lib/player-row";
export function usePlayerStats(params: PlayerStatsParams) {
return useQuery({
queryKey: euroleagueKeys.playerStats(params),
queryFn: () => euroleagueClient.players.getStats(params),
select: (rows) => rows.map(toPlayerRow),
staleTime: 1000 * 60 * 60
});
}<!-- src/components/PlayerStatsTable.vue -->
<script setup lang="ts">
import { EuroleagueApiError, EuroleagueSchemaError } from "euroleague-api";
import { usePlayerStats } from "../composables/usePlayerStats";
const { data, isPending, isError, error } = usePlayerStats({
season: 2023,
type: "traditional",
mode: "PerGame"
});
function errorMessage(err: unknown): string {
if (err instanceof EuroleagueApiError) return `Euroleague API responded ${err.status}`;
if (err instanceof EuroleagueSchemaError) return "The API returned an unexpected shape";
return "Something went wrong";
}
</script>
<template>
<p v-if="isPending">Loading player stats…</p>
<p v-else-if="isError" role="alert">{{ errorMessage(error) }}</p>
<table v-else-if="data">
<thead>
<tr>
<th>Player</th>
<th>Team</th>
<th>GP</th>
<th>PPG</th>
<th>APG</th>
</tr>
</thead>
<tbody>
<tr v-for="p in data" :key="`${p.player}-${p.team}`">
<td>{{ p.player }}</td>
<td>{{ p.team }}</td>
<td>{{ p.gamesPlayed }}</td>
<td>{{ p.points }}</td>
<td>{{ p.assists }}</td>
</tr>
</tbody>
</table>
</template>data, isPending, and error are reactive refs (auto-unwrapped in the template). Pass refs/computed as the
params if you need the query to react to user input.
Uses the Angular adapter, which exposes results as signals. It is officially experimental, so pin the
version. The same lib/euroleague.ts, lib/euroleague-keys.ts, and lib/player-row.ts files are reused.
npm install euroleague-api @tanstack/angular-query-experimental// src/app/app.config.ts
import { type ApplicationConfig } from "@angular/core";
import { provideTanStackQuery, QueryClient } from "@tanstack/angular-query-experimental";
export const appConfig: ApplicationConfig = {
providers: [provideTanStackQuery(new QueryClient())]
};injectQuery returns signals, so the template calls query.data(), query.isPending(), etc.
// src/app/player-stats-table.component.ts
import { Component } from "@angular/core";
import { injectQuery } from "@tanstack/angular-query-experimental";
import { EuroleagueApiError, EuroleagueSchemaError } from "euroleague-api";
import { euroleagueClient } from "../lib/euroleague";
import { euroleagueKeys } from "../lib/euroleague-keys";
import { toPlayerRow } from "../lib/player-row";
const PARAMS = { season: 2023, type: "traditional", mode: "PerGame" } as const;
@Component({
selector: "app-player-stats-table",
standalone: true,
template: `
@if (query.isPending()) {
<p>Loading player stats…</p>
} @else if (query.isError()) {
<p role="alert">{{ errorMessage(query.error()) }}</p>
} @else {
<table>
<thead>
<tr>
<th>Player</th>
<th>Team</th>
<th>GP</th>
<th>PPG</th>
<th>APG</th>
</tr>
</thead>
<tbody>
@for (p of query.data(); track p.player + p.team) {
<tr>
<td>{{ p.player }}</td>
<td>{{ p.team }}</td>
<td>{{ p.gamesPlayed }}</td>
<td>{{ p.points }}</td>
<td>{{ p.assists }}</td>
</tr>
}
</tbody>
</table>
}
`
})
export class PlayerStatsTableComponent {
query = injectQuery(() => ({
queryKey: euroleagueKeys.playerStats(PARAMS),
queryFn: () => euroleagueClient.players.getStats(PARAMS),
select: (rows) => rows.map(toPlayerRow)
}));
errorMessage(err: unknown): string {
if (err instanceof EuroleagueApiError) return `Euroleague API responded ${err.status}`;
if (err instanceof EuroleagueSchemaError) return "The API returned an unexpected shape";
return "Something went wrong";
}
}For params driven by user input, expose them as signals and read them inside the injectQuery callback so the
query refetches automatically when they change.
Contributions are welcome — especially adding new resources or fixing schema drift when the upstream
API changes. See CONTRIBUTING.md for the full workflow (including the
step-by-step recipe for adding a resource) and CODE_OF_CONDUCT.md. The short
version: run npm run verify before opening a PR — it mirrors CI exactly.
npm install
npm run typecheck
npm run lint
npm run format:check
npm test # vitest run --coverage
npm run build # tsup -> dist (ESM + CJS + d.ts)
npm run check:pkg # publint && attw --pack .
npm run verify # all of the above, in one command (mirrors CI)Run an example with tsx:
npm run example -- examples/shots.tsGenerate a new resource skeleton:
npm run gen:resource <name>A small set of opt-in tests hit the real API to detect upstream drift. They are skipped by default (and in CI); enable them explicitly:
npm run test:liveThe published package contains only dist/ (plus the README), ships ESM and CommonJS entrypoints with
types listed first in every exports condition, and keeps Zod as the only runtime dependency.
MIT