Changelog
Notable changes to the EsportsOdds API and documentation.
Notable, developer-facing changes to the API. Breaking changes ship under a new version prefix;
within v1, changes are additive.
2026-10-19 (between_maps, the stale-score rule and next-map starts are on)
Changed
between_mapsand the 3-minute stale-score rule are on from 18:51 UTC on 19 October 2026. A map that still shows as in progress after the series score we hold has caught up with it is now sent asbetween_maps, and a running round score we cannot refresh for 3 minutes is sent asnull. Both are described in the 17 October entry below. A client that treats an unknown phase as "not being played right now" needs no change.- After a map ends,
live_statecan move on to the next map as soon as we confirm it has begun, from 19:44 UTC on 19 October 2026. The state isin_progressfor the nextmap_number, with that map'smap_name,nullround scores and anullas_of, until the first round score arrives.map_score_aandmap_score_bstay as they were. A map that is being played is never replaced by this. between_mapscan also come from a second live signal, from 20:09 UTC on 19 October 2026. When a map still shows as in progress, but a second signal we hold confirms the series has moved past it (the maps-won count rose after the map began and was confirmed in the last 5 minutes),live_stateis sent asbetween_mapsfor that map instead of the old round score: both round scores arenull,map_score_a/map_score_bcount the map, andobserved_atis when we confirmed it. The round score can run minutes behind play, so this can arrive before the series score does. A client that handlesbetween_mapsneeds no change.
2026-10-17 (a new live_state.phase value, and no stale round scores)
Added
live_state.phasecan bebetween_maps, no sooner than 19 October 2026. It means the map inmap_numberhas ended but we do not yet know its final round score:round_score_aandround_score_barenull, andmap_score_a/map_score_balready count that map. We use it when what we know about the series shows that a map still showing as in progress has already ended, for example when the series score we hold has caught up with it. The phase list has always been documented as open, so a client that treats an unknown phase as "not being played right now" needs no change. We will add a line here the day it is on.
Changed
- A running round score we cannot refresh for 3 minutes is sent as
null(phasein_progress,as_ofnull) instead of the last number we had. Before, a stalled score could stay on screen for the half hour our clean-up takes. This applies from the same day asbetween_maps.
The response shape is unchanged: no new field.
2026-10-16 (some map and series ends arrive sooner)
Changed
- On some matches a map's final score, and the series-over state, now arrive sooner. For the events where a
second source also supplies the score, a finished map (
live_state.phasemap_over) and a finished series (series_over) can now come from whichever source reaches us first. In the 17 map and series ends we could time on 16 October 2026 (11 map ends and 7 series ends in all) the second source was ahead of the round-level feed by a median of about 2 minutes (30 to 142 seconds), and every series end matched the result we stored for the match afterwards. This replaces the statement in the 15 October entry that a map's final score and the state between maps come only from the round-level feed. - The match's own
status,score_a,score_bandwinner_team_idare unchanged: they still follow the match's own report, a median of about 12 minutes after it ends.live_stateis the place to read a finished map or series sooner. as_offor a score or a final from the second source is the time we received it less a fixed 4 or 10 seconds, depending on how we read it. The allowance is an assumed upper bound on the source's own delay, so the age you compute fromas_ofcan be too large, never too small. This corrects the 15 October entry, which gave 10 seconds only.
The response shape is unchanged: no new field, and as_of and observed_at keep their meanings. See
Live data for the measured ages.
2026-10-15 (some round scores arrive sooner)
Changed
- On some matches the round score in
live_statenow arrives sooner. For the events where a second source also supplies a round score, you get whichever of the two is ahead, so on the matches we have measured the score can lead the round-level feed's by a few seconds at some LAN events and by about two minutes at some online ones. For a score from the second source,as_ofis the time we received it less a fixed 10 seconds androunds_playedisnull; the round-level feed's ownas_ofis unchanged. A map's final score and the state between maps still come only from the round-level feed. - A finished map's
map_overstate can be skipped. When a newer round score already shows the next map, you move straight to it, andmap_score_a/map_score_bcount the finished map. as_ofalso moves when the same score is confirmed again, so on these matches a WebSocket subscriber receives a frame about every 20 seconds (14 to 20 seconds in our first measurements, up to 40 at times) even though the score has not changed. The frames are not identical, becauseas_ofis different. See Live data for the measured ages.
The response shape is unchanged: no new field, and as_of and observed_at keep their meanings.
2026-10-14 (search by name)
Added
?q=searches teams, players and tournaments by name.GET /v1/{game}/teams?q=navifinds NAVI without knowing its slug. It looks for your text anywhere in a team's name, short name, slug or alternate name, in a player's nickname or slug (never a real name), or in a tournament's name or slug, ignoring case. It takes 2 to 100 characters, combines with the other filters and keeps the list's usual order and paging, so the best match is not always first. See Resolve a name to an id.- Python client 0.4.0 takes
qonteams.list,players.listandtournaments.list, so its MCP tools can look a name up too.pip install -U esportsodds, or letuvxfetch it.
Changed
- A
404on a team, player or tournament id says where to search. An id that is neither a UUID nor a known slug now answers "not found: no team has this id or slug", with the list route to search by name. The status andcode(not_found) are the same. The400for a team or tournament given by name where an id is expected, and for an unknownopponent, point at the search too.
A request that used only documented values gets the same response as before.
2026-10-13 (clearer first calls)
Changed
GET /v1/{game}/odds?latest=truenow means the same aslatest=1: the current price for each outcome. Before, only1was read andtruewas ignored without a word, so the answer was the match's whole line history. Any value other than1,true,0orfalseis now a400 invalid_parameter, like every other filter.- A
400on a match or player list filter says what is accepted. A wrongstatuslists the four valid values, and a team or tournament given by name says to pass its id and where to look it up. - The docs ask for upcoming matches soonest first. The match list is newest first by default, so
status=scheduledon its own starts with the fixture furthest away: addsort=scheduled_at. The Quickstart and the Python client (0.3.2) do. - A first Python run for a sandbox key. Upcoming matches are outside the sandbox sample, so the
Python client's quickstart answers
403 sandbox_scopeon a sandbox key. Version 0.3.3 adds an example that reads a sample event and one of its matches in depth; the Sandbox key guide shows how to run it.
A request that used only documented values gets the same response as before.
Fixed
- 125 matches now point at the right tournament. When one event reached us under two names, a
match could stay filed under the second copy, which had no tier (110 matches, 12 of them from ESL
Pro League Season 24); 15 more had been filed under the wrong event. Each now carries its event's
real
tournament_id, and itschanged_atmoved, so achanged_sincesync picks the change up. The emptied copies still exist with no matches; ignore a tournament whose match list is empty. - A tournament that was called off now says
cancelled. Tournamentstatuscame only from the dates, so an event that never ran read asupcomingbefore its dates andfinishedafter them, with no matches.cancelledis a newstatusvalue,?status=cancelledlists those events, and they no longer matchupcoming,ongoingorfinished. An event that was called off part-way, with matches already played, keeps its date-basedstatus, so a tournament with results never readscancelled.
2026-10-12 (AI assistants)
Added
- An MCP server in the Python client (version 0.3.0 and later): Claude, Cursor and other MCP clients can answer questions from the API with 24 read-only tools, one per read endpoint. Set-up: AI assistants (MCP).
2026-10-12 (sandbox keys)
Added
- A free sandbox key reads a fixed sample of past matches, up to 2,000 requests in total, with no card. Create one in the dashboard; see Sandbox key.
- Two error codes, only ever sent to a sandbox key:
403 sandbox_scope(outside the sample, not counted) and429 sandbox_quota_exceeded(the 2,000 are used; noRetry-After, because they never reset).
Nothing changes for paid keys.
2026-10-12 (retired match ids redirect)
When we find one fixture stored twice, we merge the pair and one match id is retired. That id now keeps working.
Changed
- Every
/matches/{id}route answers a retired id with308 Permanent Redirectto the same path on the surviving match, with anX-Merged-Intoheader and amatch_mergederror body. Clients that follow redirects get the surviving match with no change. Before, the detail route answered404and the sub-routes an empty list. See When two ids turn out to be one match. - The redirect is not billed. The request it leads to counts once, as normal.
- A
404on a match route now says why: "not a valid match id" for an id that is not a UUID, "no match has this id" for a well-formed one we do not have. Status andcodeare unchanged.
Fixed
/matches/{id}/streamsandinclude=streamsnow return streams only while a match is live, as documented. Since late September a finished match could still list the channels that carried it. A scheduled, finished or cancelled match now returns an empty list.- A tournament's
current_stagenow follows the event. It used to keep the first stage it saw, so a finished event could still say "Group Stage". It is now the stage of the latest match that has started, or, before the event begins, of the next scheduled one. - Python client: version 0.2.0 raises the base
ApiErrorwithstatus == 308for a retired id, where it raisedNotFoundErrorfor the old404. The next release maps it back toNotFoundError.
2026-10-10 (player stats)
Six new endpoints that describe a player from completed matches. Everything is additive: nothing that worked yesterday changes.
New
GET /players/{id}/performance— a player's last-12-month figures over their recorded maps (maps, matches, K/D, ADR, KAST, headshot share, opening duels, multi-kill rounds, clutches, trade kills), each with its percentile among every player who has a player page, the pagelevel, and the exact wording of every rule indefinition.GET /players/{id}/maps— the same figures per competitive map.GET /players/{id}/matches— recent completed matches, newest first, with the player's own kills, deaths and ADR.limitdefault 10, max 25.GET /players/{id}/teams— team history as stints, with the current team.GET /players/{id}/weapons— weapon and hit-group totals over the last 12 months.GET /teams/{id}/players— the players in a team's newest match with stats, with their last-12-month figures.
On these endpoints kast is a 0–100 percentage, a figure from fewer than 10 recorded maps is null, and no
response carries a real name.
2026-10-10
Seven new endpoints for team and map analysis, new optional parameters and fields on four existing ones, and a correction to the player board. Everything is additive: a request that worked yesterday returns what it returned yesterday.
New
GET /teams/{id}/results— a team's recent results, newest first, with every map's score and both teams' ratings going into the match.GET /teams/{id}/rivals— the opponents a team meets most, with head-to-head, the last ten meetings and the average rating gap.GET /teams/{id}/vetoes— a team's own picks, bans, first bans and deciders per map, with apermabanflag, over a window.GET /mapsandGET /maps/{id}/teams— league-wide figures for each competitive map, and how every team fares on, and chooses around, one map.GET /rankings/history— the top of the team board at each of the last weeks — andGET /rankings/round-stats, the round-level figures of the top teams in one request.window,eraandactiveonGET /teams/{id}/maps,windowon/teams/{id}/ratingsand/teams/{id}/round-stats: describe a team as it plays now, not across every roster it has had. With none of them the response is what it was.include=detailonGET /teams/{id}/mapsadds each map's last-played date, pool membership and the team's CT, T and pistol round record.opening_duel_pcton/teams/{id}/round-statsis the share of opening kills the team took;match_idon every/teams/{id}/ratingspoint names the match that moved it.delta_days,include=trendandinclude=ratingonGET /rankings: measurerank_deltaover 1–90 days, add a twelve-weektrendseries to each team row, and add each team's Glicko-2ratingand deviationrd.GET /teams/{id}/ratingsacceptslimitup to 1000 (it was 200).
Corrections to data already served
GET /rankings?type=playercounts maps, not rows. The 30-map sample floor and the average were taken over every stats row a player had in the window: each map's own row, the whole-match summary stored beside it, and rows on maps that were not competitive maps (a walkover label, fun maps). A player with 25 played maps and 15 match summaries cleared the floor and was ranked on a blend of the two. A map now counts once, from its own per-map rating, on a played competitive map. When we measured it, 217 of the 1,066 ranked players fell below the floor and the top of the board changed.rank,valueand a player'sown_rankall follow the same rule. Team rankings are unchanged.
2026-09-27
Corrections to odds data already served, and to five matches that each held two fixtures. No field changes shape.
Corrections to data already served
-
Five matches that held two fixtures are split. Between 17 and 25 September five matches each stood for two real matches between the same teams, hours apart, and alternated between them: times, format, score and winner changed from one refresh to the next. Each fixture now has its own match — the existing
idkeeps one of the pair, and the other has a newid. The cause is fixed, and we now monitor for any recurrence. -
One closing line per series.
is_closingwas true on two or more lines of the same series on 942 of the 2,026 completed matches we priced: an earlier close was never cleared when a later one replaced it — after a start time was corrected, duplicate markets were merged, or a match reopened. Every series of a completed match now carries exactly one, and a match that reopens loses its closes until it completes again. -
in_playfollows the match's start. Where a start was corrected after a match's lines were flagged, the flags still described the old start; they now follow the corrected one. Lines on a match with no recorded start now returnin_play: null, as documented — some carriedtrueorfalse.
Documentation corrections
- The
is_closingandopen_priceseries are(market, outcome, line, source); the docs omittedline. Eachlineof atotal_mapsormap_handicapmarket is its own series. is_closingis set once a match completes. A match we began pricing only after it started has no pre-match line, so its earliest line carries the flag — checkin_playbefore scoring against it.
2026-09-26
Live scores follow a match more closely and say how old they are. Two fields are added; nothing you already parse changes shape.
New
-
live_state.phase—in_progress,map_overorseries_over. A map's final score, and the state between maps, now arrive as soon as the round-level feed has them. Until now the last round of every map never appeared: the score stayed one round short until the match result was recorded, minutes later. So a match whosestatusis stilllivecan now carry a finished map's or series' final. See match updates. -
live_state.as_of— the round-level feed's own timestamp for the score, which is its real age. That feed follows the official broadcast, which runs seconds behind play at some LAN events and minutes behind for online ones, soas_ofcan be well beforeobserved_at. See freshness.
Changed
-
Live scores are read more often. Each live match's round score is now read on its own schedule instead of every live match sharing one slot, so a score trails the feed by much less, and a busy evening no longer slows every score down.
-
Removing a live state reaches WebSocket subscribers as an
updatecarrying"live_state": null. Before, the last live score stayed until something else about the match changed. -
tson a WebSocket frame is when the server read the state the frame carries, and it still never decreases on a connection. For how old a score is, readlive_state.as_of— notts, and notobserved_at, which is when we last read the state. -
A match's updates can arrive before the
subscribedack when you subscribe to several matches at once: each starts as soon as its own snapshot is sent. -
Old team slugs keep resolving after a rename, wherever a team slug is accepted, unless another team comes to hold the slug. The team returned carries its current
slug. See resolving slugs. -
Names and statuses no longer alternate. Some team and tournament names switched between two spellings from one refresh to the next, changing the team's
slugwith them, and some matches'statusflipped betweencompletedandlive, orliveandscheduled, as two of our inputs disagreed. Each now has one owner, so a status change you receive is a real one. -
statusturnslivesooner on matches with round-level coverage. It now changes with the first round-level state of a map, in the same update, instead of waiting minutes for the match's other inputs to notice that play has begun.
Documentation corrections
- A live match outside round-level coverage can carry a
live_statethat names the map being played withnullround scores — the docs said it was alwaysnull. And alive_statecan appear shortly beforestatusturnslive.
2026-09-25
A plan change, and one change to when a key authenticates. Nothing you parse changes shape.
-
The standard plan is now 50,000 requests a month, at $49. It was 20,000 at $99.
X-Quota-Limitreads50000from today, still counted per account over the calendar month (UTC). Existing subscriptions pay the new price from their next payment. See rate limits. -
No new trials. A subscription is paid when it starts, and its API key is issued once that payment clears. A first subscription is covered by a 14-day money-back guarantee instead, provided the account has made fewer than 1,000 requests and no more than 20 bulk-export requests since that payment — the refund policy has the full terms. A trial that had already started runs to its end date with its own limits; see legacy trials.
-
A key authenticates only while its account's subscription is in force. A key on an account whose subscription has ended, is paused or has not been paid gets
401withinvalid_api_key, the same response as an unknown key. See authentication.
2026-09-20
Two additions and two documentation corrections. Nothing you already parse changes shape.
New
-
Match revision history:
GET /v1/{game}/matches/{id}/history. What a match's fields used to say, and when they stopped saying it. Each entry carrieschanged_at,changed(the fields that moved, the same vocabulary the WebSocket sends),changeswith afrom/toper field, andwas_completed.?limit=up to 200, walked back with?before=; entries are kept for 180 days.was_completed: truemarks a match we had already published as final and then revised — which is what separates a correction from an arrival, and what makes a correction rate something you can measure rather than take on trust. We will publish our own once a full window has accrued.History starts today and cannot be backfilled. Before this existed a prior value was overwritten in place and is gone, so a match that finished earlier returns an empty list — meaning "no record", not "nothing changed". See match history.
-
Measured freshness on
GET /v1/{game}/coverage. Every capability row gains afreshnessobject:p50_seconds,p90_seconds,p99_secondsover a 30-day window, thesamplebehind them,window_days,measured_at, and abasisstring naming exactly which two instants were measured.These are observations, not a service level — there is no latency SLA on any plan. Percentiles are
null, never0, where the window holds nothing yet, with anotesaying why; figures derived from the revision history above populate over the days after this ships rather than arriving complete. Live round score publishesnullpermanently and says so: we record when our poll saw a score, not when the round ended, so there is no second instant to measure against.cadence_secondson the same row remains what it always was — how often a collector runs, which is not how long anything waited. See live data.
Documentation corrections
Nothing about the API changed here. These are places the docs were wrong.
-
The WebSocket costs no request quota — not the ticket, not the connection, not the messages. Connecting stopped drawing a request in August and the guides were never updated, so they overstated what a change-driven integration costs. For anything event-driven the stream is not merely cheaper than a timer, it is free. See rate limits.
-
weapon_nameson a duel is a kill-order weapon sequence, always served and never properly described: one entry per kill, oldest first. It is not a kill feed — there are no timestamps and no round or map attribution — and its spellings are not normalised, mixing display names (AK-47), slugs (ak-47) and raw engine names (m4a1_silencer,hkp2000), withWorldstanding in for the environment. A raw string join againstweapon_namesilently drops kills; the thirteen values that don't fold are now listed. See player depth. -
Our own error messages pointed at a broken link. Every
404and405body ended withsee https://docs.esportsodds.gg/api-reference for the endpoint list— a page that has never existed on this site, so the one link we hand you when a path is wrong 404'd too. Those errors now point at the documentation root, whose sidebar carries every endpoint, and the old URL redirects there for anything already in your logs.
2026-09-19
Two halves. Everything under New is additive — nothing you already parse changes shape. The corrections below it change values we had already served, and are listed field by field so you can decide whether to re-pull.
New
-
A bulk endpoint:
GET /v1/{game}/matches/bulk. Up to 25 matches per request withmaps,stats,vetoes,rounds,depthandlineupsembedded viainclude=, each identical to its per-match route. One request against your quota whatever it carries, which brings the whole completed-match archive down to about a thousand requests. At most two pages are built at once service-wide — a third gets503+Retry-Afterand is not billed. During a free trial the endpoint has its own ceiling of 20 requests. See pull the historical archive. -
The bulk endpoint compresses its responses when you send
Accept-Encoding: gzip. It is the only route that does; every other response is uncompressed JSON. -
A new error code,
trial_endpoint_quota_exceeded(429). During a free trial each endpoint has its own ceiling — 1,000 requests, 20 on the bulk endpoint — inside the 5,000-request total. Past it that endpoint returns this code, withX-Quota-Endpoint-LimitandX-Quota-Endpoint-Remainingheaders, while every other endpoint keeps working. Paid plans never see it. See rate limits. -
The same
include=names work onGET /v1/{game}/matches/{id}, alongside the existingteams,tournamentandodds. -
A change feed:
changed_at,?changed_since=andsort=changed_aton the match list and the bulk endpoint.changed_atmoves when something you can actually see changes, unlikeupdated_at, which advances on every refresh pass and was wrongly documented as a change time. That means a match-level field — and, per the change-feed entry further down this list, the first arrival of a data layer.changed_athas been stamped since 2026-09-18, a day before this endpoint could read it, so a window opened today loses nothing. Overlap your window by ten minutes. -
The WebSocket stopped sending match updates that changed nothing. A
match_updateframe now means a match-level field actually changed, and says which indata.changed. Every frame also carriests, the server's send time. See live data. -
GET /v1/{game}/matches/{id}/lineups— each team's listed roster as snapshotted in the 48 hours before the match. A roster, not a confirmed starting five: usually 6–7 player ids. From 2026-07-09, for roughly 6 in 10 matches;data_available.lineupsand?has=lineupstell you which. -
event_typeon a tournament —lan,onlineornull— with an?event_type=filter. -
first_half_side_aon a map result — the side team A started on,ctort. Populated on about half of all maps. -
GET /v1/{game}/matches/{id}/streams, andinclude=streams— the channels broadcasting a match right now:platform,channel,url,embed_url,language,official,viewers,observed_at. A live pointer, not an archive: the list is replaced on each refresh and empties when the match ends, so[]is the normal answer for anything not currently being played. Ask for it over the live slate in one call with/matches/bulk?status=live&include=streams. See broadcast streams. -
include=playersonGET /v1/{game}/matches/{id}and/matches/bulk— every player the match refers to, resolved to full rows. It is the union of theplayer_idsin its lineup snapshots and theplayer_idon its stats rows, so a scoreboard or a lineup renders without one/players/{id}request per player. No standalone route; this is the only way to get it. -
started_aton a map result — when that individual map began, from upstream's own per-map clock. Null where upstream never published one. -
end_reasonandround_duration_mson a round.end_reasonis upstream's own vocabulary (TargetBombed,BombDefused,CTWin,TerroristsWin,TargetSaved, and others) — treat it as an open set of strings, not a closed enum. Both are collected going forward and backfilled over the last 90 days; older rounds keepnull. See round stats. -
kindon a duel —enemy,teamorself. Previously a team kill was encoded as a negativekillsand a suicide as a row whose killer and victim were the same player, both undocumented. See the correction below. -
in_playandmarket_statuson an odds line.in_playsays whether the line was captured after its match started (nullwhen no start time is known);market_statusis the market's own state. See odds movement. -
Four fields that were always served and never documented:
input_oldest_at,market_typeandmap_numberon an odds line, andteam_idon a player stat line. -
The change feed now fires when a match's data arrives, not only when the match itself changes.
changed_atmoves the first time maps, stats, per-map stats, rounds, depth or lineups land for a match, so a?changed_since=consumer learns that a finished match became worth re-fetching. Before this, depth landing hours later pushed nothing.
Corrections to data already served
Each of these changed values you may already have stored. They are fixed going forward and repaired across the archive; where a repair drains over days rather than instantly, it says so.
-
Weapon rows from 2023 were missing their slug, and several weapons collapsed into one row. For seven months in 2023 the upstream emitted the P250 with no weapon slug at all (and still does for its
UNKNOWNplaceholder). Because a weapon row is keyed on(match, player, weapon_slug, side), every slugless weapon a player used on one side merged into a single row, which we then served with an emptyweapon_slug— not joinable, not filterable, and indistinguishable from the next slugless weapon. 18,267 rows across 2,547 matches were affected. Each now carries a slug derived from its display name, and the handful that collided with an existing row were folded into it by summing every counter.Separately, slug
p250was the only slug in the archive served under two display names (P250andp250), because the upstream spells it both ways.weapon_nameis now resolved from the slug, so it is alwaysP250.Neither of these is re-fetchable — the upstream no longer serves the payloads that produced them — so both were repaired in place across the archive rather than by re-pulling matches. A small residue remains and is not a bug we can close: about 5% of P250 rows that record a kill record no shots, against a ~2% rate common to every weapon, because the upstream itself sometimes reports one without the other.
-
/depthwas permanently partial on some matches. Upstream parses each map of a series asynchronously, and we wrote whatever had landed at the moment we asked. Depth is now written only once it reconciles against the match's own per-map stat lines, and a match whose parse never completes upstream reportsdata_available.depth: falserather than a quietly half-populated payload. The same gate now applies to the whole-match aggregate stat row, which could hold fewer kills than its own per-map rows. -
A series'
adrwas the unweighted mean of its per-map figures, which is wrong whenever the maps ran to different lengths — a 16-14 and a 13-3 carry equal weight in a mean and very different weight in the real total. It is now total damage over total rounds, recomputed across the archive wherever the per-map rows cover every played map. Per-mapadris untouched. -
Overtime was folded into the second half on some maps, so a 16-14 could read as 9+7 / 3+11 with no overtime recorded. Corrected where upstream's own round data proves the split, using the game's era (CS2 is MR12, CS:GO was MR15) rather than inferring it from the half length.
-
started_aton a match was our own observation time, not the match's. Around three thousand completed matches carried a start earlier than their scheduled time, and 35 finished before they began. It is now upstream's own first-map start where published, andnull— meaning unknown — where it is not, rather than a number we made up. Ascheduledmatch no longer keeps a start time, and a walkover has none. Roughly three thousand completed matches will therefore reappear in?changed_since=with a corrected or clearedstarted_at.A start before the scheduled time is not itself an error, and is expected to remain. Series begin when both teams are ready, so a real first-map start often precedes the announced one — after the repair, 299 completed matches still do, 218 of them by five minutes or less. What was wrong before was the source of the number, not the ordering. Don't validate
started_at >= scheduled_at. -
Team kills and suicides were being served as negative and self-referential duels. 14,725 rows had a negative
kills; 2,544 named the same player as killer and victim.killsis now always ≥ 0 andkindcarries the meaning. -
An odds line's
labelis now the participant's current name. It used to be a snapshot frozen at capture time, so a team that rebranded showed its old name on historical lines — about 5% of recent rows. Lines with no resolvable participant keep the stored label. -
Some fixtures appeared twice because the same organisation existed under two names from two upstreams. Those teams are merged and the duplicate fixtures collapsed.
-
A few players existed twice, under two
ids. A player's slug was effectively their key, so one person recorded under two spellings of the same handle became two rows, each holding part of their career — different match histories, different stat lines, and in 29 matches both halves listed side by side as if they were teammates. Eight players were split this way. They are now merged: the survivingidcarries the full history, and the id it absorbed no longer resolves.Identity is decided by the upstream id a player carries, never by their handle. That distinction is load-bearing in the other direction too — 109 nicknames in the archive belong to more than one person, so a merge on matching names would have done more damage than the split did. See a player's nickname is not an identifier.
-
short_nameon a team is trimmed, and no longer frozen at whichever value arrived first. -
live_state.map_nameis now always canonical (de_ancient), not sometimes a bare or prefixed form (ancient,default-ancient), and a refresh no longer wipes a live match's map score.
Documentation
-
The copy-and-run code samples on the match and player endpoints now work. They all used one placeholder id that is actually a team id, so
GET /v1/cs2/matches/{that id}answered404and.../mapsanswered an empty list — which looked like an endpoint with no data rather than a wrong id. Thirty-two samples across eight endpoints are corrected. -
Every match example is one real match, shown end to end. The
/maps,/rounds,/stats,/vetoesand/depthexamples are now the live responses for the same series, so you can follow a single match across all five. They also show fields the old examples predated:started_at,first_half_side_a,statusandrounds_playedon a map;end_reasonandround_duration_mson a round; a populatedteam_idon a stat line; and a duel withkind: teambeside the ordinarykind: enemyone. -
Weapon
killsis a net figure, and now says so. A team kill subtracts one, so a weapon row can legitimately readkills: -1— about 3,400 rows in the corpus do, mostly grenades, and 634 are negative ondamagefor the same reason. Those values are correct as stored and clamping them breaks the sum against a player's matchkills. Two consequences are documented alongside it:headshotscan exceedkillson a row (5,948 do), so a per-row headshot rate is invalid; and filtering a weapon list onkills > 0drops 3.08M zero-kill rows that are still weapons the player used. See player depth.
2026-09-17
All additive. Nothing you already parse changes shape.
-
Per-map player stats are being written again, and you can check for them.
GET /v1/{game}/matches/{id}/statshas always been documented as one row per player per map plus a whole-match aggregate row, and on many matches only the aggregate rows were present. That is fixed going forward, and the archive is being filled in continuously.A customer reported this, and they had no way to detect it in advance, so there is now a flag:
data_available.map_statsis true only when the per-map rows exist, wheredata_available.statsis true as soon as any row does.?has=map_statsfilters a match list the same way. See player match stats. -
result_typeon a match says whether it wasplayedor awarded as awalkover. A walkover is a forfeit — no map was played, so its empty maps, rounds and stats are the correct answer rather than a gap in our data.nullmeans we were not told; it is never inferred from the score, because a genuine 1–0 best-of-one awaiting its detail looks identical. -
statusandrounds_playedon a map result.statusisplayed,technical(a forfeit, abandon or replay — decided without being played out, but still counted toward the series score) orwalkover(awarded outright).nullon older rows means the source did not say; read it asplayed.This matters if you compute anything from map rows. A 1–0 forfeit is not a map win on Dust2, and counting one distorts map records, ratings and head-to-head alike. Our own numbers now count
playedandtechnicaltoward a series score, andplayedonly for every performance statistic. See map results.
2026-07-26
Action required: the error body shape changed
Error responses moved from {"error": "message"} to a coded envelope. If you match on the old
flat string, update before this reaches you — everything else in this release is additive.
-
Coded error envelope. Every non-2xx response now returns:
{ "error": { "code": "invalid_cursor", "message": "invalid cursor", "request_id": "019f…" } }codeis a stable identifier you can switch on;messageis prose and explicitly not a contract. This exists because the flat shape gave you nothing machine-readable beyond the HTTP status, which collapses a dozen distinct 400s into one bucket — so clients were left string-matching text we were free to change. The codes that matter most: a429is now distinguishablyrate_limited(retry after a moment) orquota_exceeded(retrying cannot help until the window resets). See Errors for the full list and a worked handler. -
X-Request-Idon every response, success or failure, and repeated aserror.request_id. Log it — quoting it turns a support question into a single log lookup. A panic now also returns the envelope rather than an empty body. -
Slug addressing. A path
{id}accepts either a UUID or the resource's slug on teams, players and tournaments:/v1/cs2/teams/natus-vincereworks.?opponent=on head-to-head accepts both too. Matches have no slug. -
?include=on match detail.?include=teams,tournament,oddsembeds the related resources, so a match view is one request instead of four. Omit it and the response is byte-for-byte what it was. -
?sort=on/teams,/playersand/rankings, with a-prefix for descending. Previously the parameter was accepted and silently ignored; an unrecognised key is now a400listing the accepted ones. Cursors are bound to the sort they were minted under — cursors issued before this change keep working on the default order. -
CORS for browser callers. The rate-limit and quota headers are now readable cross-origin via
Access-Control-Expose-Headers, and the interactive "try it" panel in these docs executes real requests. -
Documentation. Every operation, parameter and schema field is now documented, every endpoint carries a real response example captured from production, and the reference is grouped by resource rather than by URL segment (old reference URLs redirect). The spec is published at /openapi.yaml. Nothing in these docs describes unshipped behaviour any more — the fields previously flagged as intended-but-not-yet-live are all shipped.
2026-07
- Per-match data availability — matches now carry
data_available, telling you which sub-resources actually hold data for that match:{maps, stats, vetoes, rounds, depth}. Each key is the sub-resource's own path segment, soGET /v1/{game}/matches/{id}/{key}returns rows if and only if that key is true — no more fetching an endpoint to discover it's empty.?has=rounds,depthfilters a list the same way (comma-separated, AND-ed). Coverage is not uniform: round-by-round and per-player depth come from a single upstream, so matches it doesn't cover carry results and little else. The flags describe what is stored now, not a forecast — a scheduled match reads all-false and flips as data lands./coveragealso now declaresround_statsandplayer_depth, two capabilities that were being served but never listed. - Roster history —
GET /v1/{game}/teams/{id}/roster-historyreturns the five a team actually fielded in each recent completed match, newest first, with the roster changes between them (players_in/players_out,has_debutant,days_since_prev). Readcontinuitybefore interpretingchanged_count: aresetis an organisation rename or full rebuild, not five stand-ins, and matches where a team used more than five players across maps are omitted rather than guessed at. - Market odds line live —
GET /v1/{game}/oddsserves the derivedeo_marketline: a de-vigged aggregate combined from multiple bookmakers and exchanges (published only with ≥2 contributing books;book_counton every line; contributing books are never named). - WebSocket tickets —
POST /v1/{game}/ws-tokenmints a short-lived (60s) signed ticket for the WebSocket handshake (wss://…/v1/ws?token=…), so raw API keys stay out of URLs. The?apiKey=handshake fallback is deprecated and will be removed. - Initial
v1surface for Counter-Strike 2:matches,odds,teams,tournaments,players,rankings,coverage. - WebSocket channel for update notifications. Metering is settled: a per-key concurrent-connection cap (5 on the standard plan), one metered request to mint the ticket, and no per-message charge. (Superseded: the ticket charge was removed on 2026-08-16 — the WebSocket now costs no request quota at all. This entry is left as shipped; see 2026-09-20.)
- Interactive API reference generated directly from the OpenAPI spec.
Coming soon
- Modeled odds — our own proprietary line (
eo_modelsource) is in development, gated behind an accuracy-validation step. It will be documented and served here once that clears, with its track record (calibration + Brier score) published alongside. - Additional markets beyond match winner, as their data clears the same quality bar.
- Additional game namespaces beyond
cs2, following the same/v1/{game}/{resource}convention.