guide

Generate a CS2 match preview (head-to-head, form, rankings)

Jul 10, 20265 min read

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 opponent parameter, 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.

Assembling a preview fans out from two resolved team IDs into head-to-head, form, and ranking calls, which combine into one preview.

The four calls, and their gotchas

Each of the four reads has a detail worth knowing before you write the code:

The four calls in order, each with its gotcha: resolve slugs to UUIDs, head-to-head needs an opponent parameter, form is not paginated, and own rank is opt-in via rank=1.

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"]

Form efficiency: request the endpoint with a limit parameter, the server clamps it to a maximum of 50, and it returns that many rows, so there is no default page to slice down in Python.

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:

The assembled preview card: the head-to-head record, each team's recent form, and their rankings, shown as text.

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()},
    }

Concurrency: submitting the independent head-to-head, form and ranking reads to a thread pool lets them overlap, so the wall-clock time falls to roughly one request instead of the sum of them all.

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

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.