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_maps and 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 as between_maps, and a running round score we cannot refresh for 3 minutes is sent as null. 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_state can move on to the next map as soon as we confirm it has begun, from 19:44 UTC on 19 October 2026. The state is in_progress for the next map_number, with that map's map_name, null round scores and a null as_of, until the first round score arrives. map_score_a and map_score_b stay as they were. A map that is being played is never replaced by this.
  • between_maps can 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_state is sent as between_maps for that map instead of the old round score: both round scores are null, map_score_a / map_score_b count the map, and observed_at is when we confirmed it. The round score can run minutes behind play, so this can arrive before the series score does. A client that handles between_maps needs no change.

2026-10-17 (a new live_state.phase value, and no stale round scores)

Added

  • live_state.phase can be between_maps, no sooner than 19 October 2026. It means the map in map_number has ended but we do not yet know its final round score: round_score_a and round_score_b are null, and map_score_a / map_score_b already 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 (phase in_progress, as_of null) 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 as between_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.phase map_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_b and winner_team_id are unchanged: they still follow the match's own report, a median of about 12 minutes after it ends. live_state is the place to read a finished map or series sooner.
  • as_of for 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 from as_of can 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_state now 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_of is the time we received it less a fixed 10 seconds and rounds_played is null; the round-level feed's own as_of is unchanged. A map's final score and the state between maps still come only from the round-level feed.
  • A finished map's map_over state can be skipped. When a newer round score already shows the next map, you move straight to it, and map_score_a / map_score_b count the finished map.
  • as_of also 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, because as_of is 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=navi finds 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 q on teams.list, players.list and tournaments.list, so its MCP tools can look a name up too. pip install -U esportsodds, or let uvx fetch it.

Changed

  • A 404 on 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 and code (not_found) are the same. The 400 for a team or tournament given by name where an id is expected, and for an unknown opponent, 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=true now means the same as latest=1: the current price for each outcome. Before, only 1 was read and true was ignored without a word, so the answer was the match's whole line history. Any value other than 1, true, 0 or false is now a 400 invalid_parameter, like every other filter.
  • A 400 on a match or player list filter says what is accepted. A wrong status lists 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=scheduled on its own starts with the fixture furthest away: add sort=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_scope on 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 its changed_at moved, so a changed_since sync 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. Tournament status came only from the dates, so an event that never ran read as upcoming before its dates and finished after them, with no matches. cancelled is a new status value, ?status=cancelled lists those events, and they no longer match upcoming, ongoing or finished. An event that was called off part-way, with matches already played, keeps its date-based status, so a tournament with results never reads cancelled.

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) and 429 sandbox_quota_exceeded (the 2,000 are used; no Retry-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 with 308 Permanent Redirect to the same path on the surviving match, with an X-Merged-Into header and a match_merged error body. Clients that follow redirects get the surviving match with no change. Before, the detail route answered 404 and 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 404 on 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 and code are unchanged.

Fixed

  • /matches/{id}/streams and include=streams now 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_stage now 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 ApiError with status == 308 for a retired id, where it raised NotFoundError for the old 404. The next release maps it back to NotFoundError.

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 page level, and the exact wording of every rule in definition.
  • 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. limit default 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 a permaban flag, over a window.
  • GET /maps and GET /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 — and GET /rankings/round-stats, the round-level figures of the top teams in one request.
  • window, era and active on GET /teams/{id}/maps, window on /teams/{id}/ratings and /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=detail on GET /teams/{id}/maps adds each map's last-played date, pool membership and the team's CT, T and pistol round record. opening_duel_pct on /teams/{id}/round-stats is the share of opening kills the team took; match_id on every /teams/{id}/ratings point names the match that moved it.
  • delta_days, include=trend and include=rating on GET /rankings: measure rank_delta over 1–90 days, add a twelve-week trend series to each team row, and add each team's Glicko-2 rating and deviation rd.
  • GET /teams/{id}/ratings accepts limit up to 1000 (it was 200).

Corrections to data already served

  • GET /rankings?type=player counts 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, value and a player's own_rank all 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 id keeps one of the pair, and the other has a new id. The cause is fixed, and we now monitor for any recurrence.

  • One closing line per series. is_closing was 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_play follows 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 return in_play: null, as documented — some carried true or false.

Documentation corrections

  • The is_closing and open_price series are (market, outcome, line, source); the docs omitted line. Each line of a total_maps or map_handicap market is its own series.
  • is_closing is 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 — check in_play before 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_over or series_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 whose status is still live can 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, so as_of can be well before observed_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 update carrying "live_state": null. Before, the last live score stayed until something else about the match changed.

  • ts on 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, read live_state.as_of — not ts, and not observed_at, which is when we last read the state.

  • A match's updates can arrive before the subscribed ack 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 slug with them, and some matches' status flipped between completed and live, or live and scheduled, as two of our inputs disagreed. Each now has one owner, so a status change you receive is a real one.

  • status turns live sooner 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_state that names the map being played with null round scores — the docs said it was always null. And a live_state can appear shortly before status turns live.

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-Limit reads 50000 from 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 401 with invalid_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 carries changed_at, changed (the fields that moved, the same vocabulary the WebSocket sends), changes with a from/to per field, and was_completed. ?limit= up to 200, walked back with ?before=; entries are kept for 180 days.

    was_completed: true marks 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 a freshness object: p50_seconds, p90_seconds, p99_seconds over a 30-day window, the sample behind them, window_days, measured_at, and a basis string 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, never 0, where the window holds nothing yet, with a note saying why; figures derived from the revision history above populate over the days after this ships rather than arriving complete. Live round score publishes null permanently 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_seconds on 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_names on 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), with World standing in for the environment. A raw string join against weapon_name silently 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 404 and 405 body ended with see 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 with maps, stats, vetoes, rounds, depth and lineups embedded via include=, 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 gets 503 + Retry-After and 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, with X-Quota-Endpoint-Limit and X-Quota-Endpoint-Remaining headers, while every other endpoint keeps working. Paid plans never see it. See rate limits.

  • The same include= names work on GET /v1/{game}/matches/{id}, alongside the existing teams, tournament and odds.

  • A change feed: changed_at, ?changed_since= and sort=changed_at on the match list and the bulk endpoint. changed_at moves when something you can actually see changes, unlike updated_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_at has 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_update frame now means a match-level field actually changed, and says which in data.changed. Every frame also carries ts, 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.lineups and ?has=lineups tell you which.

  • event_type on a tournament — lan, online or null — with an ?event_type= filter.

  • first_half_side_a on a map result — the side team A started on, ct or t. Populated on about half of all maps.

  • GET /v1/{game}/matches/{id}/streams, and include=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=players on GET /v1/{game}/matches/{id} and /matches/bulk — every player the match refers to, resolved to full rows. It is the union of the player_ids in its lineup snapshots and the player_id on 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_at on a map result — when that individual map began, from upstream's own per-map clock. Null where upstream never published one.

  • end_reason and round_duration_ms on a round. end_reason is 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 keep null. See round stats.

  • kind on a duel — enemy, team or self. Previously a team kill was encoded as a negative kills and a suicide as a row whose killer and victim were the same player, both undocumented. See the correction below.

  • in_play and market_status on an odds line. in_play says whether the line was captured after its match started (null when no start time is known); market_status is the market's own state. See odds movement.

  • Four fields that were always served and never documented: input_oldest_at, market_type and map_number on an odds line, and team_id on a player stat line.

  • The change feed now fires when a match's data arrives, not only when the match itself changes. changed_at moves 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 UNKNOWN placeholder). 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 empty weapon_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 p250 was the only slug in the archive served under two display names (P250 and p250), because the upstream spells it both ways. weapon_name is now resolved from the slug, so it is always P250.

    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.

  • /depth was 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 reports data_available.depth: false rather 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' adr was 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-map adr is 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_at on 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, and null — meaning unknown — where it is not, rather than a number we made up. A scheduled match 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 cleared started_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. kills is now always ≥ 0 and kind carries the meaning.

  • An odds line's label is 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 surviving id carries 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_name on a team is trimmed, and no longer frozen at whichever value arrived first.

  • live_state.map_name is 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} answered 404 and .../maps answered 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, /vetoes and /depth examples 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, status and rounds_played on a map; end_reason and round_duration_ms on a round; a populated team_id on a stat line; and a duel with kind: team beside the ordinary kind: enemy one.

  • Weapon kills is a net figure, and now says so. A team kill subtracts one, so a weapon row can legitimately read kills: -1 — about 3,400 rows in the corpus do, mostly grenades, and 634 are negative on damage for the same reason. Those values are correct as stored and clamping them breaks the sum against a player's match kills. Two consequences are documented alongside it: headshots can exceed kills on a row (5,948 do), so a per-row headshot rate is invalid; and filtering a weapon list on kills > 0 drops 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}/stats has 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_stats is true only when the per-map rows exist, where data_available.stats is true as soon as any row does. ?has=map_stats filters a match list the same way. See player match stats.

  • result_type on a match says whether it was played or awarded as a walkover. 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. null means we were not told; it is never inferred from the score, because a genuine 1–0 best-of-one awaiting its detail looks identical.

  • status and rounds_played on a map result. status is played, technical (a forfeit, abandon or replay — decided without being played out, but still counted toward the series score) or walkover (awarded outright). null on older rows means the source did not say; read it as played.

    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 played and technical toward a series score, and played only 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…" } }

    code is a stable identifier you can switch on; message is 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: a 429 is now distinguishably rate_limited (retry after a moment) or quota_exceeded (retrying cannot help until the window resets). See Errors for the full list and a worked handler.

  • X-Request-Id on every response, success or failure, and repeated as error.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-vincere works. ?opponent= on head-to-head accepts both too. Matches have no slug.

  • ?include= on match detail. ?include=teams,tournament,odds embeds 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, /players and /rankings, with a - prefix for descending. Previously the parameter was accepted and silently ignored; an unrecognised key is now a 400 listing 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, so GET /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,depth filters 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. /coverage also now declares round_stats and player_depth, two capabilities that were being served but never listed.
  • Roster history — GET /v1/{game}/teams/{id}/roster-history returns 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). Read continuity before interpreting changed_count: a reset is 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}/odds serves the derived eo_market line: a de-vigged aggregate combined from multiple bookmakers and exchanges (published only with ≥2 contributing books; book_count on every line; contributing books are never named).
  • WebSocket tickets — POST /v1/{game}/ws-token mints 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 v1 surface 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_model source) 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.

On this page