UTR Search Tennis Players
Purpose
Return one or more tennis players matching a name query on app.utrsports.net (Universal Tennis Rating), with each player's UTR (singles + doubles), three-month rating, profile id, gender, nationality, location, pro status, third-party rankings (ATP/WTA pro rank, country rank), and profile image path. Read-only — never claims, edits, or messages a profile. Designed as the "lookup a player and grab their rating" primitive other agents can chain (e.g. before fetching match history, college roster, or event entry lists).
When to Use
- An agent or user asks for a player's UTR by name ("what's Carlos Alcaraz's UTR?").
- Bulk-rating a list of player names (recruiting, fantasy, bracket seeding).
- Disambiguating a common name (multiple "Roger Federer" accounts exist — the API returns location, nationality, age range, and
isProto pick the right one). - Feeding downstream calls that need a UTR
playerId(the profile/results endpoints all key off it). - Anywhere you'd otherwise scrape
app.utrsports.net/search— the public REST API is one HTTP GET and skips the SPA entirely.
Workflow
app.utrsports.net is a thin React SPA over a public REST API at https://api.utrsports.net. The search box on the site UI fires GET /v2/search/players?query=... against that API and renders the JSON — there is no anti-bot, no auth, no captcha, and no rate-limit headers on read-only search/profile reads from a clean residential IP. Always use the API. The browser path costs ~50× more turns (snapshot returns mostly empty until React hydrates, and result cards lazy-load) and yields a strict subset of the data.
Step 1 — Search by name (one HTTP GET, no auth)
GET https://api.utrsports.net/v2/search/players
?query={name}
&top={pageSize, default 10, max observed 50}
&skip={offset, default 0}
[&gender=M|F]
[&utrMin={float}&utrMax={float}]
[&ageMin={int}&ageMax={int}]
[&searchOrigin=searchPage]
URL-encode query (spaces → + or %20). No headers required — but if calls start 429-ing, send Origin: https://app.utrsports.net and Referer: https://app.utrsports.net/ to mimic the SPA.
The response shape:
{
"total": 184,
"totalAllowed": 10000,
"maxScore": 9964.6,
"aggregations": {},
"hits": [
{
"id": "3569175",
"score": 9777.84,
"index": "prod_players-v1",
"source": { /* player record — see Step 2 */ }
}
]
}
total is the unpaginated match count; hits.length is what was returned this page. Paginate with skip until skip >= total or until you've returned enough matches for the disambiguation task.
Step 2 — Pull the fields you need from hits[].source
Each hit's source object holds everything the search-results card renders. The fields that matter for almost every downstream task:
| Field | Type | Notes |
|---|---|---|
id | int | The canonical playerId. Same as hits[].id (the outer is a string copy). Use this for follow-up endpoints. |
profileId | int | Separate profile-pages id. Some endpoints use this, some use id. When in doubt, try id first. |
displayName | string | "Carlos Alcaraz". Already normalized — don't compose from firstName+lastName (the order varies by locale). |
singlesUtr, doublesUtr | float | The verified UTR. See "Masking" gotcha below — non-pros are returned as integers (e.g. 6.0). |
singlesUtrDisplay, doublesUtrDisplay | string | What the website renders (e.g. "16.23" for pros, "6.xx" when the actual decimals are paywalled). Prefer this for user-facing display. |
threeMonthRating | float | 90-day rolling UTR. This field leaks the unrounded decimal even for non-pros (e.g. 6.21 for a player whose singlesUtr shows 6.0). |
threeMonthRatingChangeDetails | object | {rating, ratingDisplay, ratingDifference, changeDirection: "up"|"down"|"flat"}. Use for trend arrows. |
ratingStatusSingles | enum | "Rated", "Unrated", "Projected". Players with "Unrated" have no matches in the system yet. |
ratingProgressSingles | float | 0-100. Reliability — 100 = fully rated, lower = projected. |
gender | enum | "Male", "Female". |
nationality | string | 3-letter IOC code ("USA", "ESP", "CHN"). |
location.display | string | "Plano, TX", "Spain", "Hong Kong" — already-formatted. Other location subfields (cityName, countryName, etc.) are often null. |
isPro | bool | True for ATP/WTA-level pros. Required signal — pros are the only players whose singlesUtr is returned unmasked. |
showDecimals | bool | Mirrors isPro in practice — true means the float is real, false means it's been rounded to the integer. |
ageRange | string | "14-18", "19-22", etc. (Exact age is null for most accounts — privacy.) |
rankings | array | Third-party + UTR power-rank entries. [{rankListId, rank, rankingCategories: [...]}, ...]. rankListId: 46 = global pro singles; categories carry gender / location / division / age tags. |
thirdPartyRankings | array | ATP/WTA/ITF ranks if the profile is linked. Usually [] for non-pros. |
profileImage | string | Relative path like "747083/images/profile/{uuid}.png". Prepend https://utrprodusrwest.blob.core.windows.net/avatars/ to render (verify via SPA network trace in your locale — CDN host changes by region). |
clubMemberships | array | [{id, clubId, name, roleId}]. Useful for "find players at club X" follow-ups. |
Step 3 — (Optional) Full profile by id
If you need bio, residence, racket brand, banner image, college affiliation, or a slightly fresher rating than the search index, follow up with the unauthenticated v1 profile endpoint:
GET https://api.utrsports.net/v1/player/{id}/profile
Returns ~50 fields including singlesUtr, doublesUtr, threeMonthRating, description, playerBio, residence, locationNationality, racketBrand/racketType, apparelBrand, shoesBrand/shoesType, college, gradYearCollege, gradYearHighSchool, atpOrWtaRank, photoCount, totalResults, resultCountsSingles. Note: ratings here can be ~0.01 fresher than the search index (e.g. Alcaraz: search 16.23 vs profile 16.24) — the search index is rebuilt on a slower cadence.
The non-/profile paths require a bearer token:
GET /v1/player/{id}→400 Token is missing.GET /v2/player/{id}→400 Token is missing.GET /v2/player/{id}/profile→400 Token is missing.
So only /v1/player/{id}/profile is open. Do not waste cycles trying the v2 variants.
Step 4 — Disambiguate
For common names, expect many hits with score ranging from ~9000 (exact name match) to ~10 (fuzzy / partial match). The score is large because the API uses Elasticsearch BM25 — pick the top hit only if score > 8000 AND displayName exactly matches the query (case-insensitive). Otherwise return the top-N for the caller to disambiguate using nationality, location.display, ageRange, isPro, and rankings. Real ATP/WTA pros surface near the top because their hits get boosted by the isPro field weighting.
Browser fallback
Only relevant if api.utrsports.net is regionally unreachable from your egress (none observed during 5 test calls — but Verified + residential proxy BR-AS9080 blocks have been reported on tennis sites generally).
browse cloud sessions create --verified --proxies(UTR's web tier is fronted by Azure Front Door — bare egress is fine for read-only, but enable Verified before any login flow).browse cloud browse --connect $sid newpage https://app.utrsports.net/search?query={urlencode(name)}&type=player.browse cloud browse --connect $sid wait selector "[data-testid=player-row], a[href*='/profile/']"— the result cards render only after the SPA hydrates and fires the search XHR.browse cloud browse --connect $sid snapshotand harvest each card'sdisplayName, displayed UTR, location, country flag alt-text, and the/profile/{id}href.
The browser path will only give you the visible (masked) rating for non-pros — the unmasked values are not in the DOM, they're filtered server-side before render. So even when falling back to the browser, the resulting data is identical to or worse than the API.
Site-Specific Gotchas
- Rating masking is server-side, not client-side. Both the
/v2/search/playersresponse and the rendered card showsinglesUtrDisplay: "6.xx"for non-pros. The actual decimal is never returned over the wire.threeMonthRatingis the only float field that leaks the unrounded value (and only because UTR uses it for the trend-arrow change calculation). If a user needs decimal precision for a non-pro, that data does not exist outside an authenticated session belonging to the player or their coach. showDecimalsis the authoritative flag for "can I trust the float?" — notisPro. They almost always agree, butshowDecimalsis what the SPA reads. TreatsinglesUtras integer-only whenevershowDecimals: false.displayNameis "FirstName LastName" in some locales and "LastName FirstName" in others. Most US profiles render"Carlos Alcaraz", but Chinese, Brazilian, and some Hong Kong accounts render"Federer Chan"style with surname first. TheplayerFirstName/playerLastNamefields are not consistently ordered either — the source of truth isdisplayName. Don't try to canonicalize.idvsprofileId. The search response'ssource.id(alsohits[].id) is the player id and is what every public endpoint (/v1/player/{id}/profile,/v1/player/{id}/results/...) consumes.profileIdis a separate internal key used for the public profile page URL slugapp.utrsports.net/profiles/{profileId}— confusingly, the same SPA route also accepts the player id and silently redirects. Always useidfor API calls and treatprofileIdas a display-only opaque value.- Empty
queryreturns the index sort order. A blankquery=doesn't error — it returns players sorted by an internal Elasticsearch_score. Likely-useless results for a search task. Validate the query is non-empty before calling. totalis capped attotalAllowed: 10000. Wide queries (single common letters, popular surnames) reporttotal: 10000even if there are more matches. To page deeper, narrow the query withgender=,utrMin=, orageMin=filters —skip > 10000returns an emptyhitsarray.- The unified
/v2/searchendpoint searches every index at once — players, events, virtualEvents, clubs, colleges, highSchools — and returns each as a separate top-level key (e.g.response.players.hits[],response.events.hits[]). Useful when a query string is ambiguous ("stanford" hits a college + several players + a club). Each sub-result has the same{hits, total, totalAllowed}shape as the per-index endpoint. - Sibling search indices use identical shape:
/v2/search/events,/v2/search/clubs,/v2/search/colleges,/v2/search/highSchools. Samequery/top/skipparams. Useful if the user asks for a tournament or a college team instead of a player. v1is mostly retired./v1/search/playersand/v1/search/*return410 endpoint_gonewith body{"error":{"code":"endpoint_gone","replacement":"/v2/search"}}. The one exception is/v1/player/{id}/profilewhich is unauthenticated and current — do not try/v2/player/{id}/profile(returns 400 "Token is missing"). Do not waste cycles probing/v1for anything else.- Azure Front Door backs the API. Responses include
X-Azure-Ref,Strict-Transport-Security: max-age=31536000, and aTiPMixcookie. None of these affect access. The infra hostnameprod-utr-api-eastus-platform-azapp.azurewebsites.netis the origin — don't call it directly; routing rules at the Front Door (e.g./v1/player/{id}auth check) are enforced at the edge. - The web SPA at
https://app.utrsports.net/returns near-empty HTML before JS hydration. Plaincurlorbrowse cloud fetchon the SPA URL is a dead end for content extraction — the entire DOM is React-rendered at runtime from the same API call you can make directly. /v2/search/players?query=federerreturnstotal: 71, butquery=roger+federerreturnstotal: 5795— two-token queries are OR'd internally and fan out much wider. Pre-tokenize and choose your query string carefully if total count matters.- No anti-bot or captcha observed across 12 test calls (search + profile, range of queries). No
--proxies, no--verified, noUser-Agentspoofing needed for read-only search/profile. The Browserbase Fetch API was used from us-west-2 egress with no blocks.
Expected Output
Return one of these shapes:
Single best match (high-confidence pro lookup)
{
"result": "match",
"player": {
"id": 3569175,
"displayName": "Carlos Alcaraz",
"gender": "Male",
"nationality": "ESP",
"location": "Spain",
"isPro": true,
"showDecimals": true,
"singlesUtr": 16.23,
"singlesUtrDisplay": "16.23",
"doublesUtr": 15.25,
"doublesUtrDisplay": "15.25",
"threeMonthRating": 16.12,
"ratingChange": { "difference": 0.0, "direction": "flat" },
"ratingStatusSingles": "Rated",
"ratingProgressSingles": 100.0,
"ageRange": null,
"atpOrWtaRank": false,
"rankings": [
{ "list": "pro-male-global", "rank": 2 }
]
},
"queryEcho": "alcaraz",
"totalMatches": 184,
"searchScore": 9777.84
}
Multiple matches (caller must disambiguate)
{
"result": "ambiguous",
"queryEcho": "roger federer",
"totalMatches": 5795,
"candidates": [
{
"id": 3100657,
"displayName": "Roger Federer",
"nationality": "AUS",
"location": "Perth, Australia",
"ageRange": "30+",
"isPro": false,
"singlesUtrDisplay": "0.xx",
"ratingStatusSingles": "Unrated",
"score": 9650.12
},
{
"id": 2616620,
"displayName": "Roger Federer",
"nationality": "SUI",
"location": "Switzerland",
"ageRange": null,
"isPro": false,
"singlesUtrDisplay": "0.xx",
"ratingStatusSingles": "Unrated",
"score": 9601.34
}
]
}
Non-pro rated player (rating masked)
{
"result": "match",
"player": {
"id": 5732573,
"displayName": "Federer Chan",
"gender": "Male",
"nationality": "USA",
"location": "Plano, TX",
"isPro": false,
"showDecimals": false,
"singlesUtr": 6.0,
"singlesUtrDisplay": "6.xx",
"doublesUtr": 6.0,
"doublesUtrDisplay": "6.xx",
"threeMonthRating": 6.21,
"ratingStatusSingles": "Rated",
"ratingProgressSingles": 100.0,
"ageRange": "14-18"
},
"queryEcho": "federer chan",
"totalMatches": 1,
"searchScore": 9777.84,
"note": "singlesUtr/doublesUtr are integer-rounded because showDecimals is false. threeMonthRating (6.21) preserves the unrounded value."
}
No hits
{
"result": "not_found",
"queryEcho": "qwx zzqxxq",
"totalMatches": 0,
"candidates": []
}