mk8u.de

API documentation

Download as Markdown

Mario Kart 8 Logger - API Documentation

Describes server version 0.39 (see package.json). Changes to the site are announced on the news page (/changelog).

Base URL: https://mk8u.de/api (self-hosted: http://your-server:3000/api)

Concepts

  • One person, one entry: An account owning several Pretendo profiles is counted once in the player list, on the player page and in the leaderboards, with its numbers added up. See Players.
  • Console-aware VR uploads: For an account with several Pretendo profiles, the console serial sent by the Aroma plugin decides which profile an upload is recorded for. The mapping is learned from the VR history and can be corrected by the user. The sync reads follow the same profile. See Console Assignments.
  • Multiple Pretendo Profiles: An account can own several Pretendo player IDs, so switching to a different Pretendo profile no longer hides the lobbies, VR points and time trial times recorded under the old one. Adding a second profile is an admin-controlled per-user permission (multiPretendoLink). See Pretendo Profiles.
  • VR Points System: Track and display player VR ratings for VS Race and Battle modes
    • Public read access to VR points data
    • Authenticated write access for updating VR points
  • Guest Players: Full support for local co-op guest players
    • Guest player management and linking to main player accounts
    • Comprehensive nickname history combining Pretendo API and guest names
  • Player search also finds historical nicknames from both sources
  • Lobbies and races: a lobby (gathering) comes from Pretendo's server; the single races in it (race results) only exist when someone in the lobby races with a current MK8Logger plugin. See Race Results Upload.

Table of Contents


Authentication

Some endpoints require authentication via session cookies. To authenticate:

  1. Login: POST to /auth/login with credentials
  2. Session: The server returns a session cookie
  3. Authenticated Requests: Include the session cookie in subsequent requests

API Auth Status

Check API status including database and poller health.

Endpoint: GET /api/auth/status

Authentication: Not required

Response (Success):

{
  "status": "ok",
  "timestamp": "2025-01-10T12:00:00.000Z",
  "database": "connected",
  "poller": {
    "isRunning": true,
    "pollCount": 1500,
    "errorCount": 0
  }
}

Response (Error):

{
  "status": "error",
  "timestamp": "2025-01-10T12:00:00.000Z",
  "database": "disconnected",
  "error": "Connection failed"
}

API Login

Login endpoint for API clients (returns JSON instead of redirecting).

Endpoint: POST /api/auth/login

Authentication: Not required

Request Body:

{
  "username": "your_username",
  "password": "your_password"
}

Response (Success):

{
  "success": true,
  "message": "Login successful",
  "user": {
    "id": 1,
    "username": "your_username",
    "role": "user"
  }
}

Response (Error):

{
  "success": false,
  "message": "Invalid username or password"
}

Web Login

Endpoint: POST /auth/login

Request Body:

{
  "username": "your_username",
  "password": "your_password"
}

Response (Success):

{
  "success": true,
  "user": {
    "id": 1,
    "username": "your_username",
    "email": "user@example.com",
    "role": "user"
  }
}

Response (Error):

{
  "error": "Invalid credentials"
}

Logout

Endpoint: POST /auth/logout

Response:

{
  "success": true
}

Check Authentication Status

Endpoint: GET /auth/status

Response (Authenticated):

{
  "authenticated": true,
  "user": {
    "id": 1,
    "username": "your_username",
    "role": "user"
  }
}

Response (Not Authenticated):

{
  "authenticated": false
}

Races

Get Active Races

Get all currently active races.

Endpoint: GET /api/races/active

Authentication: Not required

Response:

{
  "races": [
    {
      "id": 1,
      "gid": 123456,
      "gameMode": 0,
      "gameModeName": "VS Race",
      "region": "Europe",
      "regionCode": 3,
      "dlcCode": 0,
      "dlcName": "No DLC",
      "isActive": true,
      "firstSeen": "2025-01-10T10:30:00.000Z",
      "lastSeen": "2025-01-10T11:00:00.000Z",
      "racePlayers": [
        {
          "id": 1,
          "playerId": 5,
          "pid": "abc123",
          "miiName": "Player1",
          "isHost": true,
          "isOwner": false,
          "isActive": true,
          "joinedAt": "2025-01-10T10:30:00.000Z",
          "Player": {
            "id": 5,
            "pid": "abc123",
            "miiName": "Player1",
            "totalRaces": 150,
            "totalSessions": 200
          }
        }
      ]
    }
  ]
}

Search Races

Search races with various filters.

Endpoint: GET /api/races/search

Authentication: Not required

Query Parameters:

  • gid (optional): Game ID
  • gameMode (optional): Game mode (0=VS Race, 1=Battle, 2=Tournament, 3=Friend Room)
  • region (optional): Region name (Worldwide, Japan, America, Europe, Korea, China, Taiwan)
  • dlc (optional): DLC code (0=No DLC, 1=DLC 1, 2=DLC 2, 3=DLC 1+2)
  • raceType (optional): Filter by race type ('real' for 2+ players, 'lobby' for 0-1 players)
  • from (optional): Start date (ISO format)
  • to (optional): End date (ISO format)
  • pids (optional): NEW - Comma-separated list of player PIDs to filter races by players
  • concurrent (optional): NEW - Boolean string ('true'/'false'). When 'true' and 2+ PIDs are provided, only returns races where all specified players had overlapping sessions
  • limit (optional): Results per page (default: 50)
  • offset (optional): Pagination offset (default: 0)
  • orderColumn (optional): Column to sort by (default: firstSeen)
  • orderDir (optional): Sort direction (ASC/DESC, default: DESC)

Important Notes:

  • Without parameters, this endpoint returns only the first 50 races (newest first, sorted by firstSeen DESC)
  • The total field shows the total number of races in the database
  • To retrieve all races, either:
    • Set a high limit value (e.g., limit=999999)
    • Use pagination by incrementing offset (recommended for large datasets)

Player Filtering:

  • Without concurrent: Returns races where at least one of the specified players participated
  • With concurrent=true: Returns only races where all specified players had overlapping sessions (they were racing at the same time)
  • The concurrent parameter requires at least 2 player PIDs
  • Player filtering works in combination with all other filters (gameMode, region, raceType, etc.)

Example Requests:

# Get first 50 races (default)
GET /api/races/search

# Get all races (not recommended for large databases)
GET /api/races/search?limit=999999

# Get races with pagination (recommended)
GET /api/races/search?limit=100&offset=0    # First 100 races
GET /api/races/search?limit=100&offset=100  # Next 100 races
GET /api/races/search?limit=100&offset=200  # Next 100 races

# Filter by game mode and region
GET /api/races/search?gameMode=0&region=Europe&limit=20&offset=0

# Filter by race type (real races only)
GET /api/races/search?raceType=real

# Filter by players (races where ANY of these players participated)
GET /api/races/search?pids=abc123,xyz789&limit=50

# Filter by concurrent players (races where ALL players had overlapping sessions)
GET /api/races/search?pids=abc123,xyz789&concurrent=true&limit=50

# Combined filters (real races with specific players in Europe)
GET /api/races/search?pids=abc123,xyz789&gameMode=0&region=Europe&raceType=real

Response:

{
  "races": [
    {
      "id": 1,
      "gid": 123456,
      "gameMode": 0,
      "gameModeName": "VS Race",
      "region": "Europe",
      "dlcName": "No DLC",
      "isActive": false,
      "firstSeen": "2025-01-10T10:30:00.000Z",
      "lastSeen": "2025-01-10T11:00:00.000Z",
      "playerCount": 8,
      "uniquePlayers": 6,
      "raceDuration": 1800000
    }
  ],
  "total": 1500,
  "limit": 50,
  "offset": 0
}

Search Races with Concurrent Players

Find races where multiple specified players were racing at the same time (overlapping sessions).

Endpoint: GET /api/races/search-concurrent-players

Authentication: Not required

Query Parameters:

  • pids (required): Comma-separated list of player PIDs
  • limit (optional): Results per page (default: 50)
  • offset (optional): Pagination offset (default: 0)
  • orderColumn (optional): Column to sort by (default: firstSeen)
  • orderDir (optional): Sort direction (ASC/DESC, default: DESC)

Example Request:

GET /api/races/search-concurrent-players?pids=abc123,xyz789&limit=20

Response:

{
  "races": [
    {
      "id": 1,
      "gid": 123456,
      "gameModeName": "VS Race",
      "region": "Europe",
      "firstSeen": "2025-01-10T10:30:00.000Z",
      "lastSeen": "2025-01-10T11:00:00.000Z",
      "overlapInfo": {
        "overlapStart": "2025-01-10T10:35:00.000Z",
        "overlapEnd": "2025-01-10T10:50:00.000Z",
        "overlapDurationMs": 900000,
        "overlapDurationFormatted": "15m 0s"
      }
    }
  ],
  "total": 15,
  "limit": 20,
  "offset": 0
}

Get Race Details

Get detailed information about a specific race.

Endpoint: GET /api/races/:id

Authentication: Not required

Parameters:

  • id: Race ID

Response:

{
  "id": 1,
  "gid": 123456,
  "gameMode": 0,
  "gameModeName": "VS Race",
  "region": "Europe",
  "dlcCode": 0,
  "dlcName": "No DLC",
  "isActive": false,
  "firstSeen": "2025-01-10T10:30:00.000Z",
  "lastSeen": "2025-01-10T11:00:00.000Z",
  "racePlayers": [
    {
      "id": 1,
      "playerId": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "Player": {
        "id": 5,
        "pid": "abc123",
        "miiName": "Player1",
        "totalRaces": 150
      }
    }
  ]
}

Get Race Statistics

Get detailed statistics for a specific race.

Endpoint: GET /api/races/:id/stats

Authentication: Not required

Parameters:

  • id: Race ID

Response:

{
  "race": {
    "id": 1,
    "gid": 123456,
    "gameMode": 0,
    "gameModeName": "VS Race",
    "region": "Europe",
    "dlcName": "No DLC",
    "isActive": false,
    "firstSeen": "2025-01-10T10:30:00.000Z",
    "lastSeen": "2025-01-10T11:00:00.000Z"
  },
  "statistics": {
    "totalSessions": 15,
    "uniquePlayers": 12,
    "minConcurrentPlayers": 2,
    "maxConcurrentPlayers": 8,
    "avgConcurrentPlayers": 5.5,
    "raceDurationMs": 1800000,
    "raceDurationFormatted": "30m 0s"
  },
  "sessionTimeline": [
    {
      "playerId": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "durationMs": 1800000,
      "durationFormatted": "30m 0s"
    }
  ]
}

Get Race Sessions

Get all player sessions for a specific race.

Endpoint: GET /api/races/:id/sessions

Authentication: Not required

Parameters:

  • id: Race ID (numeric database ID, not gid)

Response:

{
  "race": {
    "gid": 123456,
    "gameModeName": "VS Race",
    "firstSeen": "2025-01-10T10:30:00.000Z",
    "lastSeen": "2025-01-10T11:00:00.000Z",
    "isActive": false
  },
  "sessions": [
    {
      "playerId": 5,
      "playerPid": "abc123",
      "miiName": "Player1",
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "isActive": false,
      "isHost": true,
      "isOwner": false,
      "durationMs": 1800000,
      "durationFormatted": "30m 0s"
    }
  ]
}

Mii Image

Endpoint: GET /api/mii/:hash.png

Authentication: Not required

The face of a Mii reported with a race result (miiHash), 270×270 PNG. It is rendered once by Nintendo's Mii Studio renderer from the stored Mii data and then served from mk8u.de's own cache (Cache-Control: immutable - the same hash always shows the same Mii). 404 for unknown hashes or when rendering failed recently (retried after an hour).

Get Race Rounds

Get the single races of a lobby, as uploaded by the Aroma plugin (see Race Results Upload).

Endpoint: GET /api/races/:id/rounds

Authentication: Not required

Query Parameters:

  • limit (optional): 1-200, default 50
  • offset (optional): default 0

Response:

{
  "raceId": 4711,
  "current": { "courseId": 63, "courseName": "Big Blue", "startedAt": "2026-09-25T14:47:02.000Z" },
  "limit": 50,
  "offset": 0,
  "rounds": [
    {
      "id": 17,
      "raceId": 4711,
      "finishedAt": "2026-09-25T14:39:41.000Z",
      "gameMode": 0,
      "courseId": 27,
      "courseName": "Mario Kart Stadium",
      "playerCount": 12,
      "uploadCount": 2,
      "players": [
        { "rank": 1, "points": 15, "miiName": "Kurt", "playerId": 88, "pid": "1471493879",
          "matchStatus": "name", "vr": 48477, "vrChange": 22, "vrExact": 48477.02,
          "vrBeforeExact": 48455.61, "countryId": 49, "regionId": 50, "isGuest": false }
      ]
    }
  ]
}

Rounds are ordered newest first, players by placement. current is the course being raced right now (reported by Race Start), or null.


Find Lobbies by Player Names

Recent lobbies in which at least two of the given names took part. It was used by the screenshot recognition of the VR entry, which has been removed; the website no longer calls it.

Endpoint: GET /api/races/match-by-names

Authentication: Not required

Query Parameters:

  • names (required): comma-separated Mii names (partial match), at least two
  • timeWindow (optional): look back this many days (default: 7)
  • limit (optional): number of lobbies (default: 10)

Response:

{
  "matches": [
    { "id": 110535, "gid": 4431123, "gameModeName": "VS Race", "region": "Worldwide",
      "firstSeen": "…", "lastSeen": "…", "racePlayers": [ … ],
      "matchScore": 3,
      "matchingPlayers": [ { "pid": 1311404083, "miiName": "Achim" } ] }
  ],
  "total": 1,
  "searchedNames": ["Achim", "Dmi", "alex"],
  "foundPlayers": [ { "pid": 1311404083, "miiName": "Achim" } ]
}

Players

One person, one entry. Every recording keys off a Pretendo player ID, so an account that owns several profiles would otherwise appear once per profile, with its numbers split across the entries. Wherever players are counted, listed or ranked, the profiles of an account are folded into its primary one and their numbers added up:

  • GET /api/players/search — one row per person, profileCount says how many profiles it covers. Searching by an old Mii name or an old PID still finds the merged entry. Pass profiles=1 to get the individual profiles back instead, which is what a picker needs.
  • GET /api/players/:id and GET /api/players/:id/stats — reachable through any of the profiles and giving the same answer, with profiles listing what the numbers are made of. Co-players are merged on both sides, and an account's own profiles are not co-players of each other.
  • GET /api/vr-points/:playerId/latest — the current rating and the personal best span the account. Each value carries a player naming the profile it was recorded for, since VR does not carry over between profiles. The top-level player stays the profile that was requested, because that is what an edit writes to.
  • GET /api/rankings/player/:playerId — best time per track across the account: after a switch the fastest time on a track may sit under either profile.
  • GET /api/stats, GET /api/stats/summary, GET /api/stats/leaderboards, GET /api/stats/vr-points-leaderboards — the person appears once, with the combined values, and the player count counts people rather than profiles.
  • GET /api/stats/social/* and GET /api/stats/network — duos, trios, the connection matrix and the graph pair people rather than profiles, so nobody is paired with themselves and there is no edge between someone and their own former profile.
  • GET /api/stats/records, /api/stats/growth, /api/stats/engagement and /api/stats/leaderboards/stacked-area — per person as well. Note that a profile switch is therefore not counted as a new player in the growth curve.

Lobbies themselves are not merged: a race still lists the profile that actually took part. The archive records what happened, and rewriting it would misstate who was in the room.

Get Race Result Statistics

Most played courses, drivers and driver/vehicle combinations from race results (statistics page, tab "Tracks & drivers").

Endpoint: GET /api/stats/race-results?days=7

Authentication: None

Query: days (optional; without it: all time)

Response:

{ "races": 120,
  "courses": [ { "courseId": 23, "races": 9 } ],
  "drivers": [ { "driverId": 12, "uses": 40 } ],
  "combos": [ { "driverId": 12, "bodyId": 12, "tireId": 2, "gliderId": 9, "uses": 30 } ] }

Get Recent Race Rounds

The latest races of all lobbies, newest first (start page).

Endpoint: GET /api/race-rounds/recent?limit=10

Authentication: None

Query: limit 1-50 (default 10)

Response: { "rounds": [ …as in Get Race Rounds… ] }

Get VR Movers

Players with the biggest VR gain from race results in the last hours, best first. Only matched players count (no guests, no unknown names).

Endpoint: GET /api/race-rounds/movers?hours=24&limit=5

Authentication: None

Query: hours 1-168 (default 24), limit 1-20 (default 5)

Response:

{ "hours": 24, "movers": [ { "playerId": 385, "miiName": "Achim", "miiHash": "…", "countryId": 78, "gain": 102, "races": 12 } ] }

Get Race Round

One race (RaceRound) with its lobby and its position among the lobby's races. Used by the race page (/races/:id).

Endpoint: GET /api/race-rounds/:id

Authentication: None

Response:

{
  "round": { "id": 812, "raceId": 110312, "finishedAt": "2026-09-26T15:58:00.000Z", "courseId": 63,
             "courseName": "Big Blue", "playerCount": 8, "uploadCount": 1, "players": [ "…as in Get Race Rounds…" ] },
  "lobby": { "id": 110312, "gid": 14210, "gameModeName": "VS Race", "region": "Worldwide",
             "dlcName": "DLC 1+2", "isActive": false, "firstSeen": "…", "lastSeen": "…" },
  "position": { "number": 12, "total": 12, "previousId": 809, "nextId": null }
}

lobby and position are null for a race that belongs to no lobby.

Errors: 404 if the race does not exist.

Search Players

Search for players by name or PID.

Endpoint: GET /api/players/search

Authentication: Not required

Query Parameters:

  • miiName (optional): Search by player name (partial match, case-insensitive)
  • pid (optional): Search by exact player PID
  • limit (optional): Results per page (default: 50)
  • offset (optional): Pagination offset (default: 0)
  • orderColumn (optional): Column index to sort by (0=miiName, 1=pid, 2=totalRaces, 3=totalSessions, 4=firstSeen, 5=lastSeen, 6=totalPlaytime, default: lastSeen)
  • orderDir (optional): Sort direction (ASC/DESC, default: DESC)
  • profiles (optional): 1 returns one row per Pretendo profile instead of one per person

Merged or per profile

By default the profiles of an account are folded into one row — the player list is a list of people. Use profiles=1 wherever the caller has to pick a specific Pretendo player ID, because the thing being picked belongs to one profile: entering a time, resolving a PID for a VR comparison. Without it, searching for an old Mii name hands back the primary profile's ID and the pick silently lands on the wrong player.

default profiles=1
Rows one per person one per Pretendo profile
id / pid the primary profile of the account the profile itself
totalRaces etc. summed across profiles that profile's own
profileCount number of profiles merged always 1
total number of people number of profiles

Both modes return accountPid / accountMiiName, naming the account a profile belongs to. In merged mode they equal the row itself; with profiles=1 they differ for a profile someone switched away from, which is how a picker can label it.

Important Notes:

  • Without parameters, this endpoint returns only the first 50 players (newest first, sorted by lastSeen DESC)
  • The total field shows the total number of players matching the search criteria
  • totalPlaytimeSeconds is calculated from all completed sessions (where leftAt is not null)
  • Search also includes historical nicknames from both Pretendo API and guest player entries. Searching for a guest nickname will find the associated main player.
  • To retrieve all players, either:
    • Set a high limit value (e.g., limit=999999)
    • Use pagination by incrementing offset (recommended for large datasets)

Example Requests:

# Get first 50 players (default)
GET /api/players/search

# Search by player name (partial match)
GET /api/players/search?miiName=Player1&limit=10

# Search by exact PID
GET /api/players/search?pid=abc123

# Get all players sorted by total playtime
GET /api/players/search?orderColumn=6&orderDir=DESC&limit=100

# Pagination
GET /api/players/search?limit=100&offset=0    # First 100 players
GET /api/players/search?limit=100&offset=100  # Next 100 players

Response:

{
  "players": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "firstSeen": "2025-01-01T10:00:00.000Z",
      "lastSeen": "2025-01-10T11:00:00.000Z",
      "totalRaces": 150,
      "totalSessions": 200,
      "totalPlaytimeSeconds": 180000
    }
  ],
  "total": 500,
  "limit": 50,
  "offset": 0
}

Get Player Details

Get basic information about a specific player.

Endpoint: GET /api/players/:id

Authentication: Not required

Parameters:

  • id: Player ID

Response:

{
  "player": {
    "id": 5,
    "pid": "abc123",
    "miiName": "Player1",
    "firstSeen": "2025-01-01T10:00:00.000Z",
    "lastSeen": "2025-01-10T11:00:00.000Z",
    "totalRaces": 150,
    "totalSessions": 200,
    "playerRaces": [
      {
        "id": 1,
        "raceId": 1,
        "joinedAt": "2025-01-10T10:30:00.000Z",
        "leftAt": "2025-01-10T11:00:00.000Z",
        "Race": {
          "id": 1,
          "gid": 123456,
          "gameModeName": "VS Race"
        }
      }
    ]
  }
}

Get Player Statistics

Get detailed statistics for a specific player.

Endpoint: GET /api/players/:id/stats

Authentication: Not required

Parameters:

  • id: Player ID

Query Parameters:

  • period (optional): Activity timeline grouping - day (default), week, or month

Example Requests:

# Get daily activity stats (default)
GET /api/players/5/stats

# Get weekly activity stats
GET /api/players/5/stats?period=week

# Get monthly activity stats
GET /api/players/5/stats?period=month

Response:

{
  "player": {
    "id": 5,
    "pid": "abc123",
    "miiName": "Player1",
    "firstSeen": "2025-01-01T10:00:00.000Z",
    "lastSeen": "2025-01-10T11:00:00.000Z",
    "totalRaces": 150,
    "totalSessions": 200
  },
  "statistics": {
    "totalDays": 10,
    "totalWeeks": 2,
    "totalMonths": 1,
    "avgSessionsPerDay": 20.0,
    "avgSessionsPerWeek": 100.0,
    "avgSessionsPerMonth": 200.0,
    "avgRacesPerDay": 15.0,
    "avgRacesPerWeek": 75.0,
    "avgRacesPerMonth": 150.0,
    "totalPlaytimeSeconds": 180000,
    "totalPlaytimeRank": 5,
    "avgPlaytimePerSession": 900,
    "avgPlaytimePerRace": 1200,
    "avgPlaytimePerDay": 18000,
    "validRacingSessions": 120,
    "validRaces": 100,
    "validPlaytimeSeconds": 150000,
    "validPlaytimeRank": 3,
    "avgValidPlaytimePerSession": 1250
  },
  "nameHistory": [
    {
      "miiName": "Player1",
      "firstUsed": "2025-01-01T10:00:00.000Z",
      "lastUsed": "2025-01-10T11:00:00.000Z"
    }
  ],
  "longestSession": {
    "sessionId": 42,
    "raceId": 15,
    "gid": 123456,
    "joinedAt": "2025-01-05T14:00:00.000Z",
    "effectiveLeftAt": "2025-01-05T18:30:00.000Z",
    "durationMinutes": 270
  },
  "longestDay": {
    "date": "2025-01-07",
    "totalMinutes": 420,
    "sessionCount": 8,
    "raceCount": 6
  },
  "activityTimeline": [
    {
      "date": "2025-01-10",
      "sessionCount": 5,
      "raceCount": 4,
      "playtimeMinutes": 120
    }
  ],
  "activityPeriod": "day",
  "favoriteGameMode": {
    "gameMode": 0,
    "gameModeName": "VS Race",
    "raceCount": 100
  },
  "favoriteDLC": {
    "dlcCode": 0,
    "dlcName": "No DLC",
    "raceCount": 120
  },
  "favoriteDayOfWeek": {
    "dayOfWeek": 6,
    "sessionCount": 50
  },
  "weekdayActivity": [
    {
      "dayOfWeek": 0,
      "sessionCount": 20
    },
    {
      "dayOfWeek": 6,
      "sessionCount": 50
    }
  ],
  "topCoPlayers": [
    {
      "id": 10,
      "pid": "xyz789",
      "miiName": "Player2",
      "racesTogether": 80
    }
  ],
  "topConcurrentCoPlayers": [
    {
      "id": 11,
      "pid": "def456",
      "miiName": "Player3",
      "concurrentRaces": 45,
      "totalOverlapMinutes": 350
    }
  ],
  "recentRaces": [
    {
      "id": 1,
      "gid": 123456,
      "gameMode": 0,
      "gameModeName": "VS Race",
      "region": "Europe",
      "dlcName": "No DLC",
      "isActive": false,
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "sessionDuration": 1800000
    }
  ]
}

Further fields:

  • nameHistory: Array of all names the player has used with timestamps

  • longestSession: Details of the player's longest single gaming session

  • longestDay: Details of the day with the most total playtime

  • activityPeriod: Current grouping period for activity timeline (day/week/month)

  • Players with the most overlapping playtime are a separate request, see Get Concurrent Co-Players

  • nicknameHistory: Comprehensive nickname history from both Pretendo API and guest player entries, with source tracking ("pretendo" or "guest"), timestamps, and usage counts

Note on Valid Racing Sessions:

The statistics include both total counts and "valid racing" metrics:

  • Valid Racing Sessions: Sessions where the player was in a race with at least 2 players (not solo) AND the session lasted at least 2 minutes (120 seconds). These represent actual races where meaningful gameplay occurred.
  • Total Sessions/Races: All sessions/races including solo sessions and very short sessions.

The validRacingSessions, validRaces, validPlaytimeSeconds, and avgValidPlaytimePerSession fields help distinguish between actual racing activity and lobby idle time or solo sessions.

Playtime Rankings:

The statistics also include ranking information:

  • totalPlaytimeRank: The player's ranking based on total playtime (all sessions). Lower numbers are better (1 = highest playtime).
  • validPlaytimeRank: The player's ranking based on valid racing playtime only. Lower numbers are better (1 = highest valid playtime).

Get Player Race Summary

What the race results reported by the Aroma plugin say about one person, across all Pretendo profiles of the account. Used by the player page (/players/:id).

Endpoint: GET /api/players/:id/race-summary

Authentication: None

Response:

{
  "playerId": 385,
  "totals": { "races": 120, "wins": 31, "podiums": 58, "avgRank": 3.9, "limitedTo": null },
  "vr": { "current": 8802, "change7d": 197, "updatedAt": "2026-09-26T15:58:00.000Z",
          "max": 9137, "maxAt": "2026-09-12T19:03:00.000Z" },
  "recent": [
    { "roundId": 812, "raceId": 110312, "finishedAt": "2026-09-26T15:58:00.000Z", "courseId": 63,
      "gameMode": 0, "playerCount": 8, "rank": 6, "points": 7, "vr": 8738, "vrChange": -9,
      "isGuest": false, "driverId": 12, "bodyId": 12, "tireId": 2, "gliderId": 9 }
  ],
  "tracks": [
    { "courseId": 23, "races": 9, "wins": 2, "avgRank": 3.4, "vrBalance": 41 }
  ],
  "opponents": [
    { "playerId": 9723, "miiName": "Dmi", "miiHash": "…", "countryId": 110, "racesTogether": 14, "ahead": 5 }
  ]
}
  • totals, tracks and opponents cover the latest 1000 races; limitedTo is 1000 when there are more, otherwise null.
  • vr comes from all VR entries (VS race): max is the highest value, maxAt when it was first reached.
  • recent holds the latest 30 races, newest first. raceId is the lobby or null.
  • tracks: per course, most raced first; vrBalance is the sum of the VR changes.
  • opponents: the 10 matched players seen most often in the same races; ahead counts the races this player finished in front of them.
  • vr: latest VS race VR from all sources and the change against the last value at least 7 days old (null without such a value).

Errors: 404 if the player does not exist.

Get Player Sessions

✅ Returns session start times (joinedAt) for all player sessions.

Get all sessions for a specific player (by PID). This endpoint provides the complete session history including when each session started.

Endpoint: GET /api/players/:pid/sessions

Authentication: Not required

Parameters:

  • pid: Player PID

Response:

{
  "player": {
    "pid": "abc123",
    "miiName": "Player1",
    "totalRaces": 150
  },
  "sessions": [
    {
      "raceId": 1,
      "raceGid": 123456,
      "gameModeName": "VS Race",
      "joinedAt": "2025-01-10T10:30:00.000Z",
      "leftAt": "2025-01-10T11:00:00.000Z",
      "isActive": false,
      "isHost": true,
      "isOwner": false,
      "durationMs": 1800000,
      "durationFormatted": "30m 0s"
    }
  ]
}

Key Fields:

  • joinedAt: Session start time - timestamp when the player joined the race
  • leftAt: Session end time - timestamp when the player left (null if still active)
  • isActive: Whether the session is currently active
  • durationMs: Session duration in milliseconds
  • durationFormatted: Human-readable session duration

Get Player by PID

Look up a player by their Pretendo player ID (PID) instead of the internal ID.

Endpoint: GET /api/players/by-pid/:pid

Authentication: Not required

Response:

{
  "player": {
    "id": 5,
    "pid": 1311404083,
    "miiName": "Achim",
    "firstSeen": "2025-11-30T23:43:42.000Z",
    "lastSeen": "2026-09-27T20:11:02.000Z",
    "totalRaces": 412,
    "totalSessions": 530,
    "countryId": 78,
    "regionId": 2,
    "miiHash": "3f9c0b…"
  }
}

404 when no player has this PID.

Get Concurrent Co-Players

The five people who were most often in the same lobby at the same time as this player (overlapping sessions, not just the same lobby). Counted per person on both sides, so a profile switch does not split anyone.

Endpoint: GET /api/players/:id/concurrent-coplayers

Authentication: Not required

Response:

{
  "topConcurrentCoPlayers": [
    { "id": 12, "pid": 1356763971, "miiName": "Dmi", "concurrentRaces": 38 },
    { "id": 44, "pid": 1043681158, "miiName": "alex_rf", "concurrentRaces": 21 }
  ]
}

concurrentRaces is the number of lobbies with overlapping sessions. This query walks all shared lobbies and can take a few seconds for very active players.

Get Linked Player IDs

All Pretendo PIDs that are linked to any account on the site. The web pages use it to mark players who have an account.

Endpoint: GET /api/linked-pids

Authentication: Not required

Response:

{ "linkedPids": ["1311404083", "1356763971"] }

On an error the list is empty rather than a 500.

Statistics

All statistics endpoints now support time filtering via the days query parameter for better temporal analysis.

Get Dashboard Counters

The three counters the dashboard tiles show. GET /api/stats answers them as well, but only after aggregating everything the statistics page needs, which takes seconds on a large archive — this endpoint runs three counts and nothing else, and caches them for 15 seconds.

Endpoint: GET /api/stats/summary

Authentication: Not required

Query Parameters: none

Response:

{
  "totalRaces": 37500,
  "activeRaces": 3,
  "totalPlayers": 20000
}

totalPlayers counts people rather than profiles: an account that has linked several Pretendo profiles counts once, the same as everywhere else.

Get Global Statistics

Get global statistics and leaderboards.

Endpoint: GET /api/stats

Authentication: Not required

Query Parameters:

  • limit (optional): Number of results per category (default: 10)
  • days (optional): Time range filter - number of days to include, or 'all' for all-time stats (default: all)
  • startDate (optional): Alternative to days - start date in ISO format
  • endDate (optional): End date in ISO format (used with startDate)

Example Requests:

# Get all-time stats
GET /api/stats

# Get stats for last 7 days
GET /api/stats?days=7

# Get stats for last 30 days
GET /api/stats?days=30

# Get stats for specific date range
GET /api/stats?startDate=2025-01-01&endDate=2025-01-31

Response:

{
  "totalRaces": 1500,
  "totalPlayers": 500,
  "activePlayers": 50,
  "totalSessions": 5000,
  "topPlayersByRaces": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "topPlayersBySessions": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "topPlayersByPlaytime": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "totalPlaytimeSeconds": 180000,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "topPlayersByAvgPlaytimePerDay": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "avgPlaytimePerDaySeconds": 18000,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "recentPlayers": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "totalRaces": 150,
      "totalSessions": 200,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "activityHeatmap": [
    {
      "hour": 14,
      "dayOfWeek": 6,
      "sessionCount": 50
    }
  ],
  "gameModeDistribution": [
    {
      "gameMode": 0,
      "gameModeName": "VS Race",
      "count": 800
    }
  ],
  "regionDistribution": [
    {
      "region": "Europe",
      "count": 500
    }
  ],
  "dlcDistribution": [
    {
      "dlcCode": 0,
      "dlcName": "No DLC",
      "count": 1000
    }
  ]
}

Note: the concurrent-player figures used to be part of this response under concurrentPlayers. They now live on GET /api/stats/concurrency — see below.

Get Concurrent Players

How many people were in a lobby at the same time: the peak, the average, an hourly curve, and the weekday/hour grids the heatmap draws.

This is the one figure on the statistics page that has to walk every single session, so it has its own endpoint — an archive with 300 000 sessions takes a few seconds here while everything else is aggregated by the database in a fraction of that. The page fetches it alongside GET /api/stats and draws the chart when it arrives, instead of holding the whole page back.

Endpoint: GET /api/stats/concurrency

Authentication: Not required

Query Parameters: the same time range as GET /api/stats — days, or startDate/endDate.

Response:

{
  "concurrentPlayers": {
    "max": 90,
    "min": 1,
    "avg": 12.34,
    "timeline": [
      { "timestamp": "2026-08-11T12:00:00.000Z", "maxConcurrent": 42 }
    ],
    "byHour": [ { "hourOfDay": 0, "maxConcurrent": 12 } ],
    "byDayOfWeek": [ { "dayOfWeek": 0, "maxConcurrent": 30 } ],
    "heatmap": [ { "dayOfWeek": 0, "hourOfDay": 0, "maxConcurrent": 8.5 } ]
  }
}

max, byHour and byDayOfWeek are peaks; heatmap averages each weekday and hour over the weeks it saw. A person in two lobbies at once counts once, and a session that was never closed keeps counting.


Get Top Duos

The ten pairs of players with the most time together in lobbies (overlapping sessions), chosen among the 100 players with the most playtime. Pairs people, not profiles.

Endpoint: GET /api/stats/social/duos

Authentication: Not required

Query Parameters:

  • days (optional): only lobbies of the last N days (default: all)

Response:

[
  {
    "player1Id": 5, "player1Name": "Achim", "player1Pid": 1311404083,
    "player2Id": 12, "player2Name": "Dmi", "player2Pid": 1356763971,
    "totalOverlapMinutes": 1840,
    "racesTogether": 64,
    "lastRaceTogether": "2026-09-27T19:02:11.000Z",
    "avgSessionDurationMinutes": 71.4
  }
]

racesTogether counts lobbies, lastRaceTogether is the start of the latest shared lobby. Results are cached for a few minutes.

Get Top Trios

Like the duos, for three people who were in a lobby at the same time.

Endpoint: GET /api/stats/social/trios

Authentication: Not required

Query Parameters:

  • days (optional): only lobbies of the last N days (default: all)

Response:

[
  {
    "player1Id": 5, "player1Name": "Achim", "player1Pid": 1311404083,
    "player2Id": 12, "player2Name": "Dmi", "player2Pid": 1356763971,
    "player3Id": 44, "player3Name": "alex_rf", "player3Pid": 1043681158,
    "totalOverlapMinutes": 610,
    "racesTogether": 19,
    "lastRaceTogether": "2026-09-26T21:34:30.000Z"
  }
]

Get Average Co-Players

How many different people a player met on average at the same time in a lobby.

Endpoint: GET /api/stats/social/avg-co-players

Authentication: Not required

Query Parameters:

  • days (optional): only lobbies of the last N days (default: all)

Response:

{ "avgCoPlayers": 23.7 }

Get Connection Matrix

Time together for every pair among the ten people with the most lobbies.

Endpoint: GET /api/stats/social/connection-matrix

Authentication: Not required

Query Parameters:

  • days (optional): only lobbies of the last N days for the connections (default: all); the ten players are always chosen over all time

Response:

{
  "players": [
    { "id": 5, "pid": 1311404083, "miiName": "Achim", "totalRaces": 412 }
  ],
  "connections": [
    { "player1Id": 5, "player2Id": 12, "overlapMinutes": 1840, "sharedRaces": 64 }
  ]
}

A pair without shared time has no entry in connections. sharedRaces counts lobbies.

Get Leaderboard Shares Over Time

Data for the "shares" chart of a leaderboard: the value of each top player per day, week or month, plus everybody else and the total.

Endpoint: GET /api/stats/leaderboards/stacked-area

Authentication: Not required

Query Parameters:

  • type (optional): gatherings (lobbies, default), sessions or playtime (minutes)
  • days (optional): last N days (default: all)
  • topN (optional): number of players with their own series (default: 10)

The bucket size follows the period: up to 31 days per day, up to 180 days per week, otherwise per month.

Response:

{
  "type": "gatherings",
  "bucketType": "day",
  "buckets": ["2026-09-25", "2026-09-26", "2026-09-27"],
  "series": [
    { "id": 5, "miiName": "Achim", "pid": 1311404083, "data": [3, 5, 2] }
  ],
  "other": [40, 52, 31],
  "totals": [43, 57, 33]
}

week buckets look like 2026-W39, month buckets like 2026-09.

Get Players With the Most Names

Players known under more than one name - Mii names seen on Pretendo and names they were entered under as a guest - with every name.

Endpoint: GET /api/statistics/most-nicknames

Authentication: Not required

Query Parameters:

  • limit (optional): number of players (default: 50)

Response:

{
  "players": [
    {
      "playerId": 12,
      "pid": 1356763971,
      "currentName": "Dmi",
      "firstSeen": "2026-06-19T10:12:00.000Z",
      "lastSeen": "2026-09-27T20:11:02.000Z",
      "totalRaces": 188,
      "nicknameCount": 3,
      "allNicknames": [
        { "nickname": "Dmi", "source": "pretendo", "firstUsed": "…", "lastUsed": "…", "totalCount": 170 },
        { "nickname": "Dima", "source": "guest", "firstUsed": "…", "lastUsed": "…", "totalCount": 4 }
      ]
    }
  ]
}

source is pretendo or guest; totalCount counts sessions with that name.

Server Status

Get Race Logging Coverage

How much of the time spent in lobbies is covered by races logged through the MK8Logger plugin. Counted from the first race whose start was reported (POST /api/v1/race-start) - races before that have no known duration.

Endpoint: GET /api/server-status/race-logging

Authentication: Not required

Response:

{
  "since": "2026-09-27T18:04:10.000Z",
  "races": 412,
  "timedRaces": 398,
  "lobbies": 57,
  "lobbySeconds": 186420,
  "racedSeconds": 61230,
  "racedShare": 32.8
}
  • since: start of the period (first race with a stored start); null when no race has one yet - then the other fields are missing
  • races: races logged since then; timedRaces: those with a known start
  • lobbySeconds: time in which at least two players (guests included) were in a lobby at once, within the period, added up over all lobbies (running lobbies until now)
  • lobbies: lobbies that had at least two players at once in the period
  • racedSeconds: start to result of every timed race, added up
  • racedShare: racedSeconds as a percentage of lobbySeconds (one decimal), null without lobby time

A reported start counts for a result only when the course matches and the race took between 45 seconds and 8 minutes.

Get Server Downtime Statistics

Get server uptime/downtime statistics based on race logging activity. Downtime is calculated only during periods when the poller was active (based on race logs and poller errors).

Endpoint: GET /api/server-status/downtime

Authentication: Not required

Query Parameters:

  • from (optional): Start date (ISO format, default: 2025-10-29T00:00:00)
  • to (optional): End date (ISO format, default: now)

Example Request:

GET /api/server-status/downtime?from=2025-10-29T00:00:00&to=2025-11-19T23:59:59

Response:

{
  "startDate": "2025-10-29T00:00:00.000Z",
  "endDate": "2025-11-19T23:59:59.999Z",
  "pollerActiveFrom": "2025-10-29T08:00:00.000Z",
  "pollerActiveTo": "2025-11-19T20:00:00.000Z",
  "totalDowntimeMs": 3600000,
  "totalDowntimeFormatted": "1h 0m",
  "uptimePercentage": "99.50",
  "incidentCount": 2,
  "incidents": [
    {
      "startTime": "2025-11-10T14:00:00.000Z",
      "endTime": "2025-11-10T14:35:00.000Z",
      "durationMs": 2100000,
      "durationFormatted": "35m 0s"
    },
    {
      "startTime": "2025-11-15T03:00:00.000Z",
      "endTime": "2025-11-15T04:25:00.000Z",
      "durationMs": 5100000,
      "durationFormatted": "1h 25m"
    }
  ],
  "racesLogged": 15000,
  "pollerEvents": 15250,
  "note": "Downtime calculated only during poller-active period (based on races and errors). Gaps of 60+ minutes count as downtime."
}

Key Fields:

  • pollerActiveFrom/To: Time range when poller was confirmed active (first to last race/error)
  • uptimePercentage: Percentage of time with active races during monitored period
  • totalDowntimeMs: Total milliseconds of downtime (gaps >= 60 minutes without races)
  • incidents: Array of downtime incidents with exact start/end times
  • pollerEvents: Total number of poller activity indicators (races + errors)

Downtime Calculation:

  • Downtime is only counted during periods when the poller was actively monitoring
  • A gap of 60+ minutes between races is considered a downtime incident
  • Times before the first poller event or after the last are not counted as downtime
  • The default monitoring period starts from October 29, 2025

Get Server Status History

The raw status checks of the Pretendo server components, newest first.

Endpoint: GET /api/server-status/history

Authentication: Not required

Query Parameters:

  • componentId (optional): one component by its ID
  • componentName (optional): components whose name contains this text (ignored when componentId is given)
  • from, to (optional): time range of the checks (ISO 8601)
  • limit (optional): number of entries (default: 100)

Response:

{
  "history": [
    {
      "id": 9811,
      "componentId": "<Pretendo component ID>",
      "componentName": "Mario Kart 8",
      "status": "operational",
      "previousStatus": "partial_outage",
      "checkedAt": "2026-09-27T20:10:00.000Z"
    }
  ]
}

status and previousStatus: operational, degraded_performance, partial_outage or major_outage, as Pretendo's status page reports them.

Rankings (Time Trials)

Time trial toplists ("Toplisten") for all 48 courses. The data is not stored in the logger database - it is fetched live from the public Pretendo MK8 website API (https://mario-kart-8.pretendo.network/api/rankings/:trackId, which proxies the AMKJ gRPC call GetTimeTrialRanking) and normalised:

  • the raw score (milliseconds) is formatted as m:ss.mmm
  • the Mii name is decoded from the NEX commonData blob (UTF-16LE at offset 0x2E)
  • every entry is cross-referenced with the local players table, so ranked players that this logger already knows are returned with their local player record

Responses are cached server-side to keep load off the Pretendo backend (configurable via MK8_RANKINGS_CACHE_TTL_MS / MK8_RANKINGS_OVERVIEW_CACHE_TTL_MS).

Note: All ranking endpoints require authentication.

Get Track Catalogue

Returns the static cup/course list used to build the track picker.

Endpoint: GET /api/rankings/tracks

Response:

{
  "cups": [
    {
      "name": "Mushroom Cup",
      "internalName": "Mushroom",
      "dlc": false,
      "tracks": [
        { "id": 27, "name": "Mario Kart Stadium", "internalName": "Gu_FirstCircuit" }
      ]
    }
  ]
}

Get Track Ranking

Time trial times for a single course.

Endpoint: GET /api/rankings/track/:trackId

Query Parameters:

Parameter Type Default Description
order string best best for the fastest times, worst for the slowest ones
view string current current = live top 10 from Pretendo, all = the full local archive
realistic 0/1 0 1 drops every time flagged as implausible

Entries carry two positions: rank is the position in the upstream list, while displayRank is the position after filtering. With realistic=1 the remaining entries are renumbered from 1, so a list whose two fastest times were hidden still reads 1, 2, 3 - rank then still shows 3, 4, 5.

With view=all the response is capped at 500 entries; total, limit and truncated describe what was left out. If the live fetch fails, view=current falls back to the last archived list and reports source: "archive-fallback".

Example:

curl -b cookies.txt "http://localhost:3000/api/rankings/track/27?order=best"

Response:

{
  "track": {
    "id": 27,
    "name": "Mario Kart Stadium",
    "internalName": "Gu_FirstCircuit",
    "cupName": "Mushroom Cup",
    "cupInternalName": "Mushroom",
    "dlc": false
  },
  "order": "best",
  "entries": [
    {
      "rank": 1,
      "displayRank": 1,
      "pid": 1799999,
      "miiName": "Mick",
      "score": 92345,
      "time": "1:32.345",
      "recordedAt": "2026-01-05T12:34:56.000Z",
      "firstSeenAt": "2026-07-02T09:14:11.000Z",
      "lastSeenAt": "2026-08-09T08:10:02.000Z",
      "unrealistic": false,
      "unrealisticReason": null,
      "id": 412,
      "blocked": false,
      "reportStatus": null,
      "player": {
        "id": 42,
        "miiName": "Mick",
        "totalRaces": 128,
        "lastSeen": "2026-08-08T20:11:03.000Z",
        "isFavorite": true
      }
    }
  ],
  "cutoff": { "cutoffMs": 85270, "source": "gap", "gapMs": 83770 },
  "unrealisticCount": 2,
  "blockedCount": 0,
  "realisticOnly": false,
  "view": "current",
  "source": "live",
  "total": null,
  "limit": null,
  "truncated": false,
  "cachedAt": "2026-08-09T08:18:18.878Z"
}

player is null when the PID has never been seen by this logger. recordedAt is when the time was driven (reported by the API), firstSeenAt is when this logger first observed it.

cutoff.source is manual when an admin set a threshold for the track, gap when it was detected automatically and none when nothing looks implausible.

id is the archive row - it is what POST /api/rankings/report refers to. blocked marks a time an admin took out after a user report; such an entry is unrealistic with unrealisticReason: "blocked" even when cutoff.source is none. reportStatus is the requesting user's own report on that time, if any.

Error Responses:

Status Meaning
400 Unknown or non-numeric track ID
401 Not authenticated
502 The Pretendo ranking backend is unreachable or returned an error

Get All Track Records

Current record holder (rank 1) for every course. This fans out into one upstream request per track (bounded to 4 in parallel), so it is cached for 10 minutes by default. Individual track failures are reported per row instead of failing the whole response.

Endpoint: GET /api/rankings/overview

Response:

{
  "tracks": [
    {
      "track": { "id": 27, "name": "Mario Kart Stadium", "cupName": "Mushroom Cup", "dlc": false },
      "record": {
        "rank": 1,
        "pid": 1799999,
        "miiName": "Mick",
        "score": 92345,
        "time": "1:32.345",
        "recordedAt": "2026-01-05T12:34:56.000Z",
        "player": null
      },
      "entryCount": 10,
      "error": null
    },
    {
      "track": { "id": 31, "name": "Rainbow Road", "cupName": "Special Cup", "dlc": false },
      "record": null,
      "entryCount": 0,
      "error": "Rankings API error: 500 Internal Server Error"
    }
  ],
  "cachedAt": "2026-08-09T08:18:19.104Z"
}

Get Recently Discovered Times

Times the archiver observed for the first time, newest first.

Endpoint: GET /api/rankings/recent?limit=50&pollId=12

Each entry carries its track plus the same fields as a ranking entry, including firstPollId (the poll that discovered it). Pass pollId to list only what one cycle found.

Get a Player's Best Times

Best archived time per course for one logged player - the data behind the "Time Trial Best Times" section of the player details.

Endpoint: GET /api/rankings/player/:playerId

playerId is the local player ID (not the Pretendo PID).

{
  "player": { "id": 42, "pid": 1799999, "miiName": "Mick" },
  "times": [
    {
      "track": { "id": 27, "name": "Mario Kart Stadium", "cupName": "Mushroom Cup" },
      "time": "1:32.345",
      "score": 92345,
      "rank": 1,
      "inCurrentList": true,
      "unrealistic": false,
      "cutoff": { "cutoffMs": null, "source": "none", "gapMs": null },
      "trackTimeCount": 3,
      "recordedAt": "2026-01-05T12:34:56.000Z",
      "firstSeenAt": "2026-07-02T09:14:11.000Z"
    }
  ],
  "totalTimes": 58,
  "trackCount": 21,
  "inCurrentLists": 4
}

One row per course, the player's fastest there. trackTimeCount is how many of their times that course holds in total. Rows still on a leaderboard come first ordered by rank, the rest follow by course name. Returns 404 for an unknown player.

Deep links into a list

The rankings page reads query parameters, so other views can link straight to a specific list: /rankings?track=27&view=all&order=worst&realistic=1. track is required and selects both the cup and the course; the rest are optional and default to the current best-times list. An unknown or non-numeric track is ignored and the page opens normally.

List Poll Cycles

Endpoint: GET /api/rankings/polls?limit=20

{
  "polls": [
    {
      "id": 12,
      "startedAt": "2026-08-09T09:10:00.000Z",
      "finishedAt": "2026-08-09T09:12:14.000Z",
      "status": "completed",
      "triggeredBy": "scheduled",
      "tracksTotal": 48,
      "tracksOk": 47,
      "tracksFailed": 1,
      "timesCreated": 24,
      "timesUpdated": 896,
      "durationMs": 134000,
      "lastError": "Rainbow Road: Rankings API error: 500 Internal Server Error"
    }
  ]
}

The archive and implausible times

Why there is an archive

The public HTTP API is capped at 10 entries per direction and the cap is not configurable. app/api/rankings/[id]/route.ts in PretendoNetwork/mario_kart_8_website calls the backend with a hardcoded limit: 10 and reads only one query parameter, desc. The underlying gRPC call (GetTimeTrialRanking) does take a limit, but that service is internal (grpc_host defaults to localhost:50051) and requires an X-API-Key that only the website server holds. The route has not changed since it was added in April 2024.

The cap applies to HTTP only - see Full leaderboards over NEX for the protocol the game itself uses, which has no such limit.

With the HTTP source the archive widens that window in two ways:

  • both directions are polled, so one cycle sees up to 20 distinct times, not 10
  • every observation is stored permanently, so a time that later drops out of the top 10 stays visible and the archive grows well past what the API shows

The archiver runs every 10 minutes by default, polls all 48 courses, and records firstSeenAt for each new time. Its schedule, its source and the NEX credentials are configured in the admin panel under Toplisten; see Admin: Rankings Configuration.

On the HTTP path requests go out in parallel with a small concurrency, but the writes are applied sequentially - concurrent transactions from one batch otherwise collide on SQLite.

Lists are derived from poll data

Every cycle is stored as a poll record, and each archived time carries the poll that first saw it (firstPollId) and the poll that last saw it (lastPollId). Each course additionally remembers which poll its current list came from, so the live top 10 is exactly the set of times carrying that course's poll stamp, in lastRank order - no per-row "is current" flag is rewritten on each cycle. When a poll fails for a course, its pointer stays on the last good cycle, which is what the archive-fallback source serves.

Track responses report the poll their current list came from as pollId.

How implausible times are detected

Within a genuine top 10 consecutive times are usually less than a second apart, so a hacked time is separated from the honest field by a large gap. Counting from the top, every gap larger than 1000 ms is collected; the last one becomes the cutoff, and every time faster than it is flagged unrealistic.

The cutoff is derived from the course's ten fastest archived times, never from the whole archive, where big gaps are normal. Ten keeps the sample as tight as a live top 10, which is what the gap rule needs; taking it from the archive rather than the current list means a hacked time stays flagged after it leaves the live leaderboard - removed upstream, or pushed out of the top 10 - which is exactly when it would otherwise become invisible to the rule. It also makes the result independent of which source the last poll used.

A manual per-course threshold always wins over the automatic value. This is also the escape hatch for the rule's weak spot: if the large gap sits at the bottom of the list (one very slow time in tenth place), everything above it gets flagged.

A third signal sits on top of both: a time an admin blocked after a user report is always unrealistic, whatever the cutoff says. Entries carry unrealisticReason, either "blocked" (a human decision about this one time) or "cutoff" (the automatic rule), and track responses count them separately as unrealisticCount and blockedCount.

Reporting a time

Endpoint: POST /api/rankings/report

Authentication: Required (any logged-in user)

Body: multipart/form-data with trackTimeId, an optional reason and an optional evidence image (PNG/JPEG/WebP/GIF, 8 MB by default). Plain JSON still works when there is no file.

{ "success": true, "reportId": 7, "status": "pending", "hasEvidence": true }

A report never blocks a time on its own - it only asks an admin to look. Each user can report a given time once; a second attempt returns 409 together with the status of the existing report. Unknown IDs return 404, an already blocked time 409.

Entries in track responses carry reportStatus for the requesting user (pending, approved, rejected or null), so the UI can show the report button only where it still makes sense.

Filing a report notifies every active admin by e-mail (notification address if set, otherwise the account address; silently skipped when SMTP is unconfigured) and makes the dashboard widget appear. Mail failures are logged but never lose the report.

Endpoint: GET /api/rankings/report/:id/evidence

Serves the uploaded image. Visible to the reporter and to admins only.


User-entered times and evidence

Everything in this section follows the same rule: a user submits, an admin decides, and nothing is publicly visible in between.

Uploads

Images are stored outside public/ (MK8_UPLOAD_DIR, default ./data/uploads/rankings) under a random file name - the original name is user input and never touches the disk. They are served through GET /api/rankings/media/:id/file, which enforces the same visibility rule as everything else: approved files are visible to any logged-in user, pending ones only to their submitter and to admins.

Every upload is re-encoded before it is stored: scaled into MK8_UPLOAD_MAX_WIDTH × MK8_UPLOAD_MAX_HEIGHT (800 × 600 by default, never enlarged) and written as JPEG at MK8_UPLOAD_QUALITY (72). A 6 MB PNG capture typically ends up under 100 KB, and the stored mimeType is always image/jpeg regardless of what was sent. Decoding the file doubles as validation - anything that is not a readable image is rejected with 400 and deleted, whatever its declared content type said. MK8_UPLOAD_MAX_BYTES still caps what is accepted for processing in the first place.

Processing uses sharp, a native module requiring Node.js 18.17 or newer (the Docker image ships Node 22, and engines.node asks for 20.9+). When it cannot be loaded the application still starts, logs a warning and stores uploads unchanged - the size limit and the MIME filter keep working, only the scaling and the decode check are skipped.

Enter a time

Endpoint: POST /api/rankings/times

Body: multipart/form-data

Field Required Description
trackId yes Course ID
time yes m:ss.mmm, ss.mmm or plain milliseconds
screenshot yes Image of the result screen
pid admins only Pretendo player ID; ignored for normal users
miiName no Defaults to the linked profile name
note no Free text for the admin
{ "success": true, "trackTimeId": 921, "status": "pending" }

A normal user can only enter times for their own player ID. The PID is taken from their linked Pretendo profile; a request naming a different one is rejected with 403 (You can only enter times for your own player ID), and an account without a linked profile gets 403 as well. Admins may pass any pid and enter times on behalf of other players; the form offers them the same GET /api/players/search lookup the lobby list uses for its player comparison. The rule is enforced server-side - the locked field in the form is only the visible half of it.

The row lands in the normal archive table with approvalStatus: "pending" and is filtered out of every read path - lists, the overview, player best times and the cutoff analysis alike - until an admin approves it. After approval it sorts into the archive view at the position its time deserves and is marked userSubmitted: true.

A time that a later poll finds in the official ranking is approved automatically: appearing in Pretendo's own list is stronger evidence than any screenshot.

409 means the same course/player/time already exists; the response carries the existing trackTimeId.

Propose a screenshot or a video

Endpoint: POST /api/rankings/time/:id/media

Body: multipart/form-data with type (image or youtube), plus either screenshot or url, and an optional caption. YouTube URLs are accepted in the watch?v=, youtu.be, embed and shorts forms, as well as a bare 11-character video ID.

{ "success": true, "mediaId": 12, "status": "pending" }

Time detail

Endpoint: GET /api/rankings/time/:id

Everything the detail modal shows for one archived time.

{
  "entry": {
    "id": 921,
    "pid": 1966001,
    "miiName": "Formularfahrer",
    "time": "1:26.100",
    "unrealistic": false,
    "userSubmitted": true,
    "approvalStatus": "approved",
    "submissionNote": "Mit Screenshot belegt",
    "reportStatus": null,
    "media": [
      {
        "id": 11,
        "type": "image",
        "status": "approved",
        "fileUrl": "/api/rankings/media/11/file",
        "caption": null,
        "submitter": { "id": 3, "username": "fahrer" }
      },
      {
        "id": 12,
        "type": "youtube",
        "status": "approved",
        "youtubeId": "dQw4w9WgXcQ",
        "watchUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
        "thumbnailUrl": "https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg"
      }
    ]
  },
  "track": { "id": 32, "name": "3DS DK Jungle", "cupName": "Banana Cup" },
  "cutoff": { "cutoffMs": null, "source": "none", "gapMs": null },
  "submitter": { "id": 3, "username": "fahrer" },
  "reportCount": 0,
  "canReport": true
}

reports (the full list with reasons) is only included for admins. A pending or rejected submission returns 404 for everyone except its author and admins.

Track and recent responses carry the same media array per entry, which is what the preview column in the lists renders.

Admin: Submitted times

Endpoint: GET /admin/api/rankings/submissions?status=pending|approved|rejected|all Endpoint: POST /admin/api/rankings/submissions/:id/approve|reject - body { "note": "optional" } Endpoint: PUT /admin/api/rankings/submissions/:id - body { "time": "1:24.500", "miiName": "…", "note": "…" } Endpoint: DELETE /admin/api/rankings/submissions/:id

Approving releases the time and its screenshots in one go. Rejecting keeps the row for the record but leaves it invisible. PUT exists so a mistyped time can be corrected without asking the user to submit again; deleting also removes the uploaded files. Only rows with source = 'user' can be reached through these endpoints - polled times are data, not moderation objects.

Admin: Proposed media

Endpoint: GET /admin/api/rankings/media?status=pending|approved|rejected|all Endpoint: POST /admin/api/rankings/media/:id/approve|reject Endpoint: DELETE /admin/api/rankings/media/:id

Admin: Pending counts

Endpoint: GET /admin/api/rankings/moderation/pending

{ "reports": 1, "submissions": 2, "media": 3, "total": 6 }

Drives the dashboard widget, which stays hidden while total is zero.


Admin: List Reports

Endpoint: GET /admin/api/rankings/reports?status=pending|approved|rejected|all

{
  "reports": [
    {
      "id": 7,
      "status": "pending",
      "reason": "way below the world record",
      "reviewNote": null,
      "createdAt": "2026-08-10T09:12:00.000Z",
      "reviewedAt": null,
      "reporter": { "id": 3, "username": "melder" },
      "reviewer": null,
      "time": {
        "id": 412,
        "trackId": 27,
        "trackName": "Mario Kart Stadium",
        "cupName": "Mushroom Cup",
        "pid": 1800000,
        "miiName": "Racer0",
        "score": 812,
        "time": "0:00.812",
        "blocked": false
      }
    }
  ],
  "pending": 1,
  "status": "pending"
}

status defaults to pending. pending is always the total number of open reports, regardless of the filter - the dashboard widget uses it.

Admin: Approve or Reject a Report

Endpoint: POST /admin/api/rankings/reports/:id/approve Endpoint: POST /admin/api/rankings/reports/:id/reject Endpoint: DELETE /admin/api/rankings/reports/:id

Body: { "note": "optional" }

Approving sets blocked on the archived time. Rejecting lifts the block again, unless another approved report still covers the same time. Both decisions are written to the activity log. DELETE is housekeeping - it removes the report and its uploaded evidence rather than deciding anything.

Full leaderboards over NEX

The game does not use the website API - it speaks the NEX Ranking protocol (0x70) over PRUDP. GetRanking there takes an offset and a count, so a leaderboard can be read in full. Pretendo implements it via nex-protocols-common-go/ranking/get_ranking.go; RankingOrderParam.Length is a u8, so a single response holds at most 255 entries and longer lists are paged.

The logger can use that as an optional source. scripts/nex_rankings.py performs the same login the console does - NNAS account login, NEX token, PRUDP session - and returns whole leaderboards as JSON; src/services/nexRankings.js runs it and feeds the result into the same archive the HTTP path writes to. There is no NEX implementation for Node, hence the Python sidecar.

It needs real console data, because NEX authenticates consoles rather than API clients. Pretendo's account server runs consoleStatusVerificationMiddleware on the login route: it verifies the device certificate's ECDSA signature against Nintendo's Wii U device CA public key and checks that the X-Nintendo-Device-ID header matches the device ID encoded in the certificate name. A fabricated certificate is rejected as Unlinked device. Only the serial number goes unverified - their own comment explains that serials cannot be checked safely.

So a Pretendo account, device ID, serial number, system version and device certificate are all required. That is the same data Cemu needs for online play. Without them the archiver uses the HTTP API instead. Continuous NEX polling should be cleared with the Pretendo team first - it is far more load than an HTTP request.

Setup - install the sidecar, then configure it in the admin panel under Toplisten → Datenquelle & Zugangsdaten:

python3 -m venv .venv-nex
.venv-nex/bin/pip install -r scripts/requirements-nex.txt
# interpreter path goes into the form: .venv-nex/bin/python

The MK8_NEX_* environment variables still work. They seed the settings on first run and remain the fallback for anything left empty in the form, so an existing .env deployment keeps working unchanged.

One cycle reads all 48 courses through a single NEX session - the console login is the expensive part and is not repeated per course. The sidecar streams one JSON line per course as soon as it is read (--stream), so each course is archived immediately, the admin panel can show progress, and a run that dies halfway keeps everything it already delivered.

The script can also be run on its own:

.venv-nex/bin/python scripts/nex_rankings.py --check          # verify credentials
.venv-nex/bin/python scripts/nex_rankings.py --tracks 27 --limit 255
.venv-nex/bin/python scripts/nex_rankings.py --tracks all --stream

When a poll used NEX, its ranking_polls.source is nex, and view=current responses report source: "nex" and serve the full ranked list from the archive instead of the 10 entries HTTP would return. If the sidecar fails for any reason

  • expired credentials, missing dependencies, NEX unreachable - the poll falls back to HTTP and records why in the poller status. That fallback can be turned off, in which case a failed cycle is simply recorded as failed.

The plausibility cutoff still looks at ten times only. Over hundreds of entries a gap larger than a second is completely normal, so applying the "last gap" rule to a full leaderboard would push the cutoff far down and flag nearly everything.

Admin: Rankings Configuration

Endpoint: GET /admin/api/rankings/poller/config

Everything the archiver runs on: schedule, source selection and NEX credentials.

{
  "poller": {
    "pollEnabled": true,
    "pollIntervalMinutes": 10,
    "httpFallback": true,
    "includeWorst": true
  },
  "nex": {
    "enabled": true,
    "username": "pnid",
    "password": "",
    "passwordSet": true,
    "passwordType": "",
    "deviceId": "1a2b3c4d",
    "serialNumber": "FW1234567890",
    "systemVersion": "0x1010",
    "deviceCert": "",
    "deviceCertSet": true,
    "regionId": "4",
    "regionName": "EUR",
    "countryName": "DE",
    "language": "de",
    "accountUrl": "account.pretendo.cc",
    "python": "python3",
    "limit": "255",
    "delay": "0.5",
    "missing": [],
    "configured": true
  },
  "sources": { "username": "settings", "language": "default", "...": "env | settings | default | unset" },
  "scriptPresent": true,
  "status": { "...": "same shape as the poller status" }
}

The password and the device certificate are never returned - only passwordSet / deviceCertSet say whether one is stored. sources reports where each value currently comes from, so a field filled in from an .env is distinguishable from one saved through the form.

Endpoint: PUT /admin/api/rankings/poller/config

Takes the same { poller, nex } shape. Only the keys present in the body are written. For the two secrets:

value sent effect
key absent, or "" keeps the stored value
a string stores it
null clears it

deviceId and systemVersion must be hexadecimal, nex.limit is 1-1000, nex.delay is 0-60 seconds and poller.pollIntervalMinutes is 1-1440; anything else is rejected with 400. On success the archiver is restarted with the new values and the response carries the re-read configuration plus the poller status.

The two secrets are also excluded from GET /admin/api/settings and rejected by PUT /admin/api/settings/:key, so this endpoint is the only way to set them.

Admin: Check NEX Credentials

Endpoint: POST /admin/api/rankings/nex/check

Runs the sidecar's --check mode: logs in, reports the PID, fetches nothing. Returns { success, configured, pid, error, status }, where status.missingConfig lists the setting keys that are still unset.

Admin: Rankings Poller

Endpoint: POST /admin/api/rankings/poller/:action

action is one of start, stop, restart, poll (run a full cycle now) or status. Returns { success: true, status: {...} }.

While a cycle is running, status.progress reports how far along it is - a full NEX sweep takes minutes, so the admin panel polls this to drive a progress bar:

{
  "progress": {
    "source": "nex",
    "done": 31,
    "total": 48,
    "currentTrackId": 45,
    "entriesSeen": 4210
  }
}

progress is null when no cycle is in flight.

Admin: List Thresholds

Endpoint: GET /admin/api/rankings/thresholds

{
  "tracks": [
    {
      "trackId": 27,
      "trackName": "Mario Kart Stadium",
      "cupName": "Mushroom Cup",
      "archivedTimes": 56,
      "currentListSize": 10,
      "thresholdMs": null,
      "thresholdTime": null,
      "autoCutoffMs": 85270,
      "autoCutoffTime": "1:25.270",
      "autoGapMs": 83770,
      "effectiveCutoffMs": 85270,
      "effectiveCutoffTime": "1:25.270",
      "effectiveSource": "gap",
      "unrealisticCount": 2
    }
  ]
}

Admin: Set or Clear a Threshold

Endpoint: PUT /admin/api/rankings/thresholds/:trackId

Body: { "threshold": "1:23.456", "note": "optional" }

Accepts m:ss.mmm, ss.mmm or plain milliseconds. An empty string or null deletes the override and restores automatic detection. Invalid input returns 400.


Favorites

Note: All favorite endpoints require authentication.

Get Favorites

Get all favorite players for the authenticated user.

Endpoint: GET /api/favorites

Authentication: Required

Response:

{
  "favorites": [
    {
      "id": 1,
      "userId": 1,
      "playerId": 5,
      "pid": "abc123",
      "notes": "Good player",
      "notifyOnActive": true,
      "createdAt": "2025-01-01T10:00:00.000Z",
      "Player": {
        "id": 5,
        "pid": "abc123",
        "miiName": "Player1",
        "totalRaces": 150,
        "lastSeen": "2025-01-10T11:00:00.000Z"
      }
    }
  ]
}

Add Favorite

Add a player to favorites.

Endpoint: POST /api/favorites

Authentication: Required

Request Body:

{
  "playerId": 5,
  "notes": "Good player",
  "notifyOnActive": true
}

Response:

{
  "id": 1,
  "userId": 1,
  "playerId": 5,
  "pid": "abc123",
  "notes": "Good player",
  "notifyOnActive": true,
  "createdAt": "2025-01-10T12:00:00.000Z"
}

Update Favorite

Update a favorite player's settings.

Endpoint: PUT /api/favorites/:id

Authentication: Required

Parameters:

  • id: Favorite ID

Request Body:

{
  "notes": "Updated notes",
  "notifyOnActive": false
}

Response:

{
  "id": 1,
  "userId": 1,
  "playerId": 5,
  "notes": "Updated notes",
  "notifyOnActive": false
}

Delete Favorite

Remove a player from favorites.

Endpoint: DELETE /api/favorites/:id

Authentication: Required

Parameters:

  • id: Favorite ID

Response:

{
  "success": true
}

Get Active Favorites

Get favorite players who are currently in active races.

Endpoint: GET /api/favorites/active

Authentication: Required

Response:

{
  "activeFavorites": [
    {
      "favorite": {
        "id": 1,
        "playerId": 5,
        "pid": "abc123",
        "notes": "Good player"
      },
      "player": {
        "id": 5,
        "pid": "abc123",
        "miiName": "Player1",
        "totalRaces": 150
      },
      "race": {
        "id": 1,
        "gid": 123456,
        "gameModeName": "VS Race",
        "region": "Europe",
        "isActive": true
      },
      "session": {
        "joinedAt": "2025-01-10T10:30:00.000Z"
      }
    }
  ]
}

Poller

Get Poller Status

Get the current status of the API poller.

Endpoint: GET /api/poller/status

Authentication: Not required

Response:

{
  "isRunning": true,
  "pollIntervalMs": 30000,
  "apiUrl": "https://mario-kart-8.pretendo.network/api/gatherings",
  "lastPollTime": "2025-01-10T12:00:00.000Z",
  "pollCount": 1500,
  "errorCount": 0,
  "lastRaceTime": "2025-01-10T11:30:00.000Z"
}

Get Poller Errors

Get recent poller errors and error statistics.

Endpoint: GET /api/poller/errors

Authentication: Not required

Query Parameters:

  • limit (optional): Number of errors to return (default: 50, max: 500)

Example Request:

GET /api/poller/errors?limit=100

Response:

{
  "summary": {
    "total": 150,
    "last24h": 5,
    "lastError": {
      "message": "Connection timeout",
      "timestamp": "2025-01-10T11:45:00.000Z"
    }
  },
  "errors": [
    {
      "id": 150,
      "message": "Connection timeout",
      "stack": "Error: Connection timeout\n    at ...",
      "pollCount": 1500,
      "errorCount": 1,
      "timestamp": "2025-01-10T11:45:00.000Z"
    }
  ]
}

User Settings

Update Notification Settings

Update notification settings for the authenticated user.

Endpoint: PUT /api/user/notification-settings

Authentication: Required

Request Body:

{
  "notificationsEnabled": true,
  "notificationEmail": "user@example.com"
}

Response:

{
  "success": true,
  "user": {
    "id": 1,
    "username": "user",
    "notificationsEnabled": true,
    "notificationEmail": "user@example.com"
  }
}

Pretendo Profiles

All history in this logger is keyed by the Pretendo player ID, not by the account: lobbies via players.pid, VR points via the player record, time trial times via track_times.pid. An account that simply re-points at a new Pretendo ID would stop seeing everything the old ID recorded.

An account can therefore own several profiles:

  • The primary profile (users.pretendoPid) is where new data is written: VR uploads, hand-entered times and the Aroma plugin token file.
  • Every linked profile counts as the user's own when reading: profile page, VR history, the own VR permission, time submissions, and the registered-player markers.

Linking a second profile requires the per-user multiPretendoLink permission, which an admin grants in the user dialog. Without it, linking a new profile replaces the old one (the behaviour before this existed). Unlinking never deletes recorded data - it only stops being shown as this account's own.

List Linked Profiles

Endpoint: GET /api/user/pretendo-links

Authentication: Required

Response:

{
  "links": [
    { "pid": "1234567890", "miiName": "NewMii", "isPrimary": true, "linkedAt": "2026-08-01T10:00:00.000Z" },
    { "pid": "9876543210", "miiName": "OldMii", "isPrimary": false, "linkedAt": "2025-03-14T09:00:00.000Z" }
  ],
  "multiPretendoLink": true
}

Link a Profile

Endpoint: POST /api/user/link-pretendo

Authentication: Required

Request Body:

{
  "pretendoPid": "9876543210",
  "pretendoName": "OldMii"
}

Response:

{
  "success": true,
  "message": "Pretendo profile linked - your existing profiles and their history are kept",
  "action": "linked_additional",
  "replaced": false,
  "links": [ /* as above */ ]
}
action Meaning
linked_primary First profile of the account, became the primary one
linked_additional Added alongside the existing profiles (needs multiPretendoLink)
primary_switched Already linked, is now the primary profile
replaced Replaced the previous profile (account without multiPretendoLink)
unchanged Already the primary profile

Errors:

  • 409 PID_TAKEN — the profile is already linked to another account
  • 400 INVALID_PID — the player ID is not numeric

Switch the Primary Profile

Endpoint: PUT /api/user/pretendo-links/:pid/primary

Authentication: Required — the player ID must already be linked to the account

Response: { "success": true, "links": [ /* … */ ] }

Console Assignments

The Aroma plugin uploads with an account token and the console serial, but no player ID. For an account with several profiles, the console decides which profile an upload is recorded for. The mapping is learned from the VR history: whichever linked profile has been receiving points from that serial keeps receiving them.

A console with no earlier uploads has nothing to learn from and falls back to the primary profile; that assumption is stored and reported as evidenceCount: 0, and the user can correct it. A manual assignment is never overwritten by the learning path.

Note that a serial identifies a console, not an account — two profiles played on the same console cannot be told apart this way.

Endpoint: GET /api/user/console-links

Authentication: Required

Response:

{
  "consoles": [
    {
      "consoleSerial": "WUP10123456789",
      "pid": "9876543210",
      "miiName": "OldMii",
      "source": "learned",
      "evidenceCount": 12,
      "uploadCount": 13,
      "lastUsedAt": "2026-08-10T19:00:00.000Z",
      "stale": false
    }
  ],
  "links": [ /* the account's Pretendo profiles */ ]
}
Field Description
source learned (derived from the VR history) or manual (set by the user)
evidenceCount VR points backing a learned mapping; 0 means the primary profile was assumed
uploadCount VR points recorded from this console for the assigned profile
stale The assigned profile is no longer linked - the next upload re-learns the mapping

Assign a console: PUT /api/user/console-links/:consoleSerial with { "pid": "9876543210" } — stores a manual mapping. The profile must be linked to the account (404 NOT_LINKED otherwise).

Forget a console: DELETE /api/user/console-links/:consoleSerial — the next upload works the assignment out again.

Both return { "success": true, "consoles": [ /* … */ ] }. Changing an assignment affects future uploads only; VR points already recorded stay where they are.

Unlink a Single Profile

Endpoint: DELETE /api/user/pretendo-links/:pid

Authentication: Required

Unlinking the primary profile promotes the oldest remaining one, so an account never keeps profiles it cannot write to. Removing the last profile clears the account's link entirely. To unlink everything at once use POST /auth/profile/unlink-pretendo.

Response: { "success": true, "links": [ /* remaining profiles */ ] }

Get API Token

Whether the signed-in account has an API token (for the MK8Logger plugin and the /api/v1 endpoints), shortened unless asked for in full.

Endpoint: GET /api/user/api-token

Authentication: Required (session)

Query Parameters:

  • full (optional): 1 returns the whole token instead of the shortened one

Response:

{ "hasToken": true, "token": "a1b2c3d4...9f8e7d6c", "full": false }

token is null without a token.

Generate API Token

Creates a new token. The previous token stops working immediately - the plugin needs the new token file.

Endpoint: POST /api/user/api-token/generate

Authentication: Required (session)

Response:

{ "success": true, "token": "a1b2c3d4e5f6…" }

Download Token File

The token as <pid>_api_token.txt, the file the MK8Logger plugin reads from the SD card.

Endpoint: GET /api/user/api-token/download

Authentication: Required (session)

Response: text/plain attachment. 404 without a token, 400 without a linked Pretendo profile (the file name contains the PID).

Revoke API Token

Deletes the token; plugin uploads with it are rejected from then on.

Endpoint: DELETE /api/user/api-token

Authentication: Required (session)

Response:

{ "success": true, "message": "API token revoked" }

Update Preferences

Language and time zone of the account.

Endpoint: PUT /api/user/preferences

Authentication: Required (session)

Request Body (both optional):

{ "language": "de", "timezone": "Europe/Berlin" }
  • language: de or en
  • timezone: an IANA time zone name; times on the pages are shown in it

Response:

{ "success": true, "message": "Preferences updated successfully" }

Check for Races Since Sign-In

Whether one of the account's players was in a lobby since the session started (or in the last 24 hours). The site uses it to suggest entering VR.

Endpoint: GET /api/user/check-recent-races

Authentication: Required (session)

Response:

{ "hasRacedSinceLogin": true, "playerId": 5, "lastRaceTime": "2026-09-27T19:02:11.000Z" }

Without a linked Pretendo profile: { "hasRacedSinceLogin": false }.

Download Files

The MK8Logger plugin and its manuals. Each download is recorded in the activity log.

Endpoint: GET /api/downloads/:filename

Authentication: Required (session)

Files: mk8logger.wps (download), BENUTZERHANDBUCH.md, USER_GUIDE.md (shown in the browser). Any other name: 404.

The manuals and this API documentation can also be read as pages: /docs/benutzerhandbuch, /docs/user-guide and /docs/api (?format=md for the Markdown file).

Set Language Preference

The DE/EN switch of the website. Sets the lang cookie; for a signed-in account the language is also stored as the account's language, which every page uses from then on. An explicit ?lang=de|en in a page address still wins for that one page.

Endpoint: POST /api/language

Authentication: Not required

Request Body:

{
  "lang": "de"
}

Response:

{
  "success": true,
  "language": "de",
  "saved": true
}

saved is true when the language was stored in the account (signed in).


VR Points

VR Points (VR rating) is a player skill ranking system tracked separately for VS Race and Battle game modes. Players' VR points can be viewed and updated through the API.

Get VR Points History

Get the complete VR points history for a specific player.

A VR history belongs to the person, not to a single Pretendo profile. When the player is linked to an account that owns several profiles, the response covers all of them — otherwise the curve would break off at the point where the account switched profiles. Every entry names the profile it belongs to, and players lists the profiles the history is made of, the requested one first. A player nobody has linked simply answers with its own history.

Endpoint: GET /api/vr-points/:playerId

Authentication: Not required

Parameters:

  • playerId: Player ID (numeric database ID)

Query parameters:

  • vrType (optional): vs_race or battle
  • limit (optional): maximum number of entries

Response: Entries are ordered newest first, across profiles.

{
  "vrPoints": [
    {
      "id": 2,
      "playerId": 6,
      "vrType": "vs_race",
      "points": 1200,
      "recordedAt": "2026-02-03T10:00:00.000Z",
      "consoleSerial": "WUP20987654321",
      "player": { "id": 6, "pid": "2222222222", "miiName": "NewMii" },
      "enteredByUser": { "id": 1, "username": "someone" }
    },
    {
      "id": 1,
      "playerId": 5,
      "vrType": "vs_race",
      "points": 4100,
      "recordedAt": "2026-01-02T10:00:00.000Z",
      "consoleSerial": "WUP10123456789",
      "player": { "id": 5, "pid": "1111111111", "miiName": "OldMii" },
      "enteredByUser": { "id": 1, "username": "someone" }
    }
  ],
  "players": [
    { "id": 5, "pid": "1111111111", "miiName": "OldMii" },
    { "id": 6, "pid": "2222222222", "miiName": "NewMii" }
  ]
}

Returns 404 when the player does not exist.

Note: VR does not carry over between Pretendo profiles, so the web UI draws one line per profile by default rather than joining them into a single curve. The lines can also be split by console, or by both at once.

Get Latest VR Points

Get the most recent VR points for a specific player (one entry per game mode).

Endpoint: GET /api/vr-points/:playerId/latest

Authentication: Not required

Parameters:

  • playerId: Player ID (numeric database ID)

Response:

{
  "vrPoints": [
    {
      "id": 1,
      "playerId": 5,
      "gameMode": 0,
      "gameModeName": "VS Race",
      "vrPoints": 5000,
      "createdAt": "2025-01-10T10:00:00.000Z",
      "updatedAt": "2025-01-10T10:00:00.000Z"
    },
    {
      "id": 2,
      "playerId": 5,
      "gameMode": 1,
      "gameModeName": "Battle",
      "vrPoints": 4500,
      "createdAt": "2025-01-10T10:00:00.000Z",
      "updatedAt": "2025-01-10T10:00:00.000Z"
    }
  ]
}

Key Fields:

  • gameMode: 0 for VS Race, 1 for Battle (other game modes don't have VR points)
  • gameModeName: Human-readable game mode name
  • vrPoints: Current VR rating for this game mode
  • updatedAt: Last time this VR rating was updated

Add or Update VR Points

Add new VR points entries or update existing ones for multiple players.

Endpoint: POST /api/vr-points

Authentication: Required

Request Body:

{
  "vrPoints": [
    {
      "playerId": 5,
      "gameMode": 0,
      "vrPoints": 5100
    },
    {
      "playerId": 5,
      "gameMode": 1,
      "vrPoints": 4600
    },
    {
      "playerId": 10,
      "gameMode": 0,
      "vrPoints": 4800
    }
  ]
}

Response:

{
  "success": true,
  "message": "VR Points gespeichert",
  "created": 3,
  "updated": 0
}

Response Fields:

  • created: Number of new VR point entries created
  • updated: Number of existing VR point entries updated

Notes:

  • If a VR point entry already exists for the player and game mode, it will be updated
  • If no entry exists, a new one will be created
  • Multiple players and game modes can be updated in a single request
  • Only game modes 0 (VS Race) and 1 (Battle) support VR points

Compare VR of Several Players

The full VR history of two or more players at once, for comparison charts.

Endpoint: GET /api/vr-points/compare

Authentication: Not required

Query Parameters:

  • playerIds (required): comma-separated player IDs, at least two

Response:

{
  "players": [
    {
      "id": 5, "pid": 1311404083, "miiName": "Achim",
      "vrPoints": [
        { "id": 16712, "playerId": 5, "vrType": "vs_race", "points": 8802,
          "recordedAt": "2026-09-27T19:02:11.000Z", "enteredBy": 4,
          "consoleSerial": null, "source": "race_result" }
      ]
    }
  ]
}

vrPoints are newest first and contain both vs_race and battle. 400 with fewer than two IDs.

Get Latest VR of Several Players

Latest and highest VR per player in one request (lobby cards, lists).

Endpoint: GET /api/vr-points/batch-latest

Authentication: Not required

Query Parameters:

  • playerIds (required): comma-separated player IDs

Response: an object keyed by player ID; players without any VR are left out.

{
  "5": {
    "vsRace": 8802, "vsRaceAt": "2026-09-27T19:02:11.000Z",
    "battle": 1040, "battleAt": "2026-08-02T18:00:00.000Z",
    "maxVsRace": 8900, "maxBattle": 1100
  }
}

Token-Based API (v1)

These endpoints use API token authentication (Bearer token or ?token= query parameter) instead of session cookies. See the VR Points API Guide for detailed usage.

User Info

Retrieve settings and profile information for the authenticated token owner.

Endpoint: GET /api/v1/me

Authentication: API Token (Bearer or query parameter)

Response:

{
  "id": 1,
  "username": "MeinName",
  "pretendoPid": 1234567890,
  "pretendoName": "MeinMiiName",
  "aromaDebug": false,
  "manualVrPermission": "all",
  "linkedPids": ["1234567890", "9876543210"],
  "multiPretendoLink": true
}
Field Type Description
id integer User ID
username string Username
pretendoPid integer|null Primary Pretendo Player ID - uploads are recorded for this profile
pretendoName string|null Mii name of the primary profile
aromaDebug boolean Whether Aroma Plugin debugging is enabled (admin-controlled)
manualVrPermission string VR entry permission: "all", "own", or "none"
linkedPids string[] Every Pretendo Player ID the account owns, primary first
multiPretendoLink boolean Whether the account may link further profiles (admin-controlled)

Note: pretendoPid is the profile an upload without a recognised console serial is recorded for. When the upload carries a consoleSerial that is mapped to one of the other linkedPids, that profile wins - see Console Assignments.

VR Points Upload

See VR Points API Guide for:

  • POST /api/v1/vr-points — Single player upload
  • POST /api/v1/vr-points/batch — Batch upload for multiple players
  • GET /api/v1/vr-points/latest — Latest VR points
  • GET /api/v1/vr-points/sync/:consoleSerial — Console sync

Race Results Upload

Upload the result of one online race. The Aroma plugin calls this at the end of every race with the placement, race points and VR of all participants, read from game memory.

Endpoint: POST /api/v1/race-results

Authentication: API Token (Bearer or query parameter). The account needs a linked Pretendo profile.

Plugin version: Since v0.12.00 the plugin sends User-Agent: MK8Logger/<version> (e.g. MK8Logger/v0.12.00) with every request. The version is stored with the activity log entry of each upload (race results and VR) and shown in the admin activity log. Uploads that fail with a server error are logged there too.

Request Body:

{
  "consoleSerial": "FW123456789",
  "gameMode": 0,
  "courseId": null,
  "players": [
    { "rank": 1, "points": 15, "miiName": "Kurt", "pid": 1471493879, "vr": 48477,
      "countryId": 49, "regionId": 50 },
    { "rank": 2, "points": 12, "miiName": "PM", "pid": 1234567890, "vr": 9629,
      "countryId": 78, "regionId": 11 },
    { "rank": 6, "points": 7, "miiName": "Ajadue", "vr": 1333, "vrChange": 13,
      "vrExact": 1333.24, "vrBeforeExact": 1320.715,
      "countryId": 78, "regionId": 11, "isLocal": true,
      "miiCreateId": "dbfba88003b3b88d27d9" }
  ]
}
Field Type Description
consoleSerial string Optional. Decides which of the account's profiles uploads, as for VR uploads
gameMode integer Optional. 0 = VS Race, 1 = Battle
courseId integer Optional. Course ID as in the rankings (16 = Mario Circuit … 63 = Big Blue). An unknown ID is stored as null; the race itself is kept
players array 1-12 participants, spectators left out
players[].rank integer Placement 1-12, unique within the race
players[].points integer Race points 0-20 (12 players: 15, 12, 10, 9 … 1)
players[].miiName string Mii name as shown in game, 1-40 characters
players[].pid integer Optional. Pretendo PID as the game keeps it for remote players (not for local players, guests or CPUs)
players[].vr number Optional. VR (BR in battle) after the race, 0-99999, rounded
players[].vrChange number Optional. VR change in this race, if the plugin saw the value before
players[].vrExact number Optional (plugin v0.13.00+). VR after the race with decimals, as the game keeps it between races. Dropped when its whole part is not vr
players[].vrBeforeExact number Optional (plugin v0.13.00+). VR at the start of the race with decimals. Dropped when its whole part is not vr - vrChange
players[].countryId integer Optional. Country as used by the game (e.g. 78 = Germany, 49 = USA)
players[].regionId integer Optional. Region inside the country
players[].miiCreateId string Optional. Mii create ID, 20 hex characters
players[].driverId integer Optional. Driver as numbered by the game (0 Mario … 36 Villager girl), 0-63
players[].bodyId integer Optional. Kart/bike/ATV body (0 Standard Kart … 38 Bone Rattler), 0-63
players[].tireId integer Optional. Tires (0 Standard … 20 Leaf Tires), 0-63
players[].gliderId integer Optional. Glider (0 Super Glider … 13 Paper Glider), 0-15
players[].colorVariant integer Optional. Color of Yoshi / Shy Guy, suit of a Mii, 0-255
players[].miiData string Optional. The Mii as Wii U Mii data (FFLStoreData, 96 bytes = 192 hex characters). Unusable data is dropped, the race is kept
players[].isLocal boolean The uploader's own player. At most one per upload
players[].isGuest boolean A local co-op guest on one of the consoles
players[].guestOfRank integer Optional, guests only. Placement of the guest's host (the main player on the same console) in this race
items array Optional (plugin v0.21.00+). Every item box hit of the race: { "p": 2, "item": 8, "rank": 1, "t": 64230 } - p is the index in players, item the item number 0-20 (0 Banana, 1 Green Shell, 2 Red Shell, 3 Mushroom, 4 Bob-omb, 5 Blooper, 6 Spiny Shell, 7 Triple Mushroom, 8 Super Star, 9 Bullet Bill, 10 Lightning, 11 Golden Mushroom, 12 Fire Flower, 13 Piranha Plant, 14 Boomerang Flower, 15 Coin, 16 Super Horn, 17 Triple Bananas, 18 Triple Green Shells, 19 Triple Red Shells, 20 Crazy Eight), rank the place at the hit, t the race time in ms. From plugin v0.24.00 also d and d0 (distance to the leader in game units when the plugin noticed the hit and one poll before) and s (kart flag Kart+0x66, 0 = player, 1 = CPU); from plugin v0.27.00 tb, the item table set the game uses (0-19: VS normal 2, mushrooms 4, shells 6, bananas 8, bob-ombs 10, frantic 18, +1 for CPUs). GET /api/race-rounds/:id adds band and chance (%) for the measured distance and impossible per item: the game's own table (src/services/itemTables.js, from common/mush/Item.bin) never hands out the item on that place (place 1: distance 0; other places: in no band). Up to 400; invalid entries are skipped. When several consoles report the race, the first upload with items keeps them

Item statistics: GET /api/race-rounds/:id lists each player's items (itemId, place, raceTimeMs, in race order) when the race has an item log (hasItems). GET /api/stats/item-distribution?days= gives the hits per place and item over all VS races, GET /api/players/:id/items a person's items with score.rare (items under 2 % of the hits on that place among all other players) against score.expected, and strongFront: Super Star, Bullet Bill and Golden Mushroom on places 1-3 against how often all other players get them there. Admins get the players standing out most under GET /admin/api/items/suspicious?front=3 (players for rare items in general, strongFront with the races of the hits).

Matching players: The game keeps the Pretendo PID of every remote player for the race, and the plugin sends it as pid. Those players are matched exactly. For players without a PID the server looks up the lobby the uploader is in (from the poller's sessions) and matches the Mii name against the players of that lobby. matchStatus in the response tells how each player was resolved:

matchStatus Meaning
uploader The uploader's own profile (isLocal)
pid PID reported by the plugin; playerId is null if the poller has not seen that player yet
name Exactly one player with this Mii name was in the lobby
ambiguous Several players with this name were in the lobby - left open
unmatched No player with this name was in the lobby (or no lobby found)
guest Local co-op guest

VR of the other players: After a VS race (gameMode 0) the VR of every player matched for sure (matchStatus pid or name) is stored as their current VS Race VR, marked source: "race_result". Guests, ambiguous or unmatched names and the uploader are left out - the uploader's plugin uploads the exact value from the savegame. These entries count on the website like any other VR entry, but GET /api/v1/vr-points/latest ignores them, so a stranger's observation never hides a player's own entry from another console. A race reported by several users stores the VR only once.

Guests: A guest whose host is known (guestOfRank pointing to a matched player) gets a session in the lobby, the same entry "add guest" creates by hand (isGuest, guestNickname, guestOfPlayerId). It shows up in the participant list and the guest statistics. A guest reported with pid (signed in with their own account on the host's console) is linked to their player profile. The guest keeps this session over further races (matched by host and name) and leaves together with the host: the poller ends guest sessions whose host is no longer in the lobby. Race results return guestOfPlayerId and guestOfMiiName for guests.

Mii, country and region: Every Mii is stored once (by the SHA-1 of its data) and returned as miiHash per player. For matched players the latest Mii, countryId and regionId are kept on the player (GET /api/players/:id returns miiHash, countryId, regionId, and miiHistory: every Mii the person used in a reported race with firstSeen, lastSeen and raceCount, newest first, across all their Pretendo profiles). combos lists the driver/vehicle combinations with raceCount and lastUsed, most used first; names for the IDs are in MK8_PARTS in public/js/app.js. Values outside the ranges above are dropped, the race is kept. GET /api/players/search returns miiHash per person.

Duplicates: Everyone in the lobby who runs the plugin uploads the same race. Uploads with the same participants, placements and points within 90 seconds are merged into one race. uploadCount counts the different users that reported it; a repeated upload of the same user is not counted twice. A later upload fills in players the first one could not match, a missing vrChange and missing vrExact/vrBeforeExact.

Invalid details: Only a race that is unusable as a whole is rejected with 400: no players, more than 12, a placement outside 1-12 or a placement that occurs twice, an unknown gameMode. Anything wrong with a single participant is corrected instead, and the race is stored: an empty miiName becomes ? (longer than 40 characters is shortened), invalid points become 0, invalid vr, vrChange, vrExact, vrBeforeExact, countryId, regionId, pid, miiCreateId, miiData and guestOfRank are dropped, a pid reported for several participants is dropped for all of them, and only the first isLocal participant counts as the uploader. Every correction is listed in warnings in the response and in the activity log entry.

Response: 201 Created for a new race, 200 OK when merged into an existing one.

{
  "success": true,
  "created": true,
  "merged": false,
  "warnings": [],
  "raceRound": {
    "id": 17,
    "raceId": 4711,
    "finishedAt": "2026-09-25T14:39:41.000Z",
    "gameMode": 0,
    "courseId": 27,
    "courseName": "Mario Kart Stadium",
    "playerCount": 12,
    "uploadCount": 1,
    "players": [
      { "rank": 1, "points": 15, "miiName": "Kurt", "playerId": 88, "pid": "1471493879",
        "matchStatus": "name", "vr": 48477, "vrChange": null, "countryId": 49,
        "regionId": 50, "isGuest": false }
    ]
  }
}

raceId is the lobby the race belongs to, or null when the uploader was not found in any lobby (the race is stored anyway).

Race Start

Report the course of a race that just started, so the website can show what the lobby is racing right now. The Aroma plugin calls this at the start of every online race.

Endpoint: POST /api/v1/race-start

Authentication: API Token. The account needs a linked Pretendo profile.

Request Body:

{
  "consoleSerial": "FW123456789",
  "courseId": 27,
  "players": [
    { "miiName": "Kaito", "isLocal": true, "vr": 12900.375 },
    { "miiName": "Mira", "pid": 1234567890, "vr": 18200.5 },
    { "miiName": "Wes", "isGuest": true }
  ]
}

courseId must be a known course ID (see the rankings), otherwise 400. players (optional, plugin v0.16.00+) are the racers of this race. They are matched to players like the participants of a result (uploader, PID, otherwise the Mii name in the lobby); an invalid list is ignored. Everybody else in the lobby is watching, and after the result everybody is waiting for the next race: see phase in Get Race Rounds and phase/racePlayers[].raceStatus (racing, watching, waiting) in Get Active Races. Both are only set while such a plugin reports from the lobby (up to 5 minutes after the last start or result).

players[].vr (optional, plugin v0.18.00+) is the racer's VR at the start with its decimals (0–99999.5). From it the server works out what is at stake: every racer of phase.racers gets vr (rating used), vrSource (start = sent here, last = the player's last known VR, default = unknown, 1000 assumed), win (VR change as 1st) and last (VR change as last), with the game's formula. The lobby page shows them as "Was steht auf dem Spiel".

Response:

{ "success": true, "raceId": 4711, "courseId": 27, "courseName": "Mario Kart Stadium" }

raceId is null when the uploader was not found in any lobby; nothing is stored then. The course is kept in memory only: it shows as current in Get Race Rounds and as currentCourse per lobby in Get Active Races until the race result arrives or for at most 5 minutes, and is forgotten on a server restart.

The responses of Race Start and Race Results Upload also carry notices, the same list as Event Status, so the plugin can show a race result with the provisional event place right away.

Time Trial Ghosts

From plugin v0.31.00 the own ghosts of the MK8 savegame (gs…dat, one per course) go to mk8u.de. The game names the file after its content: course, combo, time, lap times, Mii name and country (see src/services/ttGhosts.js); the file starts with CTG0.

Endpoint: POST /api/v1/time-trials/ghosts/check (API Token) { "consoleSerial": "…", "names": ["gs….dat", …] } → { "success": true, "missing": ["gs….dat"] }

  • the file names not uploaded yet for the player of this console.

Endpoint: POST /api/v1/time-trials/ghosts?name=gs….dat&serial=… (API Token, Content-Type: application/octet-stream, body = the file, up to 1 MB) → 201 { "success": true, "id": 12, "created": true, "courseId": 16, "timeMs": 117799 } (200 with created: false for a file already stored). 400 for downloaded ghosts (dg…), unknown courses, lap times that do not add up to the time, or a file that is no ghost.

GET /api/time-trials/ghosts/:id/file (signed in) downloads the file under its savegame name. Ranking entries (GET /api/rankings/track/:id, /api/rankings/time/:id) carry ghost: { id, laps: [ms…], recordedAt, url } when the player uploaded a ghost with exactly that time.

Race Telemetry

From plugin v0.30.00 the item box hits and the course of the race no longer come with the result: about 10 s later the plugin sends them to the race it reported.

Endpoint: POST /api/v1/race-rounds/:id/telemetry (API Token, only accounts that reported the race)

{
  "karts": [3, 1, 4, 2],
  "items": [{ "p": 1, "item": 8, "rank": 1, "t": 64230, "d": 0, "s": 0, "tb": 2 }],
  "track": { "laps": 3, "t": [1000, 2000, 3000], "p": [[120, 260, 400], [150, 300, 455], [90, 210, null], [140, 290, 430]] }
}

karts[i] is the finishing rank of kart index i; items use the same fields as in the race result (p = kart index). track.t is the race time of each sample in ms (one per second), track.p[i] the progress of kart i at each sample: lap + share of the lap, times 1000 (0 .. laps * 1000, null = unknown). Items are stored when the race has none yet; the course replaces a stored one only when it has more samples. Response: { "success": true, "itemsStored": 40, "trackStored": true, "warnings": [] }.

GET /api/race-rounds/:id/track returns the stored course as { "laps": 3, "t": [...], "racers": [{ "rank": 1, "p": [...] }] } (404 without one); GET /api/race-rounds/:id says hasTrack.

Race Screenshot

A screenshot taken in-game during a race with the ScreenshotWUPS plugin. The Aroma plugin (v0.22.00+) finds the screenshots taken between the race start and shortly after the result and sends them one by one after the race result.

Endpoint: POST /api/v1/race-rounds/:id/screenshots?screen=tv&takenAt=2026-10-04T21:14:07

Authentication: API Token. Only accounts that reported this race (its uploaderIds) may add screenshots, within 24 hours of the race and up to the admin setting race_screenshots_max per account and race (default 10, at most 50, 0 switches race screenshots off). The plugin stops at the first 429.

Body: the image file itself (Content-Type: image/jpeg or image/png, up to 6 MB). screen (tv or drc) and takenAt (console time) are optional and come from the ScreenshotWUPS file name. The image is re-encoded as JPEG fitting 1280×720.

Response: 201 { "success": true, "id": 17, "raceRoundId": 4711 }; 400 when the body is no JPEG/PNG, 403 for an account that did not report the race, 429 when the limit is reached.

Signed-in users see the screenshots in screenshots of GET /api/race-rounds/:id (file under /api/race-screenshots/:id/file); the uploader and admins can delete one with DELETE /api/race-screenshots/:id.

Time Trial Best

A new personal best from a time trial. The Aroma plugin (v0.17.00+) reads the finishing time in-game and sends it only when the game itself counts it as a new record (faster than the saved ghost, under 5:00.000) - the same check as the game's result screen. 150cc only (Mario Kart 8 on the Wii U has no other time trials).

Endpoint: POST /api/v1/time-trials

Authentication: API Token. The account needs a linked Pretendo profile; the time is stored for the profile the console uploads for.

Request Body:

{
  "consoleSerial": "A1B2C3D4E5F60708",
  "courseId": 40,
  "timeMs": 111234,
  "previousBestMs": 112050,
  "driverId": 4, "bodyId": 29, "tireId": 14, "gliderId": 11, "colorVariant": 0,
  "miiData": "030000…(192 hex characters)"
}

courseId is a course ID of the rankings (16-63), timeMs 30 s to 4:59.999. previousBestMs, the parts and miiData are optional. Driver/vehicle (IDs as in race results) and the Mii (Wii U Mii data, plugin v0.23.00+) are stored with the time and shown in the rankings; the Mii also becomes the player's picture. A known time that had no combo yet gets it from a later upload.

Response: 201 for a new time, 200 when it is already in the archive:

{ "success": true, "status": "approved", "trackTimeId": 51234, "time": "1:51.234" }

status: approved - shown in the rankings right away (source "plugin"); pending - faster than the course's plausibility cutoff (the admin's manual threshold, or the gap rule over the current top 10 including this time), waits in Admin → Rankings like a hand-entered time; known - this time is already archived.

Time Trial Import

All saved time trial bests of the console user at once. The Aroma plugin (v0.19.00+) sends them only when the user starts "Import time trial bests" in the plugin menu; it reads the saved best of every course (the game's ghost list), up to 48 times.

Endpoint: POST /api/v1/time-trials/import

Authentication: API Token, as for Time Trial Best.

Request Body:

{
  "consoleSerial": "A1B2C3D4E5F60708",
  "times": [ { "courseId": 23, "timeMs": 151053 }, { "courseId": 40, "timeMs": 111234 } ]
}

Every entry is checked like a single Time Trial Best: already archived times are skipped, times faster than the plausibility cutoff wait for an admin (one mail for the whole import). Invalid entries (unknown course, time outside 30 s to 4:59.999, course twice) are counted and skipped. 400 without a list of 1 to 48 entries.

Response:

{ "success": true, "approved": 30, "pending": 1, "known": 12, "invalid": 0,
  "summary": "Time trial import: 30 new, 1 wait for approval, 12 already known" }

summary is the line the plugin shows in-game.

Time Trial Ghost List

The rankings Mario Kart 8 loads for the ghost download of a course: the top 10 and the list around the player's own rank. The Aroma plugin (v0.20.00+) reads both from the game's memory and sends them whenever they change (setting "Upload ghost download rankings to mk8u.de", on by default).

Endpoint: POST /api/v1/time-trials/ghost-list

Authentication: API Token.

Request Body:

{
  "consoleSerial": "A1B2C3D4E5F60708",
  "courseId": 23,
  "top": [ { "pid": 1311404083, "rank": 1, "timeMs": 128517, "miiName": "Kaito" } ],
  "around": [ { "pid": 1402030405, "rank": 57, "timeMs": 131050, "miiName": "Mira" } ]
}

The top 10 is already archived from the Pretendo API and is only used as a check: at least 3 entries with rank 1-10 must be sent, and 70 % of them must match an archived time (same PID and time). Otherwise nothing is stored (verified: false), so a wrong memory read can never put times into the archive. When the check passes, entries ranked 11 and below are archived with source ghost_list (approved right away, the rank goes to bestRank); known times only get lastSeenAt and a better bestRank, and a pending time found in the ranking is approved.

Response:

{ "success": true, "verified": true, "compared": 10, "matched": 10,
  "created": 8, "updated": 2, "confirmed": 0,
  "summary": "Ghost list: 8 new, 2 known (check 10/10)" }

Event Status

News of the events the user races in, for the Aroma plugin to show in-game: the event start, "your lobby is being scored", the result of the latest scored race with the provisional place, and the final standings. The plugin shows every key once and asks again after pollSeconds. Each call marks the user's plugin as running, which is what the event mods need before they can start scoring a lobby (at least two racers with the plugin running).

Endpoint: GET /api/v1/events/status

Authentication: API Token.

Response:

{
  "success": true,
  "notices": [
    { "key": "e3-start", "text": "Friday Night Frenzy laeuft - such dir eine VS-Lobby!" },
    { "key": "e3-r5512", "text": "Marios Piste: 2. Platz (+12 Pkt.) - vorlaeufig 3. mit 58 Pkt. (6 hinter Yuki)" }
  ],
  "pollSeconds": 60,
  "events": 1
}

Texts are in the account's language and plain ASCII (umlauts written as ae/oe/ue). Only events from 30 minutes before their start until 2 hours after their end count; pollSeconds is 60 while one of them runs, 120 around them, 300 without any.

Plugin update

The plugin (v0.28.00+) asks at most once a day for the plugin file an admin uploaded and, when its version is newer, downloads it and replaces its own .wps on the SD card (only when size and SHA-256 match). Active from the next console start.

Endpoint: GET /api/v1/plugin/latest (API Token)

{ "success": true, "available": true, "version": "v0.28.00", "size": 412345, "sha256": "9f2c…" }

available is false without an uploaded file or without a version set in the admin area.

Endpoint: GET /api/v1/plugin/download (API Token) - the .wps file itself (logged as a plugin download).


Guest Players

Guest players are local co-op players who join races alongside the main player. Guest functionality allows tracking guest player nicknames and linking them to their main player account.

Guest Player Features

Nickname History:

  • Player statistics now include comprehensive nickname history from both sources:
    • Pretendo API names (official names from the network)
    • Guest player names (local co-op nicknames)
  • Each nickname entry shows:
    • First and last usage timestamps
    • Total usage count
    • Source (Pretendo API or Guest)

Player Search Enhancement:

  • Player search now includes historical nicknames from both Pretendo API and guest entries
  • Searching for a guest nickname will find the associated main player

Guest Management in the UI:

  • The lobby page lists the guests; participants and admins add, link and remove them there
  • The MK8Logger plugin reports guests with their host along with each race result
  • The player page lists the names a player was entered under as a guest

Who May Manage Guests

Adding, linking and removing guests requires a session and is allowed for admins and for accounts one of whose players was in the lobby. Otherwise 403.

Get Guests of a Lobby

Endpoint: GET /api/races/:id/guests

Authentication: Not required

Response:

{
  "raceId": "110535",
  "guestCount": 1,
  "guests": [
    {
      "id": 88213,
      "playerId": null,
      "displayName": "Mia",
      "guestNickname": "Mia",
      "guestOfPlayer": { "id": 5, "pid": 1311404083, "miiName": "Achim" },
      "joinedAt": "2026-09-27T18:40:00.000Z",
      "leftAt": null,
      "isActive": true
    }
  ]
}

id is the session (race player) ID used by the endpoints below. playerId is set once the guest is linked to a player; displayName is then that player's Mii name.

Add a Guest

Endpoint: POST /api/races/:id/add-guest

Authentication: Required (session, see above)

Request Body:

{ "guestOfPlayerId": 5, "nickname": "Mia", "playerId": null, "joinedAt": "2026-09-27T18:40:00Z" }
  • guestOfPlayerId (required): the player who brought the guest (the console's owner)
  • nickname or playerId (one of them required): the guest's name, and/or the player the guest is known as
  • joinedAt (optional): default now

Response:

{ "success": true, "guest": { "id": 88213, "raceId": 110535, "playerId": null, "guestNickname": "Mia", "isGuest": true, "guestOfPlayerId": 5, "player": null, "guestOfPlayer": { "id": 5, "pid": 1311404083, "miiName": "Achim" } } }

The MK8Logger plugin reports guests itself with each race result; this endpoint is for adding one by hand.

Link a Guest to a Player

Endpoint: PUT /api/race-players/:id/link-player

Authentication: Required (session, see above)

Request Body:

{ "playerId": 12 }

Response:

{ "success": true, "racePlayer": { "id": 88213, "playerId": 12, "guestNickname": "Mia", "player": { "id": 12, "pid": 1356763971, "miiName": "Dmi" }, "guestOfPlayer": { "id": 5, "pid": 1311404083, "miiName": "Achim" } } }

Errors:

  • 400: playerId missing, or the entry is not a guest
  • 404: session or player not found
  • 409 with "code": "ALREADY_IN_GATHERING": the player is already in this lobby themselves; error holds a message in the page language

Remove a Guest

Endpoint: DELETE /api/race-players/:id

Authentication: Required (session, see above)

Response:

{ "success": true, "message": "Guest player removed" }

Only guest entries can be removed (400 otherwise).

Get Frequent Guests of a Player

Everyone a player has brought along as a guest, most frequent first - across all of the person's Pretendo profiles.

Endpoint: GET /api/players/:id/frequent-guests

Authentication: Not required

Response:

{
  "playerId": "5",
  "playerName": "Achim",
  "totalGuestSessions": 9,
  "uniqueGuests": 2,
  "frequentGuests": [
    { "playerId": null, "pid": null, "nickname": "Mia", "displayName": "Mia",
      "raceCount": 7, "firstSeen": "2026-08-01T17:00:00.000Z", "lastSeen": "2026-09-27T18:40:00.000Z" }
  ]
}

raceCount counts sessions (lobbies) with that guest.

Search Players for a Guest

Players whose Mii name contains the text and who were in at least one lobby, most active first (max. 10) - to find out whether a guest has a profile of their own.

Endpoint: GET /api/players/search-by-nickname

Authentication: Not required

Query Parameters:

  • nickname (required): at least 2 characters, otherwise the list is empty

Response:

{
  "players": [
    { "id": 12, "pid": 1356763971, "miiName": "Dmi", "raceCount": 188, "knownSince": "2026-06-19T10:12:00.000Z" }
  ]
}

Guest Player Data in API Responses

When fetching player statistics, the response includes a nicknameHistory field:

Example from GET /api/players/:id/stats:

{
  "player": {
    "id": 5,
    "pid": "abc123",
    "miiName": "Player1"
  },
  "nicknameHistory": [
    {
      "nickname": "Player1",
      "source": "pretendo",
      "firstUsed": "2025-01-01T10:00:00.000Z",
      "lastUsed": "2025-01-10T11:00:00.000Z",
      "totalCount": 150
    },
    {
      "nickname": "Guest-Player1",
      "source": "guest",
      "firstUsed": "2025-01-05T14:00:00.000Z",
      "lastUsed": "2025-01-08T18:00:00.000Z",
      "totalCount": 25
    }
  ]
}

Nickname History Fields:

  • nickname: The nickname used by the player
  • source: Either "pretendo" (from Pretendo API) or "guest" (from guest player entries)
  • firstUsed: First time this nickname was seen
  • lastUsed: Most recent time this nickname was seen
  • totalCount: Number of times this nickname was used

Error Handling

All API endpoints follow a consistent error handling pattern:

Success Response

Status Code: 200 OK

{
  "data": { ... }
}

Error Response

Status Code: 400, 401, 403, 404, 500, etc.

{
  "error": "Error message describing what went wrong"
}

Common Status Codes

  • 200 OK: Request successful
  • 400 Bad Request: Invalid request parameters
  • 401 Unauthorized: Authentication required
  • 403 Forbidden: Insufficient permissions
  • 404 Not Found: Resource not found
  • 500 Internal Server Error: Server error

Data Models

Race Object

{
  "id": 1,
  "gid": 123456,
  "hostPid": "host123",
  "ownerPid": "owner123",
  "gameMode": 0,
  "gameModeName": "VS Race",
  "tournamentId": null,
  "version": "17.17.17.38",
  "region": "Europe",
  "regionCode": 3,
  "dlcCode": 0,
  "dlcName": "No DLC",
  "minParticipants": 2,
  "maxParticipants": 12,
  "playerCount": 8,
  "isActive": true,
  "firstSeen": "2025-01-10T10:30:00.000Z",
  "lastSeen": "2025-01-10T11:00:00.000Z",
  "createdAt": "2025-01-10T10:30:00.000Z",
  "updatedAt": "2025-01-10T11:00:00.000Z"
}

Player Object

{
  "id": 5,
  "pid": "abc123",
  "miiName": "Player1",
  "firstSeen": "2025-01-01T10:00:00.000Z",
  "lastSeen": "2025-01-10T11:00:00.000Z",
  "totalRaces": 150,
  "totalSessions": 200
}

RacePlayer (Session) Object

{
  "id": 1,
  "raceId": 1,
  "playerId": 5,
  "pid": "abc123",
  "miiName": "Player1",
  "joinedAt": "2025-01-10T10:30:00.000Z",
  "leftAt": "2025-01-10T11:00:00.000Z",
  "isActive": false,
  "isHost": true,
  "isOwner": false
}

Session Fields:

  • joinedAt: Session start time - when the player joined this race
  • leftAt: Session end time - when the player left (null if still active)
  • isActive: Whether the player is currently in the race
  • isHost: Whether the player is the race host
  • isOwner: Whether the player is the room owner

Favorite Object

{
  "id": 1,
  "userId": 1,
  "playerId": 5,
  "pid": "abc123",
  "notes": "Good player",
  "notifyOnActive": true,
  "createdAt": "2025-01-10T12:00:00.000Z"
}

Game Mode Reference

ID Name
0 VS Race
1 Battle
2 Tournament
3 Friend Room

Region Reference

The region field contains the region name as a string. The regionCode field contains the numeric ID.

Region Code Region Name
0 Worldwide
1 Japan
2 America
3 Europe
4 Korea
5 China
6 Taiwan

DLC Reference

DLC Code Name
0 No DLC
1 DLC 1
2 DLC 2
3 DLC 1+2

Rate Limiting

Currently, there are no strict rate limits enforced. However, please be respectful:

  • Limit requests to reasonable intervals
  • Cache data when possible
  • Use pagination for large datasets

CORS

The API supports Cross-Origin Resource Sharing (CORS) for web applications. For mobile apps, ensure you include appropriate headers in your requests.


Example Usage (JavaScript/Fetch)

Get Active Races

fetch('http://your-server:3000/api/races/active')
  .then(response => response.json())
  .then(data => {
    console.log('Active races:', data.races);
  })
  .catch(error => console.error('Error:', error));

Get Player Sessions (with session start times)

const playerPid = 'abc123';
fetch(`http://your-server:3000/api/players/${playerPid}/sessions`)
  .then(response => response.json())
  .then(data => {
    console.log('Player sessions:', data.sessions);
    // Each session includes joinedAt (session start time)
    data.sessions.forEach(session => {
      console.log(`Session started: ${session.joinedAt}`);
      console.log(`Duration: ${session.durationFormatted}`);
    });
  })
  .catch(error => console.error('Error:', error));

Login and Get Favorites

// Login
fetch('http://your-server:3000/api/auth/login', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  credentials: 'include', // Important for cookies
  body: JSON.stringify({
    username: 'user',
    password: 'password'
  })
})
.then(response => response.json())
.then(loginData => {
  console.log('Logged in:', loginData);

  // Get favorites (requires authentication)
  return fetch('http://your-server:3000/api/favorites', {
    credentials: 'include' // Include session cookie
  });
})
.then(response => response.json())
.then(favData => {
  console.log('Favorites:', favData.favorites);
})
.catch(error => console.error('Error:', error));

Search Players

const searchQuery = 'Player1';
fetch(`http://your-server:3000/api/players/search?q=${encodeURIComponent(searchQuery)}&limit=10`)
  .then(response => response.json())
  .then(data => {
    console.log('Search results:', data.data);
  })
  .catch(error => console.error('Error:', error));

Get Server Downtime Statistics

fetch('http://your-server:3000/api/server-status/downtime')
  .then(response => response.json())
  .then(data => {
    console.log(`Uptime: ${data.uptimePercentage}%`);
    console.log(`Total downtime: ${data.totalDowntimeFormatted}`);
    console.log(`Incidents: ${data.incidentCount}`);
    data.incidents.forEach(incident => {
      console.log(`- ${incident.startTime} to ${incident.endTime}: ${incident.durationFormatted}`);
    });
  })
  .catch(error => console.error('Error:', error));

Get Player VR Points

const playerId = 5;

// Get latest VR points for all game modes
fetch(`http://your-server:3000/api/vr-points/${playerId}/latest`)
  .then(response => response.json())
  .then(data => {
    console.log('Latest VR Points:', data.vrPoints);
    data.vrPoints.forEach(vr => {
      console.log(`${vr.gameModeName}: ${vr.vrPoints} points`);
    });
  })
  .catch(error => console.error('Error:', error));

// Get complete VR points history
fetch(`http://your-server:3000/api/vr-points/${playerId}`)
  .then(response => response.json())
  .then(data => {
    console.log('VR Points History:', data.vrPoints);
  })
  .catch(error => console.error('Error:', error));

Update VR Points

// Login first (VR points update requires authentication)
fetch('http://your-server:3000/api/auth/login', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  credentials: 'include',
  body: JSON.stringify({
    username: 'user',
    password: 'password'
  })
})
.then(response => response.json())
.then(loginData => {
  console.log('Logged in:', loginData);

  // Update VR points for multiple players
  return fetch('http://your-server:3000/api/vr-points', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    credentials: 'include',
    body: JSON.stringify({
      vrPoints: [
        {
          playerId: 5,
          gameMode: 0, // VS Race
          vrPoints: 5100
        },
        {
          playerId: 5,
          gameMode: 1, // Battle
          vrPoints: 4600
        }
      ]
    })
  });
})
.then(response => response.json())
.then(data => {
  console.log('VR Points updated:', data);
  console.log(`Created: ${data.created}, Updated: ${data.updated}`);
})
.catch(error => console.error('Error:', error));

Search Players by Guest Nickname

// Search for a player by their guest nickname
const guestName = 'Guest-Player1';
fetch(`http://your-server:3000/api/players/search?miiName=${encodeURIComponent(guestName)}&limit=10`)
  .then(response => response.json())
  .then(data => {
    console.log('Search results (includes historical nicknames):', data.players);
    // Will find the main player even if searching by guest nickname
  })
  .catch(error => console.error('Error:', error));

Enhanced Statistics Endpoints

Get Growth Statistics

Get player growth and trend statistics over time.

Endpoint: GET /api/stats/growth

Authentication: Not required

Query Parameters:

  • days (optional): Time range in days, or 'all' for all-time (default: 30)

Example Requests:

GET /api/stats/growth?days=30
GET /api/stats/growth?days=7
GET /api/stats/growth?days=all

Response:

{
  "newPlayersByDay": [
    {
      "date": "2025-01-10",
      "newPlayers": 5
    }
  ],
  "weekOverWeek": {
    "races": {
      "current": 150,
      "previous": 120,
      "growth": 25.0
    },
    "players": {
      "current": 45,
      "previous": 40,
      "growth": 12.5
    }
  }
}

Get Engagement Statistics

Get player engagement and retention metrics.

Endpoint: GET /api/stats/engagement

Authentication: Not required

Query Parameters:

  • days (optional): Time range filter (default: all)

Response:

{
  "activePlayers": {
    "today": 25,
    "week": 80,
    "month": 150
  },
  "engagement": [
    {
      "category": "Daily Active",
      "count": 20
    }
  ],
  "avgSessionDuration": 45.5,
  "totalSessions": 5000,
  "avgSessionsPerPlayer": 12.5
}

Get Social Statistics

Get player network and co-player statistics. Note: Shows overlap time (actual time played together) instead of race count.

Endpoint: GET /api/stats/social

Authentication: Not required

Query Parameters:

  • days (optional): Time range filter (default: all)

Response:

{
  "topDuos": [
    {
      "player1Id": 5,
      "player1Name": "Player1",
      "player1Pid": "abc123",
      "player2Id": 10,
      "player2Name": "Player2",
      "player2Pid": "xyz789",
      "totalOverlapMinutes": 450,
      "racesTogether": 25,
      "lastRaceTogether": "2025-01-10T15:00:00.000Z",
      "avgSessionDurationMinutes": 18
    }
  ],
  "topTrios": [
    {
      "player1Id": 5,
      "player1Name": "Player1",
      "player1Pid": "abc123",
      "player2Id": 10,
      "player2Name": "Player2",
      "player2Pid": "xyz789",
      "player3Id": 15,
      "player3Name": "Player3",
      "player3Pid": "def456",
      "totalOverlapMinutes": 320,
      "racesTogether": 18,
      "lastRaceTogether": "2025-01-09T20:00:00.000Z"
    }
  ],
  "avgCoPlayers": 5.5,
  "connectionMatrix": {
    "players": [...],
    "connections": [...]
  }
}

Get Records Statistics

Get gaming records and extremes.

Endpoint: GET /api/stats/records

Authentication: Not required

Query Parameters:

  • days (optional): Time range filter (default: all)

Response:

{
  "longestRace": {
    "id": 42,
    "gid": 123456,
    "playerCount": 8,
    "firstSeen": "2025-01-05T14:00:00.000Z",
    "raceDuration": 7200000
  },
  "mostPlayersRace": {
    "id": 38,
    "gid": 654321,
    "firstSeen": "2025-01-08T18:00:00.000Z",
    "playerCount": 12
  },
  "longestSession": {
    "playerId": 5,
    "miiName": "Player1",
    "pid": "abc123",
    "durationMinutes": 450
  },
  "consistentPlayers": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "activeDays": 28,
      "firstRace": "2025-01-01T10:00:00.000Z",
      "lastRace": "2025-01-30T20:00:00.000Z"
    }
  ]
}

Get Enhanced Leaderboards

Get various leaderboards with time filtering.

Endpoint: GET /api/stats/leaderboards

Authentication: Not required

Query Parameters:

  • days (optional): Time range filter (default: all)
  • period (optional): For longestPeriodPlaytime - 'day' (default), 'week', or 'month'

Example Requests:

# All-time leaderboards
GET /api/stats/leaderboards

# Last 7 days with daily playtime
GET /api/stats/leaderboards?days=7&period=day

# Last 30 days with weekly playtime
GET /api/stats/leaderboards?days=30&period=week

Response:

{
  "sessionChampions": [...],
  "nightOwls": [...],
  "earlyBirds": [...],
  "topByRaces": [...],
  "topByPlaytime": [...],
  "topByDiscrepancy": [...],
  "longestSessions": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "raceId": 42,
      "gid": 123456,
      "joinedAt": "2025-01-05T14:00:00.000Z",
      "effectiveLeftAt": "2025-01-05T21:30:00.000Z",
      "durationMinutes": 450
    }
  ],
  "longestPeriodPlaytime": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "period": "2025-01-07",
      "totalMinutes": 520,
      "sessionCount": 12,
      "raceCount": 8
    }
  ],
  "mostNicknames": [
    {
      "id": 5,
      "pid": "abc123",
      "miiName": "Player1",
      "nicknameCount": 8,
      "totalRaces": 150,
      "totalSessions": 200,
      "lastSeen": "2025-01-10T11:00:00.000Z"
    }
  ],
  "playtimePeriod": "day"
}

Leaderboard Descriptions:

  • sessionChampions: Most sessions
  • nightOwls: Most active 22:00-04:00
  • earlyBirds: Most active 05:00-10:00
  • topByRaces: Most races
  • topByPlaytime: Most total playtime
  • topByDiscrepancy: Largest gap between total and valid playtime
  • longestSessions: Longest single gaming sessions
  • longestPeriodPlaytime: Longest playtime in a day/week/month (switchable)
  • mostNicknames: Players with the most different nicknames (from both Pretendo API and guest entries)

Activity Heatmap

The activity heatmap now shows weekly averages instead of peak values:

  • Before: Total number of races for each day/hour slot across all time
  • After: Average number of races per week for each day/hour slot

This provides a better view of typical weekly activity patterns regardless of the selected time range.


Support

For questions or issues with the API, please contact the system administrator or open an issue on the project repository.