Skip to content

Get a token and make your first call

Verified on production5 min

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:

Terminal window
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.

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:

Terminal window
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
}
}
}

The token goes in the query string as token, not in a header:

Terminal window
curl "https://api.statscore.com/v2/sports?token=YOUR_TOKEN&limit=5"

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 refresh
const 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 httpx
import 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.data
Terminal window
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.
  • limit is an enum, and it differs per endpoint. Catalogue endpoints such as /v2/sports accept 5, 10, 25, 50, 100, 150, 250, 500; the list endpoints /v2/events, /v2/events-simple and /v2/livescore stop at 150. 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 is 1. Fetch this list once, cache it, and never hardcode ids from memory.
StatusCauseFix
400limit outside that endpoint's enum, or a date range beyond the allowed windowcheck the endpoint's own limit values; narrow the date range
401missing, expired or malformed tokenre-exchange credentials
403the endpoint is outside your contractcheck 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.

Full reference: GET /v2/sports