Get a token and make your first call
Every request to the Sports API needs a token. Tokens are short-lived - you exchange long-lived credentials for one, cache it, and refresh it before it expires.
0. You can make your first call without a token
Section titled “0. You can make your first call without a token”Three catalogue endpoints are open - they answer without any credentials at all:
curl "https://api.statscore.com/v2/sports?limit=5"curl "https://api.statscore.com/v2/areas?limit=5"curl "https://api.statscore.com/v2/languages?limit=5"That is intentional: sports, countries and languages are public reference data. Use it to check
connectivity and to explore the response envelope before you have credentials. Everything else
returns 401 without a token - including /v2/statuses and /v2/incidents, which look like
catalogues but are not open.
1. Exchange credentials for a token
Section titled “1. Exchange credentials for a token”You need two values, issued to you by StatsCore: client_id and secret_key.
The token endpoint is a GET with both values in the query string:
curl "https://api.statscore.com/v2/oauth?client_id=YOUR_CLIENT_ID&secret_key=YOUR_SECRET_KEY"The response carries the token and its expiry:
{ "api": { "ver": "2.317.0", "timestamp": 1785783809, "method": { "name": "oauth", "details": "oauth" }, "data": { "client_id": "YOUR_CLIENT_ID", "token": "YOUR_TOKEN", "token_expiration": 1785787409 } }}2. Use the token as a query parameter
Section titled “2. Use the token as a query parameter”The token goes in the query string as token, not in a header:
curl "https://api.statscore.com/v2/sports?token=YOUR_TOKEN&limit=5"3. Cache the token and refresh it early
Section titled “3. Cache the token and refresh it early”Do not exchange credentials on every request. Cache the token in your process and refresh it
before token_expiration, leaving a safety margin so an in-flight request never fails on a
token that expires mid-call. Five minutes is a reasonable buffer.
// Node.js - token cache with early refreshconst BASE = "https://api.statscore.com";const REFRESH_BUFFER_S = 300; // odśwież 5 minut przed wygaśnięciem
let cached = null; // { token, expiresAt }
async function getToken() { const now = Math.floor(Date.now() / 1000); if (cached && cached.expiresAt - REFRESH_BUFFER_S > now) return cached.token;
// GET z parametrami w query stringu. Wyłącznie po stronie serwera - // secret_key w URL-u trafia do logów i historii. const authUrl = new URL(`${BASE}/v2/oauth`); authUrl.searchParams.set("client_id", process.env.STATSCORE_CLIENT_ID); authUrl.searchParams.set("secret_key", process.env.STATSCORE_SECRET_KEY);
const res = await fetch(authUrl, { headers: { Accept: "application/json" } }); // nie wypisujemy authUrl w komunikacie błędu - zawiera secret if (!res.ok) throw new Error(`token exchange failed: HTTP ${res.status}`);
const { api } = await res.json(); cached = { token: api.data.token, expiresAt: api.data.token_expiration }; return cached.token;}
/** Authenticated GET. Never logs the full URL - it contains the token. */export async function apiGet(path, params = {}) { const url = new URL(BASE + path); url.searchParams.set("token", await getToken()); for (const [k, v] of Object.entries(params)) { if (v != null) url.searchParams.set(k, String(v)); }
const res = await fetch(url, { headers: { Accept: "application/json" } }); const body = await res.json();
if (!res.ok) { // log the path, not the URL - the URL carries the token throw new Error(`GET ${path} → HTTP ${res.status}: ${body?.api?.error?.message ?? "unknown"}`); } return body.api.data; // ← payload lives in api.data}# Python - same pattern with httpximport os, time, httpx
BASE = "https://api.statscore.com"REFRESH_BUFFER_S = 300
_cached: dict | None = None
def get_token() -> str: global _cached now = int(time.time()) if _cached and _cached["expires_at"] - REFRESH_BUFFER_S > now: return _cached["token"]
# GET z parametrami w query stringu - tylko na serwerze, secret_key trafia do URL-a r = httpx.get( f"{BASE}/v2/oauth", params={ "client_id": os.environ["STATSCORE_CLIENT_ID"], "secret_key": os.environ["STATSCORE_SECRET_KEY"], }, timeout=10, ) r.raise_for_status() data = r.json()["api"]["data"] _cached = {"token": data["token"], "expires_at": data["token_expiration"]} return _cached["token"]
def api_get(path: str, **params): params["token"] = get_token() r = httpx.get(f"{BASE}{path}", params=params, timeout=30) r.raise_for_status() return r.json()["api"]["data"] # ← payload lives in api.data4. Your first real call
Section titled “4. Your first real call”curl "https://api.statscore.com/v2/sports?token=YOUR_TOKEN&limit=5"{ "api": { "ver": "2.317.0", "timestamp": 1785783809, "method": { "name": "sports.index", "total_items": 82, "next_page": "https://api.statscore.com/v2/sports?token=YOUR_TOKEN&limit=5&page=2" }, "data": { "sports": [ { "id": 1, "name": "Basketball", "url": "basketball", "active": "yes", "has_timer": "yes" }, { "id": 2, "name": "Volleyball", "url": "volleyball", "active": "yes", "has_timer": "no" }, { "id": 5, "name": "Soccer", "url": "soccer", "active": "yes", "has_timer": "yes" } ] } }}Three things to notice, because they apply to every endpoint:
- The payload is in
api.data, not at the top level. limitis an enum, and it differs per endpoint. Catalogue endpoints such as/v2/sportsaccept5, 10, 25, 50, 100, 150, 250, 500; the list endpoints/v2/events,/v2/events-simpleand/v2/livescorestop at150. Anything outside the endpoint's enum returns HTTP 400 - and the error body does not say why. Check the parameter in the reference rather than assuming one list.- Sport ids are not alphabetical. Soccer is
5, basketball is1. Fetch this list once, cache it, and never hardcode ids from memory.
Errors you will hit first
Section titled “Errors you will hit first”| Status | Cause | Fix |
|---|---|---|
400 | limit outside that endpoint's enum, or a date range beyond the allowed window | check the endpoint's own limit values; narrow the date range |
401 | missing, expired or malformed token | re-exchange credentials |
403 | the endpoint is outside your contract | check which products your client_id covers |
Error bodies carry api.error.internal_code; 13 means an invalid parameter. Note that a 400
does not currently tell you which parameter was wrong, so validate inputs on your side.
- Display a league table - the three-step navigation that is not obvious
- Get one match with score and stats - how to read a score correctly
Full reference: GET /v2/sports