esportsodds 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. esportsodds-0.1.0/.gitignore +15 -0
  2. esportsodds-0.1.0/CHANGELOG.md +27 -0
  3. esportsodds-0.1.0/LICENSE +21 -0
  4. esportsodds-0.1.0/PKG-INFO +385 -0
  5. esportsodds-0.1.0/README.md +351 -0
  6. esportsodds-0.1.0/examples/backfill_matches.py +61 -0
  7. esportsodds-0.1.0/examples/follow_changes.py +70 -0
  8. esportsodds-0.1.0/examples/handle_errors.py +51 -0
  9. esportsodds-0.1.0/examples/quickstart.py +11 -0
  10. esportsodds-0.1.0/examples/team_report.py +46 -0
  11. esportsodds-0.1.0/examples/upcoming_with_odds.py +29 -0
  12. esportsodds-0.1.0/examples/watch_matches.py +83 -0
  13. esportsodds-0.1.0/pyproject.toml +114 -0
  14. esportsodds-0.1.0/src/esportsodds/__init__.py +66 -0
  15. esportsodds-0.1.0/src/esportsodds/_client.py +631 -0
  16. esportsodds-0.1.0/src/esportsodds/_errors.py +261 -0
  17. esportsodds-0.1.0/src/esportsodds/_headers.py +74 -0
  18. esportsodds-0.1.0/src/esportsodds/_pagination.py +102 -0
  19. esportsodds-0.1.0/src/esportsodds/_resources.py +1041 -0
  20. esportsodds-0.1.0/src/esportsodds/_routes.py +161 -0
  21. esportsodds-0.1.0/src/esportsodds/_time.py +76 -0
  22. esportsodds-0.1.0/src/esportsodds/_transport.py +157 -0
  23. esportsodds-0.1.0/src/esportsodds/_version.py +3 -0
  24. esportsodds-0.1.0/src/esportsodds/py.typed +0 -0
  25. esportsodds-0.1.0/tests/__init__.py +0 -0
  26. esportsodds-0.1.0/tests/conftest.py +192 -0
  27. esportsodds-0.1.0/tests/localserver.py +116 -0
  28. esportsodds-0.1.0/tests/readme_blocks.py +92 -0
  29. esportsodds-0.1.0/tests/test_changes.py +301 -0
  30. esportsodds-0.1.0/tests/test_client.py +660 -0
  31. esportsodds-0.1.0/tests/test_deadline.py +62 -0
  32. esportsodds-0.1.0/tests/test_errors.py +275 -0
  33. esportsodds-0.1.0/tests/test_examples.py +691 -0
  34. esportsodds-0.1.0/tests/test_headers.py +77 -0
  35. esportsodds-0.1.0/tests/test_integration.py +210 -0
  36. esportsodds-0.1.0/tests/test_package.py +86 -0
  37. esportsodds-0.1.0/tests/test_pagination.py +194 -0
  38. esportsodds-0.1.0/tests/test_readme.py +932 -0
  39. esportsodds-0.1.0/tests/test_readme_claims.py +300 -0
  40. esportsodds-0.1.0/tests/test_resources.py +532 -0
  41. esportsodds-0.1.0/tests/test_retries.py +375 -0
  42. esportsodds-0.1.0/tests/test_time.py +142 -0
  43. esportsodds-0.1.0/tests/test_transport.py +251 -0
  44. esportsodds-0.1.0/tests/test_websocket.py +85 -0
@@ -0,0 +1,15 @@
1
+ # Build and tooling artifacts. This file ships with the public mirror, and it also covers the
2
+ # monorepo copy (the root .gitignore only knows model-lab's Python artifacts).
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ __pycache__/
9
+ *.pyc
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .coverage
14
+ htmlcov/
15
+ .DS_Store
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are recorded here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Until 1.0, a minor version may change
6
+ the API; the change will be listed here first.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - `Client`, with one method for each public endpoint of the esportsodds.gg CS2 API: matches (with
15
+ maps, stats, vetoes, rounds, history, depth, lineups and streams), odds, teams, tournaments,
16
+ players, rankings, coverage and model metrics, plus `health()`.
17
+ - Pagination: every list call returns a `Page`, and `pages()` / `iter()` walk a whole list lazily,
18
+ one request per page. The bulk export has `pages_bulk()` / `iter_bulk()`; `matches.iter_changes()`
19
+ follows the change feed with its overlap handled for you; `matches.iter_history()` walks a
20
+ match's revisions.
21
+ - Errors: a typed exception for every API error code, all subclasses of `EsportsOddsError`, each
22
+ carrying the status, code, request id and `Retry-After`.
23
+ - Retries for what is safe to retry (`429 rate_limited`, `503`, `502`, `504`, network failures and,
24
+ at most twice, `500`), never for `quota_exceeded`. `client.last_response` holds the request id and
25
+ the request budget of the latest response.
26
+ - `ws_token()` and `ws_url()` for the WebSocket; the package does not open the socket itself.
27
+ - Standard library only, Python 3.10 and newer, typed (`py.typed`, checked with `mypy --strict`).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sellframe Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,385 @@
1
+ Metadata-Version: 2.5
2
+ Name: esportsodds
3
+ Version: 0.1.0
4
+ Summary: Python client for the esportsodds.gg CS2 (Counter-Strike 2) match data and stats API.
5
+ Project-URL: Homepage, https://esportsodds.gg/cs2-api
6
+ Project-URL: Documentation, https://docs.esportsodds.gg/docs
7
+ Project-URL: Repository, https://github.com/esportsodds/esportsodds-python
8
+ Project-URL: Changelog, https://github.com/esportsodds/esportsodds-python/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/esportsodds/esportsodds-python/issues
10
+ Author: Sellframe Ltd
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api-client,counter-strike,counter-strike-2,cs2,cs2-api,cs2-stats,csgo,esports,esports-api,esports-data,match-data,sdk
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2; extra == 'dev'
29
+ Requires-Dist: mypy>=1.11; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.6; extra == 'dev'
32
+ Requires-Dist: twine>=6.1; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # esportsodds
36
+
37
+ **The official Python client for the [esportsodds.gg](https://esportsodds.gg/cs2-api) CS2 data API.**
38
+
39
+ Counter-Strike 2 matches and results, team and player stats, tournaments, rankings and a derived
40
+ market line, returned as plain Python dicts. Standard library only: there is nothing else to install.
41
+
42
+ Get CS2 match data and stats without scraping HLTV. This package is a client for a paid API; it is
43
+ not an official HLTV/Valve API.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install esportsodds
49
+ ```
50
+
51
+ Python 3.10 or newer. No dependencies.
52
+
53
+ ## Quickstart
54
+
55
+ ```python
56
+ from esportsodds import Client
57
+
58
+ client = Client() # reads ESPORTSODDS_API_KEY from the environment
59
+
60
+ for match in client.matches.list(status="scheduled", limit=5).data:
61
+ print(match["scheduled_at"], match["team_a_name"], "vs", match["team_b_name"])
62
+ ```
63
+
64
+ ```text
65
+ 2026-10-08T14:00:00Z Team A vs Team B
66
+ 2026-10-08T17:30:00Z Team C vs Team D
67
+ ```
68
+
69
+ Every call returns what the API sent, as ordinary dicts and lists. A list call returns a `Page`
70
+ (`page.data`, `page.next_cursor`, `page.has_more`, `page.meta`); a call for one thing returns a dict.
71
+
72
+ ## Your API key and the plan
73
+
74
+ There is one plan: **$49 a month for 50,000 requests**, per account, counted per calendar month
75
+ (UTC) and paid up front. There is no free tier and no trial: a key exists only for a paid account.
76
+ Past the 50,000, requests get a `429` with the code `quota_exceeded` until the month resets, and
77
+ this client raises `QuotaExceededError` without retrying (retrying cannot help).
78
+
79
+ Create a key in your account after you [subscribe](https://esportsodds.gg/pricing), then keep it
80
+ out of your code:
81
+
82
+ ```bash
83
+ export ESPORTSODDS_API_KEY="eo_live_..."
84
+ ```
85
+
86
+ - The client sends the key only in the `Authorization: Bearer` header, never in a URL. It shows
87
+ only the last four characters in `repr()` and never puts the key in an error message or a log line.
88
+ - It talks HTTPS only. Plain `http://` is accepted for `localhost` and other loopback addresses
89
+ (`127.0.0.1`, `::1`) alone, and redirects are never followed, so a key cannot be sent to a
90
+ different host.
91
+ - Keep the key on your server. A browser or mobile app should call your backend, not the API.
92
+ - You can look at real responses before subscribing on the [public demo](https://demo.esportsodds.gg).
93
+
94
+ ## Configuration
95
+
96
+ | Environment variable | Meaning |
97
+ | --- | --- |
98
+ | `ESPORTSODDS_API_KEY` | Your API key, used when `api_key` is not passed. |
99
+ | `ESPORTSODDS_BASE_URL` | The API address, used when `base_url` is not passed. |
100
+
101
+ | `Client(...)` argument | Default | Meaning |
102
+ | --- | --- | --- |
103
+ | `api_key` | `None` | Your key. When omitted, `ESPORTSODDS_API_KEY` is read; with neither, `ValueError`. |
104
+ | `base_url` | `https://api.esportsodds.gg` | The API address. HTTPS only, except `localhost` and other loopback addresses. |
105
+ | `game` | `"cs2"` | The title to query. CS2 is the only one with data today. |
106
+ | `timeout` | `30.0` | Seconds to wait for a reply. The bulk export always waits at least 120. |
107
+ | `max_retries` | `3` | Retries per call for the failures listed under [Errors](#errors). `0` turns retries off. |
108
+ | `transport` | `None` | Your own HTTP stack: any object with `send(TransportRequest) -> TransportResponse`. |
109
+
110
+ A `Client` is safe to share between threads. Use it as a context manager (`with Client() as client:`)
111
+ or call `client.close()` when you are finished: it closes a custom `transport` that has a `close()`
112
+ method (the default one keeps no connections open, so it has nothing to release).
113
+
114
+ ## Methods
115
+
116
+ Every endpoint is one method. `{game}` is the client's `game`, and `{id}` accepts the UUID or, for
117
+ teams, players and tournaments, the slug.
118
+
119
+ | Call | Endpoint | Returns | What it gives you |
120
+ | --- | --- | --- | --- |
121
+ | `client.matches.list()` | `GET /v1/{game}/matches` | `Page` | Fixtures and results, newest first; filter by `status`, `tournament`, `team`, dates and `has`. |
122
+ | `client.matches.bulk()` | `GET /v1/{game}/matches/bulk` | `Page` | Matches with their maps, stats and more embedded (`include=`), 25 per request. |
123
+ | `client.matches.get(match)` | `GET /v1/{game}/matches/{id}` | `dict` | One match; `include=` embeds teams, tournament, odds or sub-resources. |
124
+ | `client.matches.maps(match)` | `GET /v1/{game}/matches/{id}/maps` | `Page` | Map-by-map results. |
125
+ | `client.matches.stats(match)` | `GET /v1/{game}/matches/{id}/stats` | `Page` | Per-player stat lines, whole match and per map. |
126
+ | `client.matches.vetoes(match)` | `GET /v1/{game}/matches/{id}/vetoes` | `Page` | The map pick and ban sequence. |
127
+ | `client.matches.rounds(match)` | `GET /v1/{game}/matches/{id}/rounds` | `Page` | Round-by-round team stats: economy, opening duels, clutches, bomb events. |
128
+ | `client.matches.history(match)` | `GET /v1/{game}/matches/{id}/history` | `Page` | What a match used to say: each change with its value before and after. |
129
+ | `client.matches.depth(match)` | `GET /v1/{game}/matches/{id}/depth` | `dict` | Per-player weapons, grenades, hit groups, duels and flashes. |
130
+ | `client.matches.lineups(match)` | `GET /v1/{game}/matches/{id}/lineups` | `Page` | Each team's listed roster before the match. |
131
+ | `client.matches.streams(match)` | `GET /v1/{game}/matches/{id}/streams` | `Page` | The channels broadcasting a match. |
132
+ | `client.odds.list()` | `GET /v1/{game}/odds` | `Page` | The derived market line, as history or as the current board. |
133
+ | `client.teams.list()` | `GET /v1/{game}/teams` | `Page` | Every team, alphabetically. |
134
+ | `client.teams.get(team)` | `GET /v1/{game}/teams/{id}` | `dict` | One team. |
135
+ | `client.teams.h2h(team, opponent=...)` | `GET /v1/{game}/teams/{id}/h2h` | `dict` | The head-to-head record between two teams. |
136
+ | `client.teams.form(team)` | `GET /v1/{game}/teams/{id}/form` | `Page` | Recent completed results, newest first. |
137
+ | `client.teams.ratings(team)` | `GET /v1/{game}/teams/{id}/ratings` | `Page` | The team's rating history, one point per match. |
138
+ | `client.teams.maps(team)` | `GET /v1/{game}/teams/{id}/maps` | `Page` | The team's record on each map. |
139
+ | `client.teams.round_stats(team)` | `GET /v1/{game}/teams/{id}/round-stats` | `dict` | Round-level playstyle rates: side win rates, pistols, opening duels. |
140
+ | `client.teams.roster_history(team)` | `GET /v1/{game}/teams/{id}/roster-history` | `Page` | The five players fielded in each recent match, with roster changes. |
141
+ | `client.tournaments.list()` | `GET /v1/{game}/tournaments` | `Page` | Every tournament, newest first. |
142
+ | `client.tournaments.get(tournament)` | `GET /v1/{game}/tournaments/{id}` | `dict` | One tournament. |
143
+ | `client.tournaments.teams(tournament)` | `GET /v1/{game}/tournaments/{id}/teams` | `Page` | The participating teams, by seed. |
144
+ | `client.players.list()` | `GET /v1/{game}/players` | `Page` | Every player, alphabetically by nickname. |
145
+ | `client.players.get(player)` | `GET /v1/{game}/players/{id}` | `dict` | One player. |
146
+ | `client.players.career(player)` | `GET /v1/{game}/players/{id}/stats` | `dict` | A player's career totals and averages. |
147
+ | `client.rankings.list()` | `GET /v1/{game}/rankings` | `Page` | A team or player leaderboard over a rolling three-month window. |
148
+ | `client.coverage.list()` | `GET /v1/{game}/coverage` | `Page` | What data exists and how often it refreshes. |
149
+ | `client.model.metrics()` | `GET /v1/{game}/model/metrics` | `Page` | Transparency metrics for the model line, per market. The model line is coming soon; the list may be empty. |
150
+ | `client.ws_token()` | `POST /v1/{game}/ws-token` | `dict` | A 60-second ticket for the [WebSocket](#websocket-match-updates). Costs no requests. |
151
+ | `client.health()` | `GET /health` | `dict` | The service status. Needs no key and is never retried. |
152
+
153
+ Filters are keyword arguments named as in the [API reference](https://docs.esportsodds.gg/docs).
154
+ A list given for a comma-separated filter (`has=["rounds", "depth"]`, `include=["maps", "stats"]`)
155
+ is joined for you; a `datetime` or `date` is formatted for you (a naive `datetime` means UTC); an
156
+ unknown argument raises `TypeError` instead of being sent. Ids are checked and escaped before they
157
+ go into a URL.
158
+
159
+ The odds are our own derived lines (`source="eo_market"`), not any bookmaker's price. The model
160
+ line (`source="eo_model"`) is coming soon and is not returned yet.
161
+
162
+ ## Pagination, the bulk export and the change feed
163
+
164
+ List calls return one page and a cursor. Walk them yourself:
165
+
166
+ ```python
167
+ page = client.matches.list(status="completed", limit=100)
168
+ while True:
169
+ for match in page.data:
170
+ print(match["id"])
171
+ if not page.has_more:
172
+ break
173
+ page = client.matches.list(status="completed", limit=100, cursor=page.next_cursor)
174
+ ```
175
+
176
+ or let the client do it. `iter()` yields rows, `pages()` yields pages, and both fetch lazily, one
177
+ request per page, so stopping early costs nothing more. `max_pages` is a safety limit; the last page
178
+ yielded still carries its `next_cursor`, so a job that stops (or crashes) can resume with `cursor=`.
179
+
180
+ ```python
181
+ for match in client.matches.iter(status="completed", date_from="2026-09-01"):
182
+ print(match["id"])
183
+ ```
184
+
185
+ `iter` and `pages` exist on `matches`, `odds`, `teams`, `tournaments` and `players`.
186
+
187
+ **The bulk export** is the cheap way to pull history: 25 matches per request, each with the
188
+ sub-resources you ask for embedded, and one request against your allowance per page.
189
+
190
+ ```python
191
+ for match in client.matches.iter_bulk(status="completed", include=["maps", "stats"]):
192
+ print(match["id"], len(match["maps"]))
193
+ ```
194
+
195
+ A busy moment answers `503` with a short `Retry-After`; the client waits and retries for you.
196
+
197
+ **The change feed** keeps a local copy current. Give `iter_changes` the `changed_at` of the newest
198
+ row you stored last time and it yields every match that changed since, oldest change first:
199
+
200
+ ```python
201
+ from datetime import datetime, timedelta, timezone
202
+
203
+ since = datetime.now(timezone.utc) - timedelta(hours=1)
204
+ newest = since.isoformat()
205
+ for match in client.matches.iter_changes(since):
206
+ newest = match["changed_at"] # rows arrive in changed_at order
207
+ print(match["id"], match["status"])
208
+ # Store `newest`; it is the `since` for the next run.
209
+ ```
210
+
211
+ It starts ten minutes before `since` (`overlap=`) because a row can become visible shortly after the
212
+ `changed_at` it carries, and drops the repeats this causes. A quiet hour costs one request.
213
+ `matches.history(match)` and `matches.iter_history(match)` show what each of those changes was.
214
+
215
+ ## Errors
216
+
217
+ Every error is an `EsportsOddsError`. A reply with an error status raises an `ApiError` subclass;
218
+ branch on the class, or on `error.code`, never on `error.message`.
219
+
220
+ | Exception | API `code` | Status | Retried automatically |
221
+ | --- | --- | --- | --- |
222
+ | `AuthenticationError` | `missing_api_key`, `invalid_api_key`, `unauthorized` | 401 | No |
223
+ | `PermissionDeniedError` | `forbidden` | 403 | No |
224
+ | `NotFoundError` | `not_found`, `unknown_game` | 404 | No |
225
+ | `InvalidRequestError` | `invalid_parameter` | 400 | No |
226
+ | `InvalidCursorError` | `invalid_cursor` | 400 | No: start the walk again from the first page |
227
+ | `ConflictError` | `conflict` | 409 | No |
228
+ | `RateLimitError` | `rate_limited` | 429 | Yes, after the `Retry-After` the API sends |
229
+ | `QuotaExceededError` | `quota_exceeded` | 429 | Never: nothing changes until the month resets |
230
+ | `ServiceUnavailableError` | `unavailable` | 503 | Yes, after the `Retry-After` the API sends |
231
+ | `ServerError` | `internal_error` | 500 | Yes, but at most twice |
232
+ | `ApiError` | any other | other | Only 502 and 504 |
233
+ | `TransportError` | none: the request never got an answer | none | Yes, with backoff |
234
+ | `InvalidResponseError` | none: the reply was not the shape the API documents | 2xx | No |
235
+
236
+ An unknown path id is a `404` (`NotFoundError`), not a `400`, and an unknown id on a sub-resource
237
+ such as `matches.maps(...)` returns an empty page rather than an error.
238
+
239
+ An `ApiError` carries `status`, `code`, `message`, `request_id`, `retry_after`, `headers` and `body`.
240
+ Quote `request_id` when you report a problem; it is also in `client.last_response.request_id`.
241
+
242
+ ```python
243
+ from esportsodds import QuotaExceededError, RateLimitError
244
+
245
+ try:
246
+ client.matches.get("00000000-0000-7000-8000-000000000001")
247
+ except QuotaExceededError as error:
248
+ print("out of requests until", error.reset_at)
249
+ except RateLimitError as error:
250
+ print("slow down; retry in", error.retry_after, "seconds")
251
+ ```
252
+
253
+ ## Rate limits and retries
254
+
255
+ Two limits apply: a per-key token bucket (20 requests a second sustained, bursts of 100) and the
256
+ monthly allowance of 50,000 requests per account, shared by all of an account's keys. After every
257
+ call, `client.last_response.rate_limit` has the budget the API reported:
258
+
259
+ ```python
260
+ info = client.last_response # None until the first call has been made
261
+ if info is not None:
262
+ budget = info.rate_limit
263
+ print(budget.quota_remaining, "of", budget.quota_limit, "left; resets", budget.quota_reset)
264
+ ```
265
+
266
+ The client retries only what is safe to retry, up to `max_retries` times (3 by default), and logs
267
+ each retry at `INFO` on the `esportsodds` logger:
268
+
269
+ - A `429 rate_limited` or `503` waits the `Retry-After` the API sent, plus up to half a second of
270
+ jitter. A wait longer than a minute is raised instead of slept through.
271
+ - A `502` or `504` and a network failure back off from half a second, doubling, up to eight seconds,
272
+ with jitter.
273
+ - A `500` is retried at most twice however high `max_retries` is: the API counts it against your
274
+ allowance, so each retry spends a request you are paying for.
275
+ - A `429 quota_exceeded` is never retried, and nothing else is.
276
+
277
+ Polling is rarely the cheapest design: the [change feed](#pagination-the-bulk-export-and-the-change-feed)
278
+ and the WebSocket cost far fewer requests. The
279
+ [request budget guide](https://docs.esportsodds.gg/docs/how-to/request-budget) has the numbers.
280
+
281
+ ## WebSocket (match updates)
282
+
283
+ The WebSocket pushes score and odds changes for the matches you subscribe to, and it costs no
284
+ requests: neither the ticket, nor the connection, nor the messages count against your allowance.
285
+ Updates arrive when our own collection sees a change, so the socket removes your polling delay but
286
+ cannot make the data newer than that collection; the guide linked below lists the typical ages.
287
+
288
+ This package does not open sockets itself (that would need a dependency). It mints the ticket and
289
+ builds the address; any WebSocket library connects, for example
290
+ [`websockets`](https://websockets.readthedocs.io/) (`pip install websockets`):
291
+
292
+ ```python
293
+ import asyncio
294
+ import json
295
+
296
+ import websockets
297
+
298
+ from esportsodds import Client
299
+
300
+ client = Client()
301
+
302
+
303
+ async def watch(match_ids: list[str]) -> None:
304
+ ticket = await asyncio.to_thread(client.ws_token)
305
+ async with websockets.connect(client.ws_url(ticket)) as socket:
306
+ await socket.send(json.dumps({"type": "subscribe", "data": {"match_ids": match_ids}}))
307
+ async for raw in socket:
308
+ frame = json.loads(raw)
309
+ if frame["type"] in ("snapshot", "update"):
310
+ print(frame["match_id"], frame["seq"], frame["type"])
311
+
312
+
313
+ asyncio.run(watch(["00000000-0000-7000-8000-000000000001"]))
314
+ ```
315
+
316
+ - A ticket lasts 60 seconds and is not single-use, so a retry inside that window can reuse it; mint
317
+ a new one once it has expired. Your key never appears in the socket address.
318
+ - A `snapshot` is the full current state of a match and an `update` is a change. `seq` counts up per
319
+ match; if one is skipped, subscribe to that match again to get a fresh `snapshot`.
320
+ - Reconnect with exponential backoff and jitter, and re-send your `subscribe`. There is no replay.
321
+ - A key may hold five connections at once, so subscribe to many matches on one connection.
322
+ - The server pings every 54 seconds; `websockets` answers for you.
323
+
324
+ The [WebSocket guide](https://docs.esportsodds.gg/docs/live-data) lists every frame type.
325
+
326
+ ## Examples
327
+
328
+ The [`examples/`](https://github.com/esportsodds/esportsodds-python/tree/main/examples) folder has
329
+ runnable scripts. Each reads `ESPORTSODDS_API_KEY`.
330
+
331
+ | Script | What it does |
332
+ | --- | --- |
333
+ | [`quickstart.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/quickstart.py) | The quickstart above. |
334
+ | [`upcoming_with_odds.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/upcoming_with_odds.py) | Upcoming matches with their current market line. |
335
+ | [`team_report.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/team_report.py) | A team's form, head-to-head record and ratings. |
336
+ | [`backfill_matches.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/backfill_matches.py) | A resumable bulk pull of completed matches to a JSON Lines file. |
337
+ | [`follow_changes.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/follow_changes.py) | Keeps a local file current from the change feed. |
338
+ | [`handle_errors.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/handle_errors.py) | Reading the budget and handling each kind of error. |
339
+ | [`watch_matches.py`](https://github.com/esportsodds/esportsodds-python/blob/main/examples/watch_matches.py) | Match updates over the WebSocket. |
340
+
341
+ ## FAQ
342
+
343
+ **Is this an official API?** No. It is not an official HLTV/Valve API, and esportsodds.gg is not
344
+ affiliated with or endorsed by Valve or any stats site.
345
+
346
+ **Why not scrape?** Scraping breaks whenever a page changes and many sites forbid it. With this API
347
+ you build without scraping HLTV or anyone else: you call stable, documented endpoints instead.
348
+
349
+ **Is there a free tier?** No. There is no free tier and no trial; the plan is $49 a month for
350
+ 50,000 requests, paid up front. The [public demo](https://demo.esportsodds.gg) shows real
351
+ responses without an account.
352
+
353
+ **What does a call cost?** One request against the 50,000, whatever it returns. A bulk page, which
354
+ carries up to 25 matches with their detail, is also one request. A walker (`iter`, `pages`,
355
+ `iter_bulk`, `iter_changes`) costs one request per page it fetches. The WebSocket costs none.
356
+
357
+ **Does it work with asyncio?** The client is synchronous and safe to share between threads. From
358
+ async code, run a call in a thread:
359
+
360
+ ```python
361
+ matches = await asyncio.to_thread(client.matches.list, status="scheduled", limit=10)
362
+ ```
363
+
364
+ **Can I use another HTTP library?** Yes. Pass `transport=` any object with a
365
+ `send(TransportRequest) -> TransportResponse` method. To go through a proxy with the default
366
+ transport, pass `UrllibTransport(proxies={"https": "http://proxy.example:3128"})`.
367
+
368
+ **Which games are supported?** CS2 (`game="cs2"`). The `game` argument exists for later titles.
369
+
370
+ **Is it typed?** Yes: the package ships `py.typed` and passes `mypy --strict`. Responses are plain
371
+ dicts, so they are `dict[str, Any]`.
372
+
373
+ **Where do I report a bug in this client?** In this repository's issues. Questions about your
374
+ account, billing or the data go through [esportsodds.gg](https://esportsodds.gg/contact).
375
+
376
+ ## Licence
377
+
378
+ The code in this repository is released under the
379
+ [MIT licence](https://github.com/esportsodds/esportsodds-python/blob/main/LICENSE). The licence
380
+ covers this client, not the data it fetches: the data is licensed to you under sections 4 and 5 of
381
+ the [Terms of Service](https://esportsodds.gg/terms), which allow you to build on it for your own
382
+ purposes and do not allow redistributing it.
383
+
384
+ Counter-Strike and CS2 are trademarks of Valve Corporation. esportsodds.gg is independent and is
385
+ not affiliated with, endorsed by or sponsored by Valve.