guide

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

Jul 10, 20265 min read

Bottom line: A CS2 match preview is three reads stitched together, all addressed by team slug: the head-to-head record, each side's recent form, and each side's ranking. Know four things going in: every team route takes the slug as well as the UUID, 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 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 team slugs, used as they are, into head-to-head, form, and ranking calls, which combine into one preview.

The calls, and their gotchas

Four details are worth knowing before you write the code:

The four things to know: slugs work in every team route, 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. Use the slugs as they are. Every team route takes the slug in place of the UUID, and so does opponent=, so there is no lookup step: /teams/vitality/h2h?opponent=mouz-cs2 works as written. An unknown slug answers 404. If you store results, key them on the id in the response: it never changes, while a slug can when a team renames (the old slug still resolves).

import os
import requests

BASE = "https://api.esportsodds.gg"
HEADERS = {"Authorization": f"Bearer {os.environ['ESPORTSODDS_API_KEY']}"}

2. Head-to-head. GET /teams/{id}/h2h requires an opponent query parameter; omit it and you get a 400 whose message reads opponent is required (a team id or slug). 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:
    record = h2h(slug_a, slug_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(slug_a)],
            slug_b: ["W" if m["won"] else "L" for m in form(slug_b)],
        },
        "rank": {slug_a: ranking(slug_a), slug_b: ranking(slug_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:
    with ThreadPoolExecutor(max_workers=5) as pool:   # nothing to resolve first
        record = pool.submit(h2h, slug_a, slug_b)
        form_a, form_b = pool.submit(form, slug_a), pool.submit(form, slug_b)
        rank_a, rank_b = pool.submit(ranking, slug_a), pool.submit(ranking, slug_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

Fan out, assemble, format. 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.

Start free with a sandbox key: a fixed sample of past matches, no card.