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
*_namefields the list already gives you, remember which match IDs you've posted so nothing double-posts, andPOSTto 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.
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.
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.
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
- Stream updates over WebSocket — get pinged the moment a match finishes instead of polling on a timer.
- Post match previews too — announce upcoming games with head-to-head and form, not just results.
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.