Counter-Strike 2 does not hand you an API for your Premier rating. There is no “GET /my-rank” endpoint, no official export button, and no first-party dashboard that plots your climb from Gray to Red over a season. If you want to watch your CS Rating move over time, correlate it with your K/D, or build something you can actually show off on a Discord server, you have to build it yourself. This tutorial walks through exactly that: a self-hosted CS2 stats tracker written in Python, backed by the Steam Web API for identity data and a lightweight SQLite database for history, with a small Flask dashboard on top.

By the end you will have a working tool that logs your Steam profile data, stores manually entered or scraped Premier rating snapshots, calculates your percentile against 2026 rank-distribution data, and renders a simple trend chart. It is not a Valve-sanctioned integration (because one does not exist), but it is a legitimate, ToS-respecting way to track your own numbers over time.

Why CS2 has no official rank API

Before writing a line of code, it helps to understand what you are actually working with. CS2’s Premier mode replaced the old CS:GO skill-group badges with a numeric CS Rating, a single score that starts new players around 1,000 and climbs with no fixed ceiling, though practical play caps out somewhere past 30,000 for the top slice of the population. The community groups that number into seven color-coded bands spanning 5,000 points each, from Gray at the bottom to Gold at the top. That system is well documented by third-party sites, but Valve does not expose it through the Steam Web API.

The Steam Web API does give you real, useful data: SteamID resolution, profile visibility, owned games, playtime, and generic per-app stats where a title supports them. What it does not give you is Premier CS Rating or Competitive skill group. That number lives inside the game client and on your scoreboard, and Valve has kept it out of any publicly documented feed. Sites like Leetify, pley.gg, and csdb.gg publish rank-distribution data pulled from their own tracked user bases, not from a Valve API, and none of them advertise an open public endpoint you can hit programmatically without an account.

That constraint shapes the whole project. Instead of pretending an API exists, this tutorial builds a tracker around the data you genuinely can get: your Steam identity and metadata through the official API, plus a rating value you log yourself (by typing it in after a match, or by adapting the optional screenshot-parsing step near the end). That is a more honest approach than shipping a tool that scrapes a leaderboard page and breaks the day the HTML changes.

This gap is not new. CS:GO never had an official ranks API either, which is exactly why an entire ecosystem of third-party stats sites grew up around the game over the past decade, each filling the hole with their own client extensions, demo parsers, and tracked-user databases. CS2 inherited that same gap when Valve migrated the game onto the Source 2 engine, and nothing in the Premier system’s design suggests that is changing soon. Building your own small tracker is less about competing with those established platforms and more about understanding, firsthand, exactly what data a game studio does and does not choose to expose, and why.

Prerequisites

You do not need much to follow along, but get these versions right before you start, since mismatches are the single biggest source of first-run errors in this kind of project.

RequirementVersion used in this guideNotes
Python3.12 or later3.10+ works, but f-string and typing syntax below assumes 3.12
pip24.x or laterBundled with recent Python installers
Flask3.0.xServes the dashboard route
requests2.32.xHTTP client for Steam Web API calls
SQLite3.40+ (bundled with Python)No separate install needed on most systems
Steam accountAny, in good standingNeeded to register an API key
Operating systemWindows 10/11, macOS 13+, or LinuxCommands below use a POSIX shell; Windows users should run them in PowerShell or WSL

You will also need a code editor (VS Code, PyCharm, or anything with syntax highlighting) and about 45 minutes if you are typing the code out by hand, or 20 minutes if you are copying the blocks directly.

Step 1: Register a Steam Web API key

Log into a Steam account in your browser, then open the Steam Web API key page. Enter a domain name (if you do not own one, “localhost” works for local development) and agree to the terms. Steam issues a 32-character key immediately. Copy it somewhere safe and treat it exactly like a database password: never commit it to a public repository and never embed it in client-side JavaScript.

You will also need your own 64-bit SteamID. The fastest way to get it is to open your Steam profile, click Edit Profile, and copy the numeric ID from the URL, or use a SteamID lookup tool against your custom profile URL.

Step 2: Set up the project and install dependencies

Create a project folder and a virtual environment so the dependencies stay isolated from your system Python.

mkdir cs2-rank-tracker
cd cs2-rank-tracker
python3 -m venv venv
source venv/bin/activate   # on Windows: venv\Scripts\activate
pip install flask requests python-dotenv

Create a .env file in the project root to hold your secrets. This keeps the API key out of your source files entirely.

STEAM_API_KEY=your_32_character_key_here
STEAM_ID=your_64_bit_steamid_here

Add .env and venv/ to a .gitignore file if you plan on version-controlling this project. Skipping this step is the number one reason people accidentally leak API keys on GitHub.

Step 3: Build the database schema

The tracker needs somewhere to store rating snapshots over time. SQLite is the right tool here: zero configuration, a single file, and more than enough performance for a personal tracker logging a handful of entries a day. Create db.py:

import sqlite3
from pathlib import Path

DB_PATH = Path(__file__).parent / "tracker.db"

def get_connection():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn

def init_db():
    conn = get_connection()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS rating_snapshots (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            steam_id TEXT NOT NULL,
            cs_rating INTEGER NOT NULL,
            matches_played INTEGER,
            recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
        )
    """)
    conn.commit()
    conn.close()

if __name__ == "__main__":
    init_db()
    print("Database initialized at", DB_PATH)

Run it once to create the file:

python db.py
# Database initialized at /path/to/cs2-rank-tracker/tracker.db

Step 4: Pull identity data from the Steam Web API

Next, write a small module that confirms your SteamID resolves and pulls basic profile data. This is also useful for verifying a user owns CS2 before letting them log ratings, if you turn this into a multi-user tool later.

import os
import requests
from dotenv import load_dotenv

load_dotenv()

API_KEY = os.environ["STEAM_API_KEY"]
STEAM_ID = os.environ["STEAM_ID"]
BASE_URL = "https://api.steampowered.com"

def get_player_summary(steam_id: str) -> dict:
    url = f"{BASE_URL}/ISteamUser/GetPlayerSummaries/v2/"
    params = {"key": API_KEY, "steamids": steam_id}
    response = requests.get(url, params=params, timeout=10)
    response.raise_for_status()
    players = response.json()["response"]["players"]
    if not players:
        raise ValueError("No player found for that SteamID")
    return players[0]

def owns_cs2(steam_id: str) -> bool:
    url = f"{BASE_URL}/IPlayerService/GetOwnedGames/v1/"
    params = {"key": API_KEY, "steamid": steam_id, "format": "json"}
    response = requests.get(url, params=params, timeout=10)
    response.raise_for_status()
    games = response.json().get("response", {}).get("games", [])
    return any(g["appid"] == 730 for g in games)

if __name__ == "__main__":
    profile = get_player_summary(STEAM_ID)
    print(f"Logged in as: {profile['personaname']}")
    print(f"Owns CS2: {owns_cs2(STEAM_ID)}")

Run it and you should see your Steam display name and a confirmation of CS2 ownership. App ID 730 is Counter-Strike 2 (it inherited CS:GO’s app ID when Valve upgraded the game in place).

python steam_client.py
Logged in as: yourusername
Owns CS2: True

Step 5: Log a rating snapshot

Since Valve does not expose CS Rating programmatically, the practical path is to log it yourself after a Premier match, either by hand or by adapting the optional screen-reading step covered later in this guide. Add a logging function to db.py:

def log_rating(steam_id: str, cs_rating: int, matches_played: int = None):
    conn = get_connection()
    conn.execute(
        "INSERT INTO rating_snapshots (steam_id, cs_rating, matches_played) "
        "VALUES (?, ?, ?)",
        (steam_id, cs_rating, matches_played),
    )
    conn.commit()
    conn.close()

def get_history(steam_id: str):
    conn = get_connection()
    rows = conn.execute(
        "SELECT cs_rating, matches_played, recorded_at "
        "FROM rating_snapshots WHERE steam_id = ? ORDER BY recorded_at",
        (steam_id,),
    ).fetchall()
    conn.close()
    return [dict(row) for row in rows]

Test it from a Python shell or a small script:

from db import log_rating, get_history

log_rating("76561198000000000", 14200, matches_played=180)
print(get_history("76561198000000000"))
# [{'cs_rating': 14200, 'matches_played': 180, 'recorded_at': '2026-09-10 14:22:01'}]

Step 6: Map a rating to its color tier

The community-standard CS Rating bands have held steady through 2026 across multiple independent trackers. Hard-code them so your tool can label a raw number with the tier players actually recognize.

Color tierCS Rating rangeShare of tracked players (2026)
Gray0 – 4,999~18.8% – 19.0%
Light Blue5,000 – 9,999~22.2% – 22.4%
Blue10,000 – 14,999~26.4% – 27.0%
Purple15,000 – 19,999~20.6% – 20.7%
Pink20,000 – 24,999~9.4% – 9.5%
Red25,000 – 29,999~1.8% – 2.2%
Gold30,000+~0.01%

These figures are drawn from independent 2026 distribution snapshots published by pley.gg (sampling roughly 2.8 million tracked players) and BuyBoosting’s Premier distribution data, so treat the ranges as directional rather than exact. Now write the mapping function in a new file, ranks.py:

TIERS = [
    ("Gray", 0, 4999, 18.8),
    ("Light Blue", 5000, 9999, 22.4),
    ("Blue", 10000, 14999, 26.4),
    ("Purple", 15000, 19999, 20.7),
    ("Pink", 20000, 24999, 9.5),
    ("Red", 25000, 29999, 2.2),
    ("Gold", 30000, float("inf"), 0.01),
]

def get_tier(cs_rating: int) -> dict:
    for name, low, high, share in TIERS:
        if low <= cs_rating <= high:
            return {"tier": name, "range": f"{low}-{high}", "population_share": share}
    return {"tier": "Unknown", "range": None, "population_share": None}

def estimate_percentile(cs_rating: int) -> float:
    cumulative = 0.0
    for name, low, high, share in TIERS:
        if cs_rating >= low:
            cumulative += share
    return round(min(cumulative, 99.99), 2)

Test the mapping against a known value:

from ranks import get_tier, estimate_percentile

print(get_tier(14200))
# {'tier': 'Blue', 'range': '10000-14999', 'population_share': 26.4}
print(estimate_percentile(14200))
# 67.6

Step 7: Build the Flask dashboard route

With the data layer working, wire up a minimal web dashboard. Create app.py:

from flask import Flask, render_template, request, redirect
from db import init_db, log_rating, get_history
from ranks import get_tier, estimate_percentile
from steam_client import get_player_summary
import os
from dotenv import load_dotenv

load_dotenv()
app = Flask(__name__)
STEAM_ID = os.environ["STEAM_ID"]

@app.route("/")
def dashboard():
    history = get_history(STEAM_ID)
    profile = get_player_summary(STEAM_ID)
    latest = history[-1] if history else None
    tier_info = get_tier(latest["cs_rating"]) if latest else None
    percentile = estimate_percentile(latest["cs_rating"]) if latest else None
    return render_template(
        "dashboard.html",
        profile=profile,
        history=history,
        tier_info=tier_info,
        percentile=percentile,
    )

@app.route("/log", methods=["POST"])
def log_new_rating():
    rating = int(request.form["cs_rating"])
    matches = request.form.get("matches_played")
    log_rating(STEAM_ID, rating, int(matches) if matches else None)
    return redirect("/")

if __name__ == "__main__":
    init_db()
    app.run(debug=True, port=5000)

Step 8: Create the dashboard template

Flask looks for templates in a templates/ folder by default. Create templates/dashboard.html:

<!DOCTYPE html>
<html>
<head><title>CS2 Rank Tracker</title></head>
<body>
  <h1>{{ profile.personaname }}'s CS2 Rating</h1>
  {% if tier_info %}
    <p>Current tier: <strong>{{ tier_info.tier }}</strong>
       ({{ tier_info.range }})</p>
    <p>Estimated percentile: top {{ 100 - percentile }}%</p>
  {% else %}
    <p>No ratings logged yet.</p>
  {% endif %}

  <form method="POST" action="/log">
    <input type="number" name="cs_rating" placeholder="CS Rating" required>
    <input type="number" name="matches_played" placeholder="Matches played">
    <button type="submit">Log rating</button>
  </form>

  <h2>History</h2>
  <ul>
    {% for entry in history %}
      <li>{{ entry.recorded_at }}: {{ entry.cs_rating }}</li>
    {% endfor %}
  </ul>
</body>
</html>

Step 9: Run the tracker locally

Start the Flask development server:

python app.py
 * Serving Flask app 'app'
 * Debug mode: on
 * Running on http://127.0.0.1:5000

Open http://127.0.0.1:5000 in a browser. You should see your Steam display name, a form to log a new rating, and a running history list once you submit a few entries. Log a rating after each Premier session and the history list grows into a real trend line you can eyeball, or later plug into a charting library like Chart.js.

Step 10: Add a simple trend chart

A list of numbers is functional but not very readable. Add Chart.js via CDN to the template, right before the closing </body> tag, and feed it the history data as JSON:

<canvas id="ratingChart" width="600" height="300"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
  const labels = {{ history | map(attribute='recorded_at') | list | tojson }};
  const data = {{ history | map(attribute='cs_rating') | list | tojson }};
  new Chart(document.getElementById('ratingChart'), {
    type: 'line',
    data: { labels: labels, datasets: [{ label: 'CS Rating', data: data }] }
  });
</script>

Refresh the dashboard after logging a few entries and you will see an actual line chart of your rating over time, which is far more useful for spotting tilt-driven losing streaks than a plain list.

Step 11: Export your history to CSV

If you want to analyze your climb in a spreadsheet or hand it off to a friend building their own overlay, add an export route. This also doubles as a backup mechanism, since the SQLite file is the only copy of your logged history.

import csv
import io
from flask import Response

@app.route("/export")
def export_csv():
    history = get_history(STEAM_ID)
    output = io.StringIO()
    writer = csv.DictWriter(output, fieldnames=["recorded_at", "cs_rating", "matches_played"])
    writer.writeheader()
    writer.writerows(history)
    return Response(
        output.getvalue(),
        mimetype="text/csv",
        headers={"Content-Disposition": "attachment;filename=cs2_rating_history.csv"},
    )

Step 12: Schedule automatic reminders to log a rating

A tracker only works if you actually feed it data. The easiest fix is a scheduled reminder rather than trying to automate a scrape of client-only data. On Linux or macOS, a cron entry works fine:

# Runs every day at 9pm, prints a reminder to your terminal notifier
0 21 * * * notify-send "Log your CS2 rating" "Open http://127.0.0.1:5000 and log today's number"

On Windows, Task Scheduler can trigger the same kind of notification via a short PowerShell script. This keeps your dataset consistent instead of having gaps every time you forget to log a session.

Common pitfalls when building a CS2 stats tracker

A handful of mistakes account for most of the frustration people run into with this kind of project. Here is what to watch for.

  • Assuming a Premier rating API exists. It does not. Any tutorial or package that claims to fetch live CS Rating from an official endpoint is either scraping a website (fragile, and often against that site’s terms) or reading local game files.
  • Hardcoding the API key in source files. This is the fastest way to leak a credential the moment you push to a public repository. Always load it from environment variables.
  • Forgetting Steam profile privacy settings. If a profile is set to private, GetPlayerSummaries returns limited data and GetOwnedGames may return nothing at all, even for the profile owner’s own key in some client configurations.
  • Using the wrong App ID. CS2 uses App ID 730, inherited from CS:GO. Using a different or outdated ID silently breaks the ownership check.
  • Not handling rate limits. The Steam Web API enforces request limits per key. A tight polling loop (for example, checking every few seconds) will start returning errors; poll at most once every few minutes for personal use.
  • Storing ratings without a timestamp. Without recorded_at, you cannot build a trend line, which defeats the entire point of a tracker.
  • Trusting third-party “public APIs” without checking ToS. Sites like Leetify and csstats.gg provide valuable data through their own web apps, but scraping them programmatically without permission can violate their terms of service and get your IP blocked.

Troubleshooting

If something breaks along the way, check this list before assuming your code is wrong. Most of these issues come from configuration, not logic bugs.

  • “KeyError: STEAM_API_KEY” — Your .env file is not being loaded, or is not in the same directory as the script you are running. Confirm load_dotenv() runs before you read os.environ.
  • “403 Forbidden” from the Steam Web API — Your API key is invalid, revoked, or you are calling an endpoint your key is not authorized for. Regenerate the key from the Steam dev page.
  • Empty players list from GetPlayerSummaries — The SteamID you passed is malformed. It must be the 64-bit numeric SteamID, not a vanity URL name or a 32-bit SteamID.
  • “sqlite3.OperationalError: no such table” — You forgot to run python db.py before starting the Flask app, so the table was never created.
  • Flask says “Address already in use” — Another process is bound to port 5000. Kill it, or run with app.run(port=5001).
  • Chart renders blank — Usually means history is empty. Log at least two entries so the chart has data points to connect.
  • owns_cs2() always returns False — Your Steam privacy settings hide your game list from the API. Set your game details to public in Steam privacy settings, or skip the ownership check for personal use.
  • “429 Too Many Requests” — You are polling too aggressively. Add a delay between calls or cache the profile summary locally for a few minutes.
  • CSV export downloads but is blank — Check that get_history() is querying the correct SteamID; a mismatch between the ID used to log data and the ID used to read it returns an empty result silently.

Advanced tips

Once the base tracker works, a few extensions make it genuinely more useful day to day.

First, track per-map performance alongside overall rating by adding a map_name column to the snapshots table. CS2’s Premier map pool rotates by season, and some players climb faster on specific maps, so segmenting by map surfaces patterns a single aggregate number hides.

Second, if you want to reduce manual logging, look into OCR-based screenshot parsing with a library like Tesseract: capture the post-match scoreboard, crop the region showing your rating, and run text extraction against it. This is more fragile than manual entry (UI changes break it) but cuts the friction of typing in a number after every match.

Third, add a simple win/loss streak counter by comparing consecutive cs_rating deltas. A string of losses is often a better signal to take a break than raw rating alone, since tilt compounds quickly in ranked play.

Finally, if you want to compare yourself against the broader population beyond the static percentile table in Step 6, periodically pull fresh distribution snapshots from public rank-distribution pages like csdb.gg’s distribution page and update the TIERS constant. Just do it manually and infrequently (monthly is plenty) rather than scraping on every dashboard load.

Writing a quick test suite

It is tempting to skip tests on a personal project like this, but a tracker that silently logs wrong data is worse than no tracker at all, since you will not notice the bug until your chart looks strange weeks later. A handful of fast tests around the tier-mapping and database logic catches the most common regressions before they corrupt your history.

Install pytest and add a test file for the rank-mapping logic, since that is the part most likely to have an off-by-one error at a tier boundary:

pip install pytest
# test_ranks.py
from ranks import get_tier, estimate_percentile

def test_gray_lower_bound():
    assert get_tier(0)["tier"] == "Gray"

def test_tier_boundary_is_inclusive():
    assert get_tier(4999)["tier"] == "Gray"
    assert get_tier(5000)["tier"] == "Light Blue"

def test_gold_has_no_upper_bound():
    assert get_tier(500000)["tier"] == "Gold"

def test_percentile_increases_with_rating():
    low = estimate_percentile(3000)
    high = estimate_percentile(20000)
    assert high > low

Run the suite with:

pytest -v
# test_ranks.py::test_gray_lower_bound PASSED
# test_ranks.py::test_tier_boundary_is_inclusive PASSED
# test_ranks.py::test_gold_has_no_upper_bound PASSED
# test_ranks.py::test_percentile_increases_with_rating PASSED
# 4 passed in 0.02s

Add a second file to cover the database layer, using a temporary in-memory database so tests never touch your real tracker.db file:

# test_db.py
import sqlite3
import pytest
from db import init_db, log_rating, get_history

@pytest.fixture
def temp_db(monkeypatch, tmp_path):
    test_path = tmp_path / "test.db"
    monkeypatch.setattr("db.DB_PATH", test_path)
    init_db()
    return test_path

def test_log_and_read_back(temp_db):
    log_rating("76561198000000000", 12000, matches_played=50)
    history = get_history("76561198000000000")
    assert len(history) == 1
    assert history[0]["cs_rating"] == 12000

def test_history_is_ordered_by_time(temp_db):
    log_rating("76561198000000000", 10000)
    log_rating("76561198000000000", 11000)
    history = get_history("76561198000000000")
    assert history[0]["cs_rating"] == 10000
    assert history[1]["cs_rating"] == 11000

These tests run in well under a second and would have caught, for example, an off-by-one bug where a rating of exactly 5,000 got mapped to Gray instead of Light Blue, or a schema change that silently dropped the recorded_at ordering. Run them before every change you make to the schema or tier logic, not just once at the start.

The complete project structure

Once every step above is done, your project folder should look like this:

cs2-rank-tracker/
├── .env
├── .gitignore
├── app.py
├── db.py
├── ranks.py
├── steam_client.py
├── test_ranks.py
├── test_db.py
├── tracker.db
├── requirements.txt
└── templates/
    └── dashboard.html

Generate the requirements.txt file so the project is reproducible on another machine:

pip freeze > requirements.txt

To set the project up fresh elsewhere, clone it, then run:

python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python db.py
python app.py

Deploying the tracker to a small VPS

Running the tracker on your own laptop is fine for testing, but the Flask development server is not built for anything beyond that. It has no process manager, restarts on every code change, and prints stack traces to whoever hits an unhandled route. If you want the dashboard reachable from your phone or a friend’s browser, move it to a small VPS and put a real WSGI server and a reverse proxy in front of it.

Install Gunicorn as your production WSGI server. It handles multiple worker processes and recovers from a crashed worker without taking the whole app down, something the Flask dev server does not do.

pip install gunicorn
gunicorn --workers 2 --bind 127.0.0.1:8000 app:app

Next, wrap that in a systemd service so it survives reboots and restarts automatically if it crashes. Create /etc/systemd/system/cs2-tracker.service:

[Unit]
Description=CS2 Rank Tracker
After=network.target

[Service]
User=tracker
WorkingDirectory=/home/tracker/cs2-rank-tracker
Environment="PATH=/home/tracker/cs2-rank-tracker/venv/bin"
ExecStart=/home/tracker/cs2-rank-tracker/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now cs2-tracker
sudo systemctl status cs2-tracker
# ● cs2-tracker.service - CS2 Rank Tracker
#      Loaded: loaded (/etc/systemd/system/cs2-tracker.service; enabled)
#      Active: active (running)

Finally, put Nginx in front of Gunicorn so you get proper HTTPS termination and do not expose the app server directly to the internet. A minimal server block looks like this:

server {
    listen 80;
    server_name tracker.example.com;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Run Certbot against that domain to get a free TLS certificate, and you have a dashboard reachable over HTTPS that restarts itself if it ever crashes, without you needing to babysit a terminal window. This is the same pattern used for most small self-hosted Flask tools, not anything CS2-specific, so it transfers directly if you build other trackers later.

Securing the tracker before you expose it publicly

The moment your dashboard is reachable from outside your own machine, the threat model changes. A tool that only ever talked to localhost is now a public web app, and it should be treated with the same care as any other public app, even if it is “just a stats tracker.”

Start by disabling debug mode. The debug=True flag used earlier in this guide is convenient during development because it auto-reloads code and shows detailed stack traces in the browser, but those same stack traces can leak file paths, environment details, and in some configurations allow arbitrary code execution through the interactive debugger. Never run debug=True on anything reachable from the internet.

# Development
app.run(debug=True, port=5000)

# Production (let Gunicorn handle serving instead)
if __name__ == "__main__":
    init_db()
    app.run(debug=False, host="127.0.0.1", port=5000)

Next, add basic rate limiting to the /log route so a script or a bored visitor cannot flood your database with junk entries. Flask-Limiter is a lightweight way to do this without pulling in a heavier framework:

pip install flask-limiter
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(get_remote_address, app=app, default_limits=["30 per hour"])

@app.route("/log", methods=["POST"])
@limiter.limit("10 per hour")
def log_new_rating():
    rating = int(request.form["cs_rating"])
    matches = request.form.get("matches_played")
    log_rating(STEAM_ID, rating, int(matches) if matches else None)
    return redirect("/")

Finally, if you built the multi-user variant from the extension section above, do not skip authentication. An open route that accepts any SteamID as a URL parameter lets anyone enumerate and read your friends’ logged rating history, which is a real privacy leak even if the data feels low-stakes. Steam OpenID is the standard way to solve this: it lets a visitor prove they own a given Steam account without your app ever handling a password, and it is the same mechanism large CS2-adjacent sites use for login.

How this compares to third-party CS2 stats sites

It is worth being upfront about what a self-built tracker gets you versus an established platform. Leetify, for instance, draws on a large tracked-player base to publish monthly Premier rank distribution reports and gives users detailed per-match breakdowns through a browser extension and client integration, something a weekend project cannot replicate without significant additional engineering. csdb.gg and pley.gg similarly maintain regularly updated distribution and season-date pages that a solo tracker has no way to match in scope.

FeatureThis DIY trackerLeetify / pley.gg / csdb.gg
Data source for your own ratingManual entry (or optional OCR)Client integration or browser extension
Population percentile dataStatic table, manually updatedLive, updated from large tracked samples
Match-level detail (aim, utility, etc.)Not included by defaultDetailed per-match breakdowns
HostingFully self-hosted, your data stays localCloud-hosted, account required
CostFree, run on your own machineFree tier plus paid tiers on some platforms
Setup time~45 minutes, one-timeMinutes, but ongoing dependency on the service

The DIY route makes the most sense if you care about owning your data, want to learn the Steam Web API for other projects, or just want a lightweight personal dashboard without creating another account on another service. If you want deep per-round analytics without writing any code, an established platform is the faster path.

Extending the tracker: multi-user support

If you want to track a whole 5-stack rather than just yourself, the schema from Step 3 already supports it since steam_id is stored per row. The main changes needed are a simple login flow (Steam OpenID works well for this and avoids handling passwords yourself) and a small tweak to the dashboard route to accept a steam_id query parameter instead of hardcoding your own.

@app.route("/player/<steam_id>")
def player_dashboard(steam_id):
    history = get_history(steam_id)
    profile = get_player_summary(steam_id)
    latest = history[-1] if history else None
    tier_info = get_tier(latest["cs_rating"]) if latest else None
    return render_template("dashboard.html", profile=profile, history=history, tier_info=tier_info)

For a small friend group this is enough. For anything public-facing, add authentication before deploying, since an open route that accepts any SteamID lets strangers query each other’s logged data.

One more practical note if you go the multi-user route: batch your Steam Web API calls where you can. Calling GetPlayerSummaries once per player on every dashboard load works fine for a handful of friends, but the endpoint actually accepts a comma-separated list of up to 100 SteamIDs in a single request. For a group of five or ten, fetching them all in one call instead of five or ten separate calls cuts your API usage and page load time noticeably, and keeps you well clear of any rate limit even under heavier use.

Frequently asked questions

Does Valve provide an official API for CS2 Premier ranks?

No. The Steam Web API exposes profile data, owned games, and playtime, but not Premier CS Rating or Competitive skill group. That data is only visible in the game client and on the scoreboard.

What app ID does CS2 use for the Steam Web API?

App ID 730, the same ID CS:GO used before Valve upgraded the game in place to Counter-Strike 2.

Can I scrape Leetify or csdb.gg to get live rank data?

You can technically request their public pages, but neither site advertises an open API for third-party programmatic use, and scraping without checking their terms of service risks getting your IP address blocked. It is safer to log your own numbers manually or reference their published aggregate reports.

How many CS Rating tiers are there in 2026?

Seven: Gray, Light Blue, Blue, Purple, Pink, Red, and Gold, each spanning roughly 5,000 rating points, with Gold covering everything from 30,000 upward.

Is my Steam API key safe to store in a .env file?

Yes, as long as the file is excluded from version control via .gitignore and never sent to the browser. Keep all Steam API calls on the server side.

Why does GetOwnedGames return an empty list for my own account?

Your Steam privacy settings likely hide your game details. Go to Steam Privacy Settings and set “Game details” to public, or accept that the ownership check will not work while private.

Can I run this tracker on a Raspberry Pi or a small VPS?

Yes. The stack (Python, Flask, SQLite) is lightweight enough to run comfortably on a Raspberry Pi 4 or the smallest tier of most VPS providers. If you expose it publicly, put it behind HTTPS and add authentication first.

What is a good CS2 Premier rating in 2026?

Based on 2026 distribution data, the Blue tier (10,000–14,999) sits around the population average, with roughly a quarter to a little over a quarter of tracked players in that band. Purple and above (15,000+) puts you ahead of roughly a third of tracked players, while Red (25,000+) represents only around 2% of the tracked population.

Do I need a paid Steam Web API tier for this project?

No. The Steam Web API key you register through the developer page is free for non-commercial personal use and covers everything this tracker needs, including profile summaries and owned-games lookups. There is no paid tier for the endpoints used in this guide.

Will this tracker break if Valve changes the Premier rating system?

The Steam Web API calls (profile lookup, owned-games check) are stable and unlikely to change. The tier boundaries in ranks.py are community-derived, not pulled from Valve directly, so if Valve rebalances Premier’s rating curve you would need to manually update the TIERS constant to match new published ranges, the same way every third-party CS2 stats site has to.