Bottom line: A CS2 match preview is four reads stitched together: resolve both teams' slugs to IDs, then pull their head-to-head record, each side's recent form, and each side's ranking. Each call has one gotcha to know — detail routes need UUIDs, head-to-head requires an
opponentparameter, form is a single page, and a team's own rank is opt-in via?rank=1. Get those four right and the rest is formatting. Built on the EsportsOdds CS2 data API.
The shape of a preview
Two team slugs go in; a preview panel comes out. In between, the work fans out into parallel reads and comes back together.
The four calls, and their gotchas
Each of the four reads has a detail worth knowing before you write the code:
Let's build them one at a time.
1. Resolve slugs to IDs. Every detail and derived route works on UUIDs, so start by turning both slugs into IDs via the teams list:
import os
import requests
BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}
def team_id(slug: str) -> str:
r = requests.get(f"{BASE}/v1/cs2/teams", params={"slug": slug},
headers=HEADERS, timeout=10)
r.raise_for_status()
rows = r.json()["data"]
if not rows:
raise LookupError(f"no team {slug!r}")
return rows[0]["id"]
2. Head-to-head. GET /teams/{id}/h2h requires an opponent query parameter; omit it and you get a 400 "opponent must be a valid team id". It returns the completed-match record between the two. Read it with care: head-to-head across roster changes is a small and often stale sample, so weight recent, map-level meetings far above a lifetime tally.
def h2h(team: str, opponent: str) -> dict:
r = requests.get(f"{BASE}/v1/cs2/teams/{team}/h2h",
params={"opponent": opponent}, headers=HEADERS, timeout=10)
r.raise_for_status()
return r.json()["data"] # {team_wins, opponent_wins, total, ...}
3. Recent form. GET /teams/{id}/form returns a team's recent completed results as a single page: it's not paginated, so there's no cursor loop. It defaults to 10 results and accepts a ?limit= up to 50, so ask the server for exactly the run you want rather than fetching the default and slicing it in Python. Each row carries won, opponent_id, and the map score as score_for / score_against:
def form(team: str, n: int = 5) -> list[dict]:
r = requests.get(f"{BASE}/v1/cs2/teams/{team}/form",
params={"limit": n}, headers=HEADERS, timeout=10)
r.raise_for_status()
return r.json()["data"]
4. Ranking. A team's position on our win-rate leaderboard (see the methodology for how that board is ranked) is own_rank, and it's opt-in: request the team detail with ?rank=1, or own_rank comes back null:
def ranking(team: str) -> int | None:
r = requests.get(f"{BASE}/v1/cs2/teams/{team}",
params={"rank": 1}, headers=HEADERS, timeout=10)
r.raise_for_status()
return r.json()["data"].get("own_rank")
Assemble the preview
With the four helpers in place, a preview is a matter of calling them and shaping the result:
def preview(slug_a: str, slug_b: str) -> dict:
a, b = team_id(slug_a), team_id(slug_b)
record = h2h(a, b)
return {
"teams": [slug_a, slug_b],
"h2h": f"{record['team_wins']}–{record['opponent_wins']} "
f"(over {record['total']} meetings)",
"form": {
slug_a: ["W" if m["won"] else "L" for m in form(a)],
slug_b: ["W" if m["won"] else "L" for m in form(b)],
},
"rank": {slug_a: ranking(a), slug_b: ranking(b)},
}
p = preview("natus-vincere", "faze")
print(f"H2H: {p['h2h']}")
print(f"{p['teams'][0]}: form {''.join(p['form'][p['teams'][0]])} rank #{p['rank'][p['teams'][0]]}")
The result is a compact, factual panel: the record between the teams, each side's recent W/L run, and where they sit in the rankings:
Keep the framing neutral. A preview reports what happened (the record, the form, the standings) and lets the reader draw conclusions. It's context, not a tip.
Making it fast
The reads don't depend on each other, so run them concurrently. A ThreadPoolExecutor around the blocking requests calls is the least-effort way to overlap them and cut the wall-clock time to roughly a single request:
from concurrent.futures import ThreadPoolExecutor
def preview_fast(slug_a: str, slug_b: str) -> dict:
a, b = team_id(slug_a), team_id(slug_b) # resolve first, then fan out
with ThreadPoolExecutor(max_workers=5) as pool:
record = pool.submit(h2h, a, b)
form_a, form_b = pool.submit(form, a), pool.submit(form, b)
rank_a, rank_b = pool.submit(ranking, a), pool.submit(ranking, b)
return {
"h2h": record.result(),
"form": {slug_a: form_a.result(), slug_b: form_b.result()},
"rank": {slug_a: rank_a.result(), slug_b: rank_b.result()},
}
Async is the other route: httpx with asyncio.gather overlaps the same calls without threads. Either way, if you're generating previews in a batch, wrap the calls in the resilient client from the rate-limits guide so the burst doesn't trip the per-second limit.
Next steps
- Add fantasy projections — layer per-player numbers onto the team preview.
- Show it on a dashboard — render the preview alongside the live score.
Resolve, fan out, assemble. Mind the required opponent parameter and the opt-in rank, and a preview that reports rather than recommends will read as credible to whoever lands on it.
Full API reference: fetching a full match in one request in the developer docs.