API documentation
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
- Races
- Players
- Statistics
- Server Status
- Rankings (Time Trials)
- Favorites
- Poller
- User Settings
- VR Points
- Token-Based API (v1)
- Guest Players
- Error Handling
Authentication
Some endpoints require authentication via session cookies. To authenticate:
- Login: POST to
/auth/loginwith credentials - Session: The server returns a session cookie
- 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 IDgameMode(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 playersconcurrent(optional): NEW - Boolean string ('true'/'false'). When 'true' and 2+ PIDs are provided, only returns races where all specified players had overlapping sessionslimit(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
firstSeenDESC) - The
totalfield shows the total number of races in the database - To retrieve all races, either:
- Set a high
limitvalue (e.g.,limit=999999) - Use pagination by incrementing
offset(recommended for large datasets)
- Set a high
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
concurrentparameter 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®ion=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®ion=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 PIDslimit(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 50offset(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 twotimeWindow(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,profileCountsays how many profiles it covers. Searching by an old Mii name or an old PID still finds the merged entry. Passprofiles=1to get the individual profiles back instead, which is what a picker needs.GET /api/players/:idandGET /api/players/:id/stats— reachable through any of the profiles and giving the same answer, withprofileslisting 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 aplayernaming the profile it was recorded for, since VR does not carry over between profiles. The top-levelplayerstays 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/*andGET /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/engagementand/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 PIDlimit(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):1returns 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
lastSeenDESC) - The
totalfield shows the total number of players matching the search criteria totalPlaytimeSecondsis calculated from all completed sessions (whereleftAtis 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
limitvalue (e.g.,limit=999999) - Use pagination by incrementing
offset(recommended for large datasets)
- Set a high
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, ormonth
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 timestampslongestSession: Details of the player's longest single gaming sessionlongestDay: Details of the day with the most total playtimeactivityPeriod: 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,tracksandopponentscover the latest 1000 races;limitedTois 1000 when there are more, otherwisenull.vrcomes from all VR entries (VS race):maxis the highest value,maxAtwhen it was first reached.recentholds the latest 30 races, newest first.raceIdis the lobby ornull.tracks: per course, most raced first;vrBalanceis the sum of the VR changes.opponents: the 10 matched players seen most often in the same races;aheadcounts 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 (nullwithout 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 raceleftAt: Session end time - timestamp when the player left (null if still active)isActive: Whether the session is currently activedurationMs: Session duration in millisecondsdurationFormatted: 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 todays- start date in ISO formatendDate(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),sessionsorplaytime(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);nullwhen no race has one yet - then the other fields are missingraces: races logged since then;timedRaces: those with a known startlobbySeconds: 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 periodracedSeconds: start to result of every timed race, added upracedShare:racedSecondsas a percentage oflobbySeconds(one decimal),nullwithout 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 periodtotalDowntimeMs: Total milliseconds of downtime (gaps >= 60 minutes without races)incidents: Array of downtime incidents with exact start/end timespollerEvents: 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 IDcomponentName(optional): components whose name contains this text (ignored whencomponentIdis 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 asm:ss.mmm - the Mii name is decoded from the NEX
commonDatablob (UTF-16LE at offset0x2E) - every entry is cross-referenced with the local
playerstable, 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
ownVR 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 account400 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):1returns 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:deorentimezone: 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_raceorbattlelimit(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 namevrPoints: Current VR rating for this game modeupdatedAt: 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 createdupdated: 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 uploadPOST /api/v1/vr-points/batch— Batch upload for multiple playersGET /api/v1/vr-points/latest— Latest VR pointsGET /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)nicknameorplayerId(one of them required): the guest's name, and/or the player the guest is known asjoinedAt(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:playerIdmissing, or the entry is not a guest404: session or player not found409with"code": "ALREADY_IN_GATHERING": the player is already in this lobby themselves;errorholds 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 playersource: Either "pretendo" (from Pretendo API) or "guest" (from guest player entries)firstUsed: First time this nickname was seenlastUsed: Most recent time this nickname was seentotalCount: 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 successful400 Bad Request: Invalid request parameters401 Unauthorized: Authentication required403 Forbidden: Insufficient permissions404 Not Found: Resource not found500 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 raceleftAt: Session end time - when the player left (null if still active)isActive: Whether the player is currently in the raceisHost: Whether the player is the race hostisOwner: 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): ForlongestPeriodPlaytime- '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 sessionsnightOwls: Most active 22:00-04:00earlyBirds: Most active 05:00-10:00topByRaces: Most racestopByPlaytime: Most total playtimetopByDiscrepancy: Largest gap between total and valid playtimelongestSessions: Longest single gaming sessionslongestPeriodPlaytime: 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.