Esports Data API: Why Your Data Breaks and How Canonical IDs Fix It
Updated · Published
Quick answer: An esports data API breaks your app when the same match or team arrives under different names and in a different team order, so joins return nothing and duplicates appear. Canonical IDs fix this by giving each match, team and player one permanent ID, such as kr_match_id and kr_tm_*, with every alternative spelling resolved to it.
| Symptom | Cause in a raw feed | Canonical fix | Real field |
|---|---|---|---|
| Match has no odds | Team spelled two ways | Join on the ID, keep the alias | kr_match_id, team1_alias |
| Same match twice | a vs b and b vs a | Reversed order resolves to one match | kr_match_id |
| Team shows as two teams | Case, suffix or sponsor changes | One team ID, aliases attached | kr_tm_* |
| Finished match looks live | Feed never flips status | Explicit final state | status, end_date |
The join that fails without an error
The worst esports data bugs do not crash. They return zero rows. You join a fixture feed to an odds feed on the team name, one source says Team Liquid and the other says Liquid, and the match simply has no odds in your app. No exception, no log line. This is the whole problem in six lines of Python, using a real match:
Python (runs as written)
# Two feeds, one real match. Each spells the team its own way.
fixtures = {"Team Liquid": "match-row-1"} # fixture feed
odds = [{"team": "Liquid", "price": -350}] # odds feed
joined = [o for o in odds if o["team"] in fixtures]
print(len(joined)) # 0 -- the match has no odds, and nothing raised an errorYou can patch this with a name-cleaning function, and then patch it again for the next spelling. Every patch is a bet that you have seen all the spellings. You have not. A feed can rename a team at any time, and sponsors change names mid-season.
What the same team looks like across a week
This is not a rare edge case. These are real canonical names and the alias KashRock holds next to each, taken from CS2 matches finished on 27 and 30 September 2026:
| Canonical name | Alias |
|---|---|
| Team Liquid | Liquid |
| Yawara Esports | Yawara |
| Imperial Esports | Imperial |
| paiN Gaming | paiN |
| KUUSAMO.gg | KUUSAMO |
| Bestia Academy | BESTIA Academy |
| Metanoia Wolves | METANOIA Wolves |
| Alka | ALKA |
Some differences are a dropped word. Others are only capitalization. A string comparison treats all of them as different teams, and a human sees the same one. Here is how the API returns it, with both names on the match:
{
"kr_match_id": "kr_cs2_liquid-vs-wildcard-gaming-27-09-2026",
"team1": "Team Liquid",
"team1_alias": "Liquid",
"team2": "Wildcard Gaming",
"team2_alias": "Wildcard"
}When a team's source spelling already matches the canonical name, the alias is null. You only see an alias when there is one to see.
Why cleaning names never finishes
The usual first fix is to lowercase both sides and compare. It is worth measuring how far that gets you. Run the eight pairs from the table above through it:
Python (runs as written)
pairs = [
("Team Liquid", "Liquid"),
("Yawara Esports", "Yawara"),
("Imperial Esports", "Imperial"),
("paiN Gaming", "paiN"),
("KUUSAMO.gg", "KUUSAMO"),
("Bestia Academy", "BESTIA Academy"),
("Metanoia Wolves", "METANOIA Wolves"),
("Alka", "ALKA"),
]
fixed = [(a, b) for a, b in pairs if a.lower() == b.lower()]
print(len(fixed), "of", len(pairs), "match after lowercasing") # 3 of 8Lowercasing fixes 3 of the 8 pairs: Alka, Bestia Academy and Metanoia Wolves. The other 5 differ by a dropped word or suffix: Team, Esports (twice), Gaming or .gg. Each of those needs its own rule, and a rule written for one team can merge two different ones. Every new rule is a new way to be wrong, and none of them can tell you a team is missing.
An alias table maintained once, behind the ID, moves that work out of your codebase. You read team1_alias when you want to show a familiar name, and you never compare names to decide what is the same match.
The fix: join on the ID, display the name
A canonical ID separates two jobs that raw feeds mix together: identifying a match and describing it. The ID identifies. The name is for display. Once both feeds carry the same kr_match_id, the join that returned zero rows returns one:
Python (runs as written)
# Same two feeds, joined on the permanent match ID instead.
MATCH = "kr_cs2_liquid-vs-wildcard-gaming-27-09-2026"
fixtures = {MATCH: "match-row-1"}
odds = [{"kr_match_id": MATCH, "price": -350}]
joined = [o for o in odds if o["kr_match_id"] in fixtures]
print(len(joined)) # 1KashRock uses one namespace for everything you join on. A match is kr_match_id, in the format kr_{sport}_{team-a}-vs-{team-b}-DD-MM-YYYY. A team is kr_tm_*. A player is kr_pl_*. A prop is kr_prop_*. Source-specific IDs are not part of what you store or join on.
Reversed fixtures resolve to the same match
The second way data breaks is order. One source lists a vs b, another lists b vs a. If your system keys on the pair, you now have two matches, two sets of odds and two results. This is the canonical match for one real fixture. Reveal is team 1 in the response, but the ID and slug list Huskies first:
{
"kr_match_id": "kr_cs2_huskies-esport-vs-reveal-30-09-2026",
"match_slug": "huskies-esport-vs-reveal-30-09-2026",
"status": "finished",
"start_date": "2026-09-30T16:20:00.000+00:00",
"end_date": "2026-09-30T17:54:16.000+00:00",
"team1": {
"name": "Reveal",
"id": "kr_tm_81428365c10b",
"slug": "reveal",
"score": 2
},
"team2": {
"name": "Huskies eSport",
"id": "kr_tm_c6d077861786",
"slug": "huskies-esport",
"score": 0
},
"maps": [
{
"map_number": 1,
"map_name": "Inferno",
"winner": "Reveal",
"winner_score": 13,
"loser_score": 9
},
{
"map_number": 2,
"map_name": "Dust2",
"winner": "Reveal",
"winner_score": 13,
"loser_score": 4
}
]
}The ID is permanent, so the order a source uses does not change it. We requested this match three ways on 30 September 2026 and got the same kr_match_id back each time:
| Request | Returned kr_match_id |
|---|---|
/matches/id/kr_cs2_reveal-vs-huskies-esport-30-09-2026 | kr_cs2_huskies-esport-vs-reveal-30-09-2026 |
/matches/reveal-vs-huskies-esport-30-09-2026/boxscore | kr_cs2_huskies-esport-vs-reveal-30-09-2026 |
/matches/huskies-esport-vs-reveal-30-09-2026/boxscore | kr_cs2_huskies-esport-vs-reveal-30-09-2026 |
Here is that check as runnable code. It requests the match in both orders and asserts that one ID comes back:
Python
import os
import requests
BASE = "https://kashrock.up.railway.app/v6/esports/cs2"
HEADERS = {"X-API-Key": os.environ["KASHROCK_API_KEY"]}
def match(ref: str) -> dict:
r = requests.get(f"{BASE}/matches/{ref}/boxscore", headers=HEADERS, timeout=10)
r.raise_for_status()
return r.json()
a = match("reveal-vs-huskies-esport-30-09-2026")
b = match("huskies-esport-vs-reveal-30-09-2026")
assert a["kr_match_id"] == b["kr_match_id"]
print(a["kr_match_id"], a["status"], a["team1"]["score"], a["team2"]["score"])Captured live on 2026-09-30 and trimmed for length. Fields are unchanged; unrelated fields are removed.
Finished means finished
The third failure is state. A match that ended an hour ago should not look upcoming. The response above shows the shape of a settled match: status is finished, end_date is set, each team has a series score and each map has a winner and a score. Your app reads the state instead of inferring it from the clock.
Three checks to run on any feed today
You can find out how exposed your app is without changing any code. Pick five finished matches and check your data for these:
- Count matches per fixture. More than one row for the same two teams on the same day means reversed order or a renamed team is creating duplicates.
- Count fixtures with no odds. Any match that your odds source covers but your app shows empty is a failed name join.
- List matches older than a few hours with a status other than final. Each one is a feed that never flipped its state.
If all three counts are zero, your feeds already agree. If they are not, the fix is the same each time: stop comparing text and compare a permanent ID.
How to migrate an existing app
- Add a
kr_match_idcolumn to your matches table and fill it from the API. - Move every join from team names to
kr_match_idandkr_tm_*. - Keep the canonical name and alias only for display and search.
- Delete your name-cleaning code once the joins no longer use it.
- For lines and props, read the scope fields before comparing. See the Esports Odds API and how market scope is labelled.
The full picture, including lines, props and history, is in the Esports API developer guide. The product page for this schema is the Esports Data API, and How It Works explains where the data comes from. Start with the Quickstart.
FAQ
What is a canonical ID in an esports data API?
A canonical ID is one permanent identifier for a match, team or player that stays the same across every source. KashRock uses kr_match_id, kr_tm_* and kr_pl_*.
Why do esports joins fail when team names differ?
Different sources spell the same team differently, such as Team Liquid and Liquid. A join on the name treats them as two teams and returns no rows, without raising an error.
Does the order of the teams change the match ID?
No. A kr_match_id is permanent. Requesting a fixture as reveal-vs-huskies or huskies-esport-vs-reveal returned the same ID in our test.
What is the team alias field?
It holds the alternative spelling of a team name, for example Liquid for Team Liquid. It is null when the source spelling already matches the canonical name.
How do I know a match has finished?
Read status, which is finished, and end_date. A finished match also carries the series score and the result of every map.
Written by
Javieon, Founder of KashRock
Javieon founded KashRock, the esports data API that gives every match, player, team and prop one permanent ID.