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.

SymptomCause in a raw feedCanonical fixReal field
Match has no oddsTeam spelled two waysJoin on the ID, keep the aliaskr_match_id, team1_alias
Same match twicea vs b and b vs aReversed order resolves to one matchkr_match_id
Team shows as two teamsCase, suffix or sponsor changesOne team ID, aliases attachedkr_tm_*
Finished match looks liveFeed never flips statusExplicit final statestatus, 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 error

You 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 nameAlias
Team LiquidLiquid
Yawara EsportsYawara
Imperial EsportsImperial
paiN GamingpaiN
KUUSAMO.ggKUUSAMO
Bestia AcademyBESTIA Academy
Metanoia WolvesMETANOIA Wolves
AlkaALKA

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:

GET /v6/esports/cs2/matches/id/kr_cs2_liquid-vs-wildcard-gaming-27-09-2026
{
  "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 8

Lowercasing 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))  # 1

KashRock 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:

GET /v6/esports/cs2/matches/reveal-vs-huskies-esport-30-09-2026/boxscore
{
  "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:

RequestReturned kr_match_id
/matches/id/kr_cs2_reveal-vs-huskies-esport-30-09-2026kr_cs2_huskies-esport-vs-reveal-30-09-2026
/matches/reveal-vs-huskies-esport-30-09-2026/boxscorekr_cs2_huskies-esport-vs-reveal-30-09-2026
/matches/huskies-esport-vs-reveal-30-09-2026/boxscorekr_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_id column to your matches table and fill it from the API.
  • Move every join from team names to kr_match_id and kr_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.