guide

"Build a CS2 score bot for Discord (Python)"

Jul 10, 2026

Bottom line: A CS2 results bot is a small polling loop plus a Discord webhook. Ask the API for completed matches, format a score line from the *_name fields the list already gives you, remember which match IDs you've posted so nothing double-posts, and POST to a webhook URL. No bot framework required — a webhook is just an HTTP endpoint. Built on the EsportsOdds CS2 data API.

How the pieces fit

Three parties: the API you poll, your bot in the middle, and the Discord channel it posts to. A Discord webhook is the simplest bridge — you don't need a gateway connection or the discord.py library, just a URL you send JSON to.

The bot polls the EsportsOdds API for completed matches, formats each result, and posts it to a Discord channel through a webhook.

Create a webhook in your Discord server under Channel Settings → Integrations → Webhooks, copy its URL, and store both secrets in the environment:

export ESPORTSODDS_API_KEY="eo_live_your_key_here"
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/…"

The posting loop

The logic is a loop with a memory: pull completed matches, skip any you've already announced, format the rest, and post them.

The loop: request completed matches, format a score line from the name fields, dedupe by match id so nothing repeats, then post to the webhook.

Deduplication is the part people forget. If you poll every few minutes, the same completed match keeps appearing in the result — you must track which IDs you've posted or the channel fills with repeats. A small file of seen IDs is enough to survive restarts:

import json
import os
import time
from pathlib import Path
import requests

BASE = "https://api.esportsodds.gg"
API_KEY = os.environ["ESPORTSODDS_API_KEY"]
WEBHOOK = os.environ["DISCORD_WEBHOOK_URL"]
SEEN_FILE = Path("posted_matches.json")

HEADERS = {"Authorization": f"Bearer {API_KEY}"}


def load_seen() -> set[str]:
    if SEEN_FILE.exists():
        return set(json.loads(SEEN_FILE.read_text()))
    return set()


def save_seen(seen: set[str]) -> None:
    SEEN_FILE.write_text(json.dumps(sorted(seen)))


def recent_results():
    resp = requests.get(
        f"{BASE}/v1/cs2/matches",
        params={"status": "completed", "limit": 50},
        headers=HEADERS, timeout=15,
    )
    resp.raise_for_status()
    return resp.json()["data"]

Formatting the score line

Because the matches list is enriched, you have everything you need for a readable line without a second call — team_a_name, team_b_name, score_a, score_b, and tournament_name are all on the row.

The posted message is a plain score line built from the list row's name and score fields, with the tournament as context — no logos or images.

def format_result(m: dict) -> str:
    a, b = m["team_a_name"], m["team_b_name"]
    sa, sb = m["score_a"], m["score_b"]
    winner = a if (sa or 0) > (sb or 0) else b
    return f"🏆 **{winner}** win — {a} {sa}{sb} {b}  ·  _{m['tournament_name']}_"

There are no team logos to attach (logo_url is always empty), so a text line with the tournament in italics is the clean, reliable format. It renders the same on every device and never shows a broken image.

Posting and running

Sending to Discord is a plain POST with a content field:

def post(text: str) -> None:
    r = requests.post(WEBHOOK, json={"content": text}, timeout=10)
    r.raise_for_status()


def tick(seen: set[str]) -> None:
    for m in recent_results():
        if m["id"] in seen:
            continue
        # Only announce matches that actually have a score.
        if m["score_a"] is None:
            continue
        post(format_result(m))
        seen.add(m["id"])
    save_seen(seen)


if __name__ == "__main__":
    seen = load_seen()
    while True:
        try:
            tick(seen)
        except requests.RequestException as e:
            print(f"transient error, will retry: {e}")
        time.sleep(300)  # poll every 5 minutes

Polling every five minutes is plenty for a results bot and keeps you comfortably within the request limits. If you want to run several bots or poll harder, wrap the API calls with the backoff logic from the rate-limits guide.

Next steps

Poll, dedupe, format, post. The whole bot is under a hundred lines, and the endpoint reference covers the filters you can add — by tournament, by team, or by date.