IDs, slugs and time
UUIDv7 identifiers, human-readable slugs, and the timestamp formats the API uses.
Identifiers
Every resource id is a UUIDv7 — canonical, 36 characters, lowercase and hyphenated:
019f3d18-c15f-7319-81a7-343e8a80a578UUIDv7 embeds a millisecond timestamp in its leading bits, so ids sort roughly by creation time.
That is an implementation detail, not a contract: don't parse a timestamp out of an id or infer
ordering from one. It does mean ids from the same era share a visible prefix (019f…), which is a
handy sanity check that you're looking at our data.
Ids are validated by format, not by version — a v4 UUID is accepted as well-formed and then simply matches nothing.
Slugs
Teams, players and tournaments also have a slug: lowercase, hyphenated, unique within the game.
natus-vincere s1mple pgl-major-singapore-2026A slug works anywhere a path id does, so you can address a resource straight from a name you already have. Matches have no slug.
Slugs are stable but not guaranteed permanent — an organisation rename can change one. If you're storing a reference for the long term, store the UUID and treat the slug as a display convenience.
A player's nickname is not an identifier
Two different people can compete under the same handle, and they do: the CS2 corpus currently holds
109 nicknames worn by more than one player. nickname is a display name, nothing more — only id
(and, within a game, slug) identifies a player.
So if you key your own tables on a nickname, two careers will eventually merge into one row on your side. Three practical consequences:
- A roster or
include=playersresponse can legitimately contain two players with the samenicknameand differentids. - Matching a player across your data and ours by nickname will sometimes match the wrong person.
Match on
id, or onslugif you're working from our data already. - A player who changes handle keeps the same
id. Theirnicknameandslugchange; nothing else does, and their match history stays on the one id.
When two ids turn out to be one player
Rarely, we find that we have stored one person twice — the two rows come from upstream records we
had no way to connect until they were connected for us. When that happens we merge them: one id
keeps the full history, and the other is retired and stops resolving. GET /players/{id} on a
retired id returns 404, and it disappears from lineups, rosters and stat lines, where the
surviving id now appears instead.
This is uncommon — eight players across the whole CS2 archive as of September 2026 — but if you
store player ids and one starts returning 404, a merge is the likely reason. Re-resolve it by
slug or by nickname plus team and you will land on the surviving row. Every stat, duel and roster
entry that named the retired id was moved, not deleted; nothing is lost but the id itself.
When two ids turn out to be one match
The same thing can happen to a fixture. Occasionally we find that we have stored one match twice —
usually because it reached us from two places before we could tell they were the same. We merge the
pair: one id keeps the match, and the other is retired and redirects to it. Its maps, stats,
vetoes, rounds and odds history were moved onto the surviving match, not deleted; nothing is lost but
the id itself.
What a retired match id does:
- Every route under
/matches/{id}answers308 Permanent Redirectto the surviving match. TheLocationheader is the same path and query with the surviving id, andX-Merged-Intocarries the id on its own. The body is an error with codematch_merged, for clients that don't follow redirects. - Clients that follow redirects (browsers,
fetch, Pythonrequests, Go'shttp.Client,curl -L) get the surviving match without any change on your side. - The redirect doesn't count toward your monthly allowance. The request it leads to counts once, as normal.
- Replace the id you stored with the one in
X-Merged-Into, so you skip the extra round trip. - Our Python client (
pip install esportsodds) doesn't follow redirects, so your key is never sent anywhere you didn't ask. It raisesApiErrorwithstatus == 308andcode == "match_merged"; the new id is inerr.headers["X-Merged-Into"].
curl -i -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches/RETIRED_ID"
# HTTP/2 308
# location: /v1/cs2/matches/SURVIVING_ID
# x-merged-into: SURVIVING_IDA handful of ids retired before October 2026 have no record of where they went, and still answer
404 (not_found), as does a mistyped id. If you are sure an id was real, find the fixture again with
the list filters (the team's id and the day it was scheduled) and store the id it returns:
curl -H "Authorization: Bearer YOUR_API_KEY" \
"https://api.esportsodds.gg/v1/cs2/matches?team=TEAM_ID&date_from=2026-09-24&date_to=2026-09-24"Stop polling an id that answers 404. Those requests count toward your monthly allowance.
Timestamps
Every timestamp is RFC 3339 in UTC, with a trailing Z:
{ "scheduled_at": "2026-07-04T15:36:36.403882Z" }Sub-second precision appears where the source has it (odds captures) and is absent where it doesn't
(scheduled fixtures). Parse with a standard RFC 3339 parser rather than a fixed-width format string
— time.Parse(time.RFC3339, …), datetime.fromisoformat(…), new Date(…) all handle both.
There is no timezone parameter. Everything is UTC; convert at the display layer.
Date-only parameters
Filters that take a day rather than an instant use YYYY-MM-DD:
?date_from=2026-07-01&date_to=2026-07-31Both bounds are inclusive of the entire day in UTC. X-Quota-Reset is the one header that carries a
full RFC 3339 instant rather than a date.
Nulls
A nullable field is null, never omitted and never an empty string — except where the API
deliberately uses "" for "no value on file" (short_name, real_name, nationality). Arrays are
[] rather than null when empty. So null always means "no value", and you never have to
distinguish "absent" from "explicitly nothing".