# WSBKDATA Public API

Version: `2026-06-02`

Base URL:

```text
https://api.wsbkdata.pro
```

Swagger:

```text
https://api.wsbkdata.pro/docs
```

## Authentication

All endpoints under `/api/motorsport/wsbk` and `/api/cue-sports` require an API key.

Use either header:

```http
X-API-Key: your_api_key
```

Or bearer token:

```http
Authorization: Bearer your_api_key
```

The API also accepts `api_key` as a query parameter for simple integration tests, but production clients should use headers.

Example:

```bash
curl -H "X-API-Key: your_api_key" \
  "https://api.wsbkdata.pro/api/cue-sports/tournaments?discipline=heyball&season=2026"
```

## Common Response Rules

- All responses are JSON unless the endpoint explicitly returns a binary asset.
- MongoDB `_id` values are serialized as string `id`.
- Dates are ISO 8601 strings when available.
- Missing upstream data may be returned as `null`, empty string, empty array, or omitted depending on source.
- `source`, `source_url`, and `scraped_at` identify where and when the data was collected.
- A `401` response means the API key is missing or invalid.
- A `404` response means the requested entity is not in the database.

## System

### Health

```http
GET /health
```

No API key required.

Response:

```json
{
  "status": "ok"
}
```

## WorldSBK API

Base path:

```text
/api/motorsport/wsbk
```

Supported class codes:

```text
SBK, SSP, SPB, YR3EC, WCR
```

For standings/results, currently supported class filters are:

```text
SBK, SSP, WCR
```

### Current Collection Counts

```http
GET /api/motorsport/wsbk/admin/counts
```

Response shape:

```json
{
  "series": "WorldSBK",
  "counts": {
    "raw_pages": 37,
    "assets": 182,
    "riders": 156,
    "teams": 103,
    "events": 12,
    "sessions": 279,
    "results": 21,
    "result_documents": 15,
    "standings": 73,
    "news": 0
  }
}
```

### Seasons

```http
GET /api/motorsport/wsbk/seasons
```

Response:

```json
{
  "series": "WorldSBK",
  "seasons": [2026]
}
```

### Calendar

```http
GET /api/motorsport/wsbk/{season}/calendar
```

Example:

```http
GET /api/motorsport/wsbk/2026/calendar
```

Response shape:

```json
{
  "series": "WorldSBK",
  "season": 2026,
  "calendar": [
    {
      "id": "mongo_id",
      "event_code": "AUS",
      "season": 2026,
      "round_number": 1,
      "round_name": "Australian Round",
      "circuit": "Phillip Island Grand Prix Circuit",
      "country": "AUS",
      "date_start": "2026-02-20T00:00:00",
      "date_end": "2026-02-22T00:00:00",
      "background_image_asset_id": "asset_id",
      "circuit_svg": "<svg>...</svg>",
      "destination_guide": {
        "intro": ["..."],
        "sections": [
          {
            "title": "Destination title",
            "description": "Destination text"
          }
        ]
      },
      "source_url": "https://www.worldsbk.com/en/calendar"
    }
  ]
}
```

### Event Sessions

Get sessions by internal event id:

```http
GET /api/motorsport/wsbk/events/{event_id}/sessions
```

Get sessions by season and event code:

```http
GET /api/motorsport/wsbk/{season}/events/{event_code}/sessions?class_code=SBK
```

Query parameters:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `class_code` | string | No | `SBK`, `SSP`, or `WCR` |

Response shape:

```json
{
  "series": "WorldSBK",
  "season": 2026,
  "event_code": "AUS",
  "class": "SBK",
  "sessions": [
    {
      "id": "mongo_id",
      "event_id": "event_mongo_id",
      "event_code": "AUS",
      "season": 2026,
      "class_code": "SBK",
      "type": "Race 1",
      "session_code": "RAC1",
      "date": "2026-02-21",
      "time_start": "12:00",
      "time_end": "12:40",
      "status": "Finished",
      "result_links": [
        {
          "label": "Results",
          "url": "https://www.worldsbk.com/..."
        }
      ]
    }
  ]
}
```

### Riders

```http
GET /api/motorsport/wsbk/{season}/riders?class_code=SBK
```

Query parameters:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `class_code` | string | No | `SBK`, `SSP`, `SPB`, `YR3EC`, or `WCR` |

Response shape:

```json
{
  "series": "WorldSBK",
  "season": 2026,
  "class": "SBK",
  "riders": [
    {
      "id": "mongo_id",
      "rider_id": "6331",
      "season": 2026,
      "class_code": "SBK",
      "number": 5,
      "name": "Yari Montella",
      "country": "ITA",
      "team": "Barni Spark Racing Team",
      "bike": "Panigale V4R",
      "image_asset_id": "asset_id",
      "profile_url": "https://www.worldsbk.com/en/riders/yari-montella/6331",
      "all_time_stats": {
        "races": 53,
        "poles": null,
        "wins": null,
        "podiums": 5,
        "second_positions": 1,
        "third_positions": 4,
        "best_race_lap": null
      },
      "stats_by_year": [
        {
          "year": 2026,
          "category": "WorldSBK",
          "position": null,
          "races": 17,
          "podiums": 5
        }
      ],
      "rider_story": {
        "paragraphs": ["..."]
      },
      "rider_bio": {
        "bike": "Panigale V4R",
        "date_of_birth": "5 January, 2000",
        "place_of_birth": "Oliveto Citra"
      },
      "teammates": [
        {
          "name": "Alvaro Bautista",
          "rider_id": "..."
        }
      ]
    }
  ]
}
```

### Teams

```http
GET /api/motorsport/wsbk/{season}/teams?class_code=SBK
```

Query parameters:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `class_code` | string | No | `SBK`, `SSP`, `SPB`, `YR3EC`, or `WCR` |

Response shape:

```json
{
  "series": "WorldSBK",
  "season": 2026,
  "class": "SBK",
  "teams": [
    {
      "id": "mongo_id",
      "season": 2026,
      "class_code": "SBK",
      "name": "Barni Spark Racing Team",
      "manufacturer": "Ducati",
      "bike": "Panigale V4R",
      "image_asset_id": "asset_id",
      "source_url": "https://www.worldsbk.com/en/teams/..."
    }
  ]
}
```

### Standings

```http
GET /api/motorsport/wsbk/{season}/standings?class_code=SBK&standing_type=riders
```

Query parameters:

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `class_code` | string | No | `SBK` | `SBK`, `SSP`, or `WCR` |
| `standing_type` | string | No | `riders` | `riders`, `teams`, or `manufacturers` |

Response shape:

```json
{
  "series": "WorldSBK",
  "season": 2026,
  "class": "SBK",
  "standing_type": "riders",
  "standings": [
    {
      "id": "mongo_id",
      "season": 2026,
      "class_code": "SBK",
      "standing_type": "riders",
      "position": 1,
      "rider_name": "Nicolo Bulega",
      "rider_number": 11,
      "rider_country": "ITA",
      "team": "Aruba.it Racing - Ducati",
      "manufacturer": "Ducati",
      "points": 335,
      "source_url": "https://www.worldsbk.com/en/standings/riders/2026/SBK",
      "scraped_at": "2026-06-02T00:00:00"
    }
  ]
}
```

### Results

```http
GET /api/motorsport/wsbk/{season}/results?class_code=SBK&event_code=AUS&session_code=RAC1
```

Query parameters:

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `class_code` | string | No | `SBK` | `SBK`, `SSP`, or `WCR` |
| `session_id` | string | No | | Internal session id |
| `event_code` | string | No | | Event code such as `AUS`, `ARA` |
| `session_code` | string | No | | Session code from the event schedule |

Response shape:

```json
{
  "series": "WorldSBK",
  "season": 2026,
  "class": "SBK",
  "event_code": "AUS",
  "session_code": "RAC1",
  "results": [
    {
      "id": "mongo_id",
      "season": 2026,
      "class_code": "SBK",
      "session_id": "session_mongo_id",
      "event_id": "event_mongo_id",
      "round_name": "Australian Round",
      "session": "Race 1",
      "position": 1,
      "rider_name": "Nicolo Bulega",
      "rider_number": 11,
      "rider_country": "ITA",
      "team": "Aruba.it Racing - Ducati",
      "manufacturer": "Ducati",
      "bike": "Ducati",
      "time_gap": "33'33.333",
      "laps": 22,
      "points": 25,
      "final": true
    }
  ]
}
```

### Session Results

```http
GET /api/motorsport/wsbk/sessions/{session_id}/results?season=2026&class_code=SBK
```

Query parameters:

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `season` | integer | Yes | | Season |
| `class_code` | string | No | `SBK` | `SBK`, `SSP`, or `WCR` |

### Result Documents

```http
GET /api/motorsport/wsbk/{season}/result-documents?class_code=SBK&event_code=AUS
```

Query parameters:

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `class_code` | string | No | `SBK`, `SSP`, or `WCR` |
| `event_code` | string | No | Event code |

Response shape:

```json
{
  "series": "WorldSBK",
  "season": 2026,
  "class": "SBK",
  "event_code": "AUS",
  "documents": [
    {
      "id": "mongo_id",
      "event_code": "AUS",
      "class_code": "SBK",
      "session_code": "RAC1",
      "document_code": "results",
      "label": "Results",
      "url": "https://www.worldsbk.com/...",
      "file": null
    }
  ]
}
```

### Assets

```http
GET /api/motorsport/wsbk/assets/{asset_id}
```

Returns binary image/SVG/content from MongoDB.

Headers:

```http
Cache-Control: public, max-age=31536000
X-Original-URL: https://www.worldsbk.com/...
```

Use cases:

- Rider profile image
- Team bike image
- Calendar event background
- Circuit SVG

## Cue Sports API

Base path:

```text
/api/cue-sports
```

Current supported disciplines:

```text
heyball, pool, snooker
```

Current major sources:

```text
cuescore, joy, wpa, epbf, cbsa
```

### Current Collection Counts

```http
GET /api/cue-sports/admin/counts
```

Response shape:

```json
{
  "module": "cue-sports",
  "counts": {
    "cue_sports": 3,
    "cue_tournaments": 31,
    "cue_players": 208,
    "cue_entries": 253,
    "cue_matches": 455,
    "cue_match_frames": 455,
    "cue_rankings": 8,
    "cue_ranking_rows": 247,
    "cue_brackets": 7,
    "cue_venues": 4,
    "cue_tables": 31,
    "cue_live_scores": 455,
    "cue_news_articles": 29,
    "cue_assets": 80
  }
}
```

### Disciplines

```http
GET /api/cue-sports/disciplines
```

Response shape:

```json
{
  "disciplines": [
    {
      "id": "mongo_id",
      "code": "heyball",
      "name": "Heyball",
      "aliases": ["Chinese 8-Ball", "Chinese Pool"]
    }
  ]
}
```

### Assets

```http
GET /api/cue-sports/assets/{asset_id}
```

Returns binary image/content from MongoDB `cue_assets`.

Headers:

```http
Cache-Control: public, max-age=31536000
X-Original-URL: https://img.cuescore.com/...
```

Current use cases:

- Player avatars downloaded from public `cue_players.avatar_url`
- Future cue sports media assets exposed by public sources

### Tournaments

```http
GET /api/cue-sports/tournaments?discipline=heyball&season=2026&source=cuescore&status=finished&limit=100
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `discipline` | string | No | | | `heyball`, `pool`, `snooker` |
| `season` | integer | No | | | Season/year |
| `source` | string | No | | | `cuescore`, `joy`, `wpa`, `epbf`, `cbsa` |
| `status` | string | No | | | Source status, for example `scheduled`, `live`, `finished` |
| `limit` | integer | No | `100` | `500` | Max rows |

Response shape:

```json
{
  "tournaments": [
    {
      "id": "mongo_id",
      "source": "cuescore",
      "source_tournament_id": "78608725",
      "source_url": "https://cuescore.com/tournament/78608725",
      "name": "2026 ASIAN HEYBALL OPEN",
      "discipline": "heyball",
      "season": 2026,
      "organizer": "CueScore",
      "venue": "Venue name",
      "city": "City",
      "country": "Country or ISO code",
      "start_at": "2026-01-01T00:00:00",
      "end_at": "2026-01-05T00:00:00",
      "participant_count": 64,
      "format": "single_elimination",
      "race_to": 7,
      "status": "finished",
      "raw": {}
    }
  ]
}
```

### Tournament Detail

```http
GET /api/cue-sports/tournaments/{tournament_id}
```

`tournament_id` can be either Mongo `id` or source tournament id where supported.

Response:

```json
{
  "tournament": {}
}
```

### Tournament Entries

```http
GET /api/cue-sports/tournaments/{tournament_id}/entries?limit=500
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `limit` | integer | No | `500` | `1000` | Max rows |

Response shape:

```json
{
  "tournament_id": "78608725",
  "entries": [
    {
      "id": "mongo_id",
      "source": "cuescore",
      "source_tournament_id": "78608725",
      "tournament_id": "mongo_tournament_id",
      "source_player_id": "player_external_id",
      "player_id": "mongo_player_id",
      "player_name": "Player Name",
      "country": "CHN",
      "seed": 1,
      "status": "active",
      "raw": {}
    }
  ]
}
```

### Tournament Matches

```http
GET /api/cue-sports/tournaments/{tournament_id}/matches?status=finished&limit=500
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `status` | string | No | | | Match status |
| `limit` | integer | No | `500` | `1000` | Max rows |

Response shape:

```json
{
  "tournament_id": "78608725",
  "matches": [
    {
      "id": "mongo_id",
      "source": "cuescore",
      "source_match_id": "external_match_id",
      "source_tournament_id": "78608725",
      "tournament_id": "mongo_tournament_id",
      "round_name": "Quarter Final",
      "stage_name": "Main",
      "match_no": "A001",
      "scheduled_at": "2026-06-02T12:00:00",
      "table_no": "T08",
      "player_a_id": "mongo_player_id_a",
      "player_b_id": "mongo_player_id_b",
      "player_a_name": "Player A",
      "player_b_name": "Player B",
      "score_a": 7,
      "score_b": 4,
      "race_to": 7,
      "winner_id": "mongo_player_id_a",
      "status": "finished",
      "live_url": "https://...",
      "vod_url": "https://...",
      "raw": {}
    }
  ]
}
```

### Match Search

```http
GET /api/cue-sports/matches?tournament_id=...&source_tournament_id=78608725&status=live&limit=200
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `tournament_id` | string | No | | | Mongo tournament id |
| `source_tournament_id` | string | No | | | Source tournament id |
| `status` | string | No | | | Match status |
| `limit` | integer | No | `200` | `1000` | Max rows |

Response:

```json
{
  "matches": []
}
```

### Match Frames

```http
GET /api/cue-sports/matches/{match_id}/frames?limit=200
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `limit` | integer | No | `200` | `500` | Max rows |

Response shape:

```json
{
  "match_id": "mongo_or_source_match_id",
  "frames": [
    {
      "id": "mongo_id",
      "source": "cuescore",
      "match_id": "mongo_match_id",
      "source_match_id": "external_match_id",
      "frame_number": 0,
      "score_a": 7,
      "score_b": 4,
      "winner_id": "mongo_player_id_a",
      "synthetic": true,
      "source_detail": "aggregate_only",
      "data_grain": "match_frame_aggregate",
      "sequence_available": false
    }
  ]
}
```

Important:

CueScore often does not expose true frame-by-frame sequences in the public HTML/API. In that case the API stores one aggregate row per match with:

```json
{
  "frame_number": 0,
  "synthetic": true,
  "source_detail": "aggregate_only",
  "sequence_available": false
}
```

This is not a fabricated frame sequence. It only preserves the match total score so downstream products can still show a frame summary.

### Tournament Bracket

```http
GET /api/cue-sports/tournaments/{tournament_id}/bracket
```

Response shape:

```json
{
  "tournament_id": "78608725",
  "bracket": {
    "id": "mongo_id",
    "source": "cuescore",
    "source_tournament_id": "78608725",
    "tournament_id": "mongo_tournament_id",
    "format": "single_elimination",
    "rounds": [
      {
        "name": "Quarter Final",
        "matches": [
          {
            "match_id": "mongo_match_id",
            "player_a_name": "Player A",
            "player_b_name": "Player B",
            "score_a": 7,
            "score_b": 4,
            "winner_name": "Player A"
          }
        ]
      }
    ],
    "raw": {}
  }
}
```

### Live Scores

```http
GET /api/cue-sports/live-scores?tournament_id=78608725&status=live&limit=200
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `tournament_id` | string | No | | | Mongo or source tournament id |
| `status` | string | No | | | Live status |
| `limit` | integer | No | `200` | `1000` | Max rows |

Response shape:

```json
{
  "live_scores": [
    {
      "id": "mongo_id",
      "source": "cuescore",
      "source_match_id": "external_match_id",
      "source_tournament_id": "78608725",
      "match_id": "mongo_match_id",
      "tournament_id": "mongo_tournament_id",
      "table_no": "T08",
      "player_a_name": "Player A",
      "player_b_name": "Player B",
      "score_a": 3,
      "score_b": 2,
      "race_to": 7,
      "status": "live",
      "updated_at": "2026-06-02T12:00:00"
    }
  ]
}
```

### Players

```http
GET /api/cue-sports/players?name=liu&country=CHN&source=cuescore&limit=100
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `name` | string | No | | | Case-insensitive name search |
| `country` | string | No | | | Country or country code |
| `source` | string | No | | | Source |
| `limit` | integer | No | `100` | `500` | Max rows |

Response shape:

```json
{
  "players": [
    {
      "id": "mongo_id",
      "source": "cuescore",
      "source_player_id": "external_player_id",
      "name": "Player Name",
      "first_name": "Player",
      "last_name": "Name",
      "country": "CHN",
      "gender": "male",
      "profile_url": "https://...",
      "image_url": null,
      "avatar_url": "https://img.cuescore.com/image/...",
      "avatar_asset_id": "cue_asset_id",
      "raw": {}
    }
  ]
}
```

### Player Detail

```http
GET /api/cue-sports/players/{player_id}
```

`player_id` can be Mongo `id` or source player id where supported.

Response:

```json
{
  "player": {}
}
```

### Player Match History

```http
GET /api/cue-sports/players/{player_id}/matches?limit=30
```

`player_id` can be Mongo `id` or source player id. This endpoint returns historical rows from `cue_matches` by matching player ids and player names.

Response:

```json
{
  "player_id": "mongo_or_source_player_id",
  "matches": [
    {
      "id": "mongo_match_id",
      "source": "cuescore",
      "source_match_id": "79768342",
      "player_a_name": "Player A",
      "player_b_name": "Player B",
      "score_a": 7,
      "score_b": 0,
      "round_name": "Round 1",
      "scheduled_at": "2026-04-08T07:51:11",
      "status": "finished"
    }
  ]
}
```

### Rankings

```http
GET /api/cue-sports/rankings?discipline=heyball&source=cuescore&ranking_name=Asian&limit=50
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `discipline` | string | No | | | `heyball`, `pool`, `snooker` |
| `source` | string | No | | | Ranking source |
| `ranking_name` | string | No | | | Case-insensitive ranking name search |
| `limit` | integer | No | `50` | `200` | Max rows |

Response shape:

```json
{
  "rankings": [
    {
      "id": "mongo_id",
      "source": "cuescore",
      "source_ranking_id": "cuescore-tournament-78608725-results",
      "ranking_name": "2026 ASIAN HEYBALL OPEN Results",
      "discipline": "heyball",
      "season": 2026,
      "source_tournament_id": "78608725",
      "source_url": "https://cuescore.com/tournament/78608725",
      "row_count": 64,
      "scraped_at": "2026-06-02T00:00:00"
    }
  ]
}
```

### Ranking Rows

```http
GET /api/cue-sports/rankings/{ranking_id}/rows?limit=200
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `limit` | integer | No | `200` | `1000` | Max rows |

Response shape:

```json
{
  "ranking_id": "cuescore-tournament-78608725-results",
  "rows": [
    {
      "id": "mongo_id",
      "ranking_id": "mongo_or_source_ranking_id",
      "source_ranking_id": "cuescore-tournament-78608725-results",
      "rank": 1,
      "player_id": "mongo_player_id",
      "source_player_id": "external_player_id",
      "player_name": "Player Name",
      "country": "CHN",
      "points": null,
      "matches_total": 8,
      "matches_won": 8,
      "matches_lost": 0,
      "frames_won": 56,
      "frames_lost": 21,
      "win_percentage": 100.0
    }
  ]
}
```

### News

```http
GET /api/cue-sports/news?source=joy&limit=50
```

Query parameters:

| Name | Type | Required | Default | Max | Description |
| --- | --- | --- | --- | --- | --- |
| `source` | string | No | | | Source |
| `limit` | integer | No | `50` | `200` | Max rows |

Response shape:

```json
{
  "articles": [
    {
      "id": "mongo_id",
      "source": "joy",
      "title": "Article title",
      "url": "https://...",
      "summary": "...",
      "published_at": "2026-06-02T00:00:00",
      "scraped_at": "2026-06-02T00:00:00"
    }
  ]
}
```

## Community API

These endpoints require the same API key as the cue sports endpoints.

### Feed

```http
GET /api/community/feed?limit=30
POST /api/community/posts
POST /api/community/posts/{post_id}/reactions
GET /api/community/posts/{post_id}/comments?limit=100
POST /api/community/posts/{post_id}/comments
```

Use these endpoints for the app's community feed, discussion posts, likes, and comments.

### Chat Messages

```http
GET /api/community/chat/messages?room=general&limit=100
POST /api/community/chat/messages
```

Post body:

```json
{
  "room": "general",
  "author": "Heyball Fan",
  "content": "Match discussion"
}
```

Response shape:

```json
{
  "room": "general",
  "messages": [
    {
      "id": "mongo_id",
      "room": "general",
      "author": "Heyball Fan",
      "content": "Match discussion",
      "created_at": "2026-06-02T05:30:28.331300Z"
    }
  ]
}
```

## Recommended Integration Flow

For a product that wants to show Heyball tournament data:

1. Call `/api/cue-sports/tournaments?discipline=heyball&limit=100`.
2. Pick a tournament `id` or `source_tournament_id`.
3. Call `/api/cue-sports/tournaments/{tournament_id}/entries`.
4. Call `/api/cue-sports/tournaments/{tournament_id}/matches`.
5. Call `/api/cue-sports/tournaments/{tournament_id}/bracket`.
6. For selected matches, call `/api/cue-sports/matches/{match_id}/frames`.
7. For active events, poll `/api/cue-sports/live-scores?tournament_id=...`.

For a product that wants to show WorldSBK:

1. Call `/api/motorsport/wsbk/seasons`.
2. Call `/api/motorsport/wsbk/{season}/calendar`.
3. Call `/api/motorsport/wsbk/{season}/riders?class_code=SBK`.
4. Call `/api/motorsport/wsbk/{season}/standings?class_code=SBK`.
5. Call `/api/motorsport/wsbk/{season}/results?event_code=AUS&class_code=SBK`.
6. Use `/api/motorsport/wsbk/assets/{asset_id}` for locally stored images/SVGs.

## Data Freshness Notes

- WorldSBK standings are intended to be refreshed daily.
- WorldSBK event/session data is more stable after each round is completed.
- CueScore match/live data depends on public source availability and should be treated as mutable.
- Historical metadata such as riders, teams, venues, destination guides, and downloaded assets should be treated as more stable.
- CueScore player avatars exposed through public `avatar_url` are downloaded into `cue_assets` and referenced by `cue_players.avatar_asset_id`.
