# NBTiktok Leaderboard API v2

The Leaderboard API lets an authorized game client discover boards, inspect seasons, write ten-dimensional scores, and query regional or global rankings. Its public base URL is:

```text
https://<platform-origin>/openapi/v2
```

Every HTTPS request uses the short-lived `credentials.gameApi.token` returned by game-client login and exchanges JSON except where noted. Stable validation errors use `{ "error": { "code": "ERROR_CODE", "requestId": "018f1f6d-7b2a-7e34-a6cc-5e5f3fb70420" } }`. Do not use container ports, `/internal/*`, or `/admin/api/*`. See the [Interactive Game Integration API](./game-integration.en.md) for login, Session, refresh, and WebSocket contracts.

## Quick integration

1. Call `POST /api/game-client/v2/login` with `mode: "production"` or `mode: "debug"`.
2. Read `credentials.gameApi.token` and `credentials.gameApi.expiresAt`.
3. Discover boards with `GET /openapi/v2/leaderboards`.
4. Submit scores only while `currentSeason` is present.
5. Include `score1` through `score10` for every player and create a unique `requestId` per player mutation.
6. Query a ranking list or one player's rank.
7. At the game Session's `refreshAt`, atomically switch to the new Game API Token.

## Public routes and authentication

| Capability | Method / path |
| --- | --- |
| Discover boards | `GET /openapi/v2/leaderboards` |
| List simulation players | `GET /openapi/v2/leaderboards/simulation-players` |
| List board seasons | `GET /openapi/v2/leaderboards/:boardKey/seasons` |
| Write ten-dimensional scores | `POST /openapi/v2/leaderboards/:boardKey/score-mutations` |
| List rankings | `GET /openapi/v2/leaderboards/:boardKey/seasons/:seasonKey/rankings` |
| Get one player's rank | `GET /openapi/v2/leaderboards/:boardKey/seasons/:seasonKey/players/:playerId/rank` |
| List current-week rankings | `GET /openapi/v2/leaderboards/:boardKey/weeks/current/rankings` |
| Get one player's current-week rank | `GET /openapi/v2/leaderboards/:boardKey/weeks/current/players/:playerId/rank` |

All requests include:

```http
Authorization: Bearer <gameApiToken>
Accept: application/json
```

The Token is a short-lived Core-signed JWT bound to game, streamer, and mode, and Game verifies it locally. Never persist or log it. Production maps to the `production` score space; Debug maps to `sandbox`.

Login returns a Session context plus the Game API credential:

```json
{
  "session": {
    "gameId": "game_one",
    "mode": "production",
    "streamerId": "9f833c5b-ec62-4530-9ae4-0af8130a26b6"
  },
  "credentials": {
    "gameApi": {
      "token": "signed-game-api-token",
      "expiresAt": "2026-08-19T12:10:00.000Z"
    }
  }
}
```

The Token locks `gameId`, `space`, `streamerId`, `regionCode`, `authorizationId`, and Session. Dealer members inherit their dealer region; personal authorizations use the profile region. Supported write regions are `SG`, `MY`, `TH`, `ID`, `PH`, and `TW`; a client cannot override its write region. The Token is valid for at most 10 minutes and can be shortened by Session absolute expiry.

After `POST /api/game-client/v2/session/refresh`, atomically install the new Session access, refresh, and Game API Tokens and immediately stop actively using the old Game API Token. Its security validity lasts until its own 10-minute `exp`, or ends earlier when Game receives the Session revocation projection; refresh alone does not revoke it immediately.

## Data model

Each board has a stable `boardKey`, title, sort direction, supported score fields, available regions, and possibly a `currentSeason`. Ascending boards place lower scores first; descending boards place higher scores first.

Score writes do not accept `seasonKey`. The server resolves the active season at request time; when none is active, the write returns `409 CURRENT_SEASON_UNAVAILABLE`.

Every mutation must contain exactly these ten fields:

```text
score1, score2, score3, score4, score5,
score6, score7, score8, score9, score10
```

Scores are signed 64-bit integers transported as decimal strings to avoid JavaScript precision loss. Parse with a 64-bit integer or BigInt, not `number`. Every value, operation result, and global aggregate must stay between `-9223372036854775808` and `9223372036854775807`.

Each write changes only the Token `regionCode`. For every score field, global means the player's best regional value: `desc` selects the maximum, `asc` selects the minimum, and a tie selects the lexicographically smallest `regionCode`. Game recomputes all ten best sources in the same transaction. Global ranking items return that source `regionCode`; regional queries return the requested region.

Production regional scores are isolated by game × board × season × player × `regionCode`, then Game derives each player's global best. Sandbox adds streamer × game isolation. The spaces share board and season definitions but never scores or idempotency records.

The season is a canonical game-level timeline: every board transitions together using one `scoreRetentionBasisPoints` value (`0..10000`, default `50` means `0.5%`). All ten Production score fields retain that percentage and truncate toward zero; Game recomputes global best values and source regions. The same transaction resets Production player attributes in all six regions according to published `seasonReset` rules. Debug scores are not copied and Sandbox attributes are not reset. Closing a season without transitioning resets neither scores nor attributes. Saving the automatic rule updates timing only for automatic seasons; a current manual season keeps its explicit end time, after which the automatic rule takes over.

## Discover boards

```http
GET /openapi/v2/leaderboards
Authorization: Bearer <gameApiToken>
```

```json
{
  "space": "production",
  "scoreFields": [
    "score1", "score2", "score3", "score4", "score5",
    "score6", "score7", "score8", "score9", "score10"
  ],
  "gameId": "game_one",
  "boards": [
    {
      "boardKey": "main",
      "displayName": "Total score",
      "scoreOrder": "desc",
      "currentSeason": {
        "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112",
        "seasonKey": "auto-20260819000000-r3",
        "startsAt": "2026-08-19T00:00:00.000Z",
        "endsAt": "2026-08-20T00:00:00.000Z",
        "status": "active"
      }
    },
    {
      "boardKey": "speedrun",
      "displayName": "Fastest completion",
      "scoreOrder": "asc",
      "currentSeason": null
    }
  ]
}
```

`boards` is ordered by `boardKey`. `currentSeason: null` permits historical reads but prevents public writes. Discover again after login or refresh; do not hard-code board keys, sort order, or season timing.

## Simulation players and seasons

Debug clients can list the current streamer's simulation players:

```http
GET /openapi/v2/leaderboards/simulation-players
Authorization: Bearer <gameApiToken>
```

```json
{
  "players": [
    { "playerId": "debug-player-1", "displayName": "Debug Player 1" }
  ]
}
```

The server ensures that the current streamer × game has preset simulation players and also returns custom players. A valid Production Token may call this route too, but the result is always Sandbox players and must not be treated as Production identities. Production score mutations should use canonical `payload.player.sourcePlayerId`; ranking responses return only `playerId`, not the live nickname.

List seasons for one allowlisted board:

```http
GET /openapi/v2/leaderboards/main/seasons?limit=50
Authorization: Bearer <gameApiToken>
```

`limit` is optional from 1–100 and defaults to 50. `cursor` is the opaque value from the preceding page and is valid only for the same board.

```json
{
  "seasons": [
    {
      "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112",
      "seasonKey": "auto-20260819000000-r3",
      "status": "active",
      "source": "automatic",
      "scoreRetentionBasisPoints": 50,
      "startsAt": "2026-08-19T00:00:00.000Z",
      "endsAt": "2026-08-20T00:00:00.000Z"
    }
  ],
  "nextCursor": null
}
```

Seasons are newest first. `status` is `scheduled`, `active`, or `ended`; `source` is `manual` or `automatic`; `scoreRetentionBasisPoints` is the score-retention snapshot saved for that season. A route `seasonKey` may be a real key or `current`; ended seasons remain readable but cannot be publicly mutated.

## Write scores

```http
POST /openapi/v2/leaderboards/main/score-mutations
Authorization: Bearer <gameApiToken>
Content-Type: application/json
```

```json
{
  "mutations": [
    {
      "requestId": "533b2994-b638-45a8-9db2-f0fc926fe9e5",
      "playerId": "tiktok-user-id",
      "playerName": "Alice",
      "operation": "increment",
      "scores": {
        "score1": "100",
        "score2": "0",
        "score3": "0",
        "score4": "0",
        "score5": "0",
        "score6": "0",
        "score7": "0",
        "score8": "0",
        "score9": "0",
        "score10": "0"
      }
    }
  ]
}
```

The request must contain 1–20 players and remain at or below 128 KiB. Every `requestId` is a UUID v1–v5, may not repeat within a batch, and should be globally unique. `playerId` is a non-empty opaque string of at most 256 characters. Each `scores` object contains exactly `score1` through `score10`, with signed decimal integer strings and no missing or additional keys. `operation` is one of:

Production additionally requires a trimmed 1–200 character `playerName`, which Game persists for that player and region. Sandbox must omit `playerName` and uses the registered simulation player's name.

- `increment`: add each submitted value;
- `set`: replace each current value;
- `max`: retain the larger of current and submitted values.

One operation applies to all ten fields. To increment only some fields, submit `"0"` for the rest. `set` replaces all ten values and `max` compares all ten; omitted-field-means-unchanged does not exist. The obsolete single-field shape using `scoreField` and `value` returns `400 INVALID_MUTATIONS`.

The whole batch runs serially in one database transaction. Any invalid mutation, score, or idempotency check rolls back the batch. Concurrent writes for the same player and region are serialized; regional and global values change together.

```json
{
  "season": {
    "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112",
    "seasonKey": "auto-20260819000000-r3",
    "startsAt": "2026-08-19T00:00:00.000Z",
    "endsAt": "2026-08-20T00:00:00.000Z",
    "status": "active"
  },
  "mutations": [
    {
      "requestId": "533b2994-b638-45a8-9db2-f0fc926fe9e5",
      "playerId": "tiktok-user-id",
      "displayName": "Alice",
      "regionCode": "SG",
      "operation": "increment",
      "scores": {
        "score1": "1280",
        "score2": "0",
        "score3": "0",
        "score4": "0",
        "score5": "0",
        "score6": "0",
        "score7": "0",
        "score8": "0",
        "score9": "0",
        "score10": "0"
      }
    }
  ]
}
```

`season.seasonKey` is the actual resolved season. Each result includes persisted `displayName` and the Token `regionCode`; `scores` are the new regional values, not global best values.

## Idempotency and retries

`requestId` identifies one player's ten-field mutation. In Production, its idempotency scope covers all Production data. In Sandbox, the scope is game × streamer. Generate a globally unique UUID for every new mutation in both modes.

- Repeating the identical request in the same scope returns the original result without another write.
- Reusing that `requestId` while changing game, player, operation, region, or any score returns `409 IDEMPOTENCY_CONFLICT`.
- After a timeout or transient server failure, retry the exact original mutation with the same `requestId`.
- Never hide a conflict by generating a new UUID for data that may already have committed.

A new request resolves the `current` season at that time. Even after a season transition, an identical `requestId` and payload replay returns the original resolved season and mutation result without touching the new season. A batch that mixes new mutations with historical replays, or replays from different original seasons, returns `409 IDEMPOTENCY_CONFLICT` and rolls back completely. A duplicate `requestId` inside one HTTP batch returns `400 INVALID_MUTATIONS`. Persist the original request and reconcile rather than changing UUID. For a reliable live event, a single-player mutation may use `eventId` as `requestId`; for multiple players, derive a distinct deterministic UUID for each. ACK the source event only after the score write succeeds.

## Ranking list

Global example:

```http
GET /openapi/v2/leaderboards/main/seasons/current/rankings?scoreField=score2&scope=global&limit=100
Authorization: Bearer <gameApiToken>
```

`scoreField` is required and accepts only `score1`–`score10`. `scope` is `global` or `region`; regional queries also require a supported `region`. `limit` is capped at 100. Supply an opaque `cursor` from the previous page to continue.

```json
{
  "scoreField": "score2",
  "scoreOrder": "desc",
  "rankings": [
    {
      "rank": 1,
      "playerId": "tiktok-user-id",
      "regionCode": "SG",
      "score": "980"
    }
  ],
  "nextCursor": null,
  "space": "production"
}
```

Each space + game + Sandbox streamer + board + season + scope + region + score field exposes at most the first 100 ordered rows; `limit` paginates only within those rows. Equal scores share an SQL `RANK`, so positions can skip, while `playerId` provides stable tie ordering. With `scoreOrder=asc`, lower values rank higher.

A cursor is bound to its game, space, Sandbox streamer, season, scope, region, and score field. Never parse, edit, or reuse it after changing a filter. A mismatched context returns `400 INVALID_CURSOR`.

## One player's rank

```http
GET /openapi/v2/leaderboards/main/seasons/current/players/tiktok-user-id/rank?scoreField=score2&scope=region&region=SG
Authorization: Bearer <gameApiToken>
```

`scoreField` defaults to `score1` on this endpoint; `scope` defaults to global. Regional rank requires `region`. Rank is computed on the complete matching board, not only the first list page.

```json
{
  "ranking": {
    "rank": 12,
    "playerId": "tiktok-user-id",
    "regionCode": "SG",
    "scoreField": "score2",
    "score": "5500"
  }
}
```

URL-encode `boardKey`, `seasonKey`, and `playerId`. When the player has no row, the successful response is `{ "ranking": null }`. This endpoint computes against the complete board and can return a rank beyond 100.

## Current-week rankings

The current week runs from Monday 00:00 to the next Monday 00:00 in `Asia/Shanghai`. Weekly scores start from zero and independently apply the same accepted `increment`, `set`, or `max` operation. Production admin adjustments apply as weekly increments; idempotent replays do not update the week twice. Weekly scores continue across season transitions and ignore season carryover or reset operations.

Use `GET /openapi/v2/leaderboards/main/weeks/current/rankings?scoreField=score2&scope=global&limit=100` for the list and `GET /openapi/v2/leaderboards/main/weeks/current/players/tiktok-user-id/rank?scoreField=score2` for one player. Filters, top-100 pagination, SQL `RANK`, sort direction, and best-region behavior match season rankings. The player endpoint defaults `scoreField` to `score1` and computes against the complete weekly board.

Both responses return `space` and a `week` object with `startsAt`, `endsAt`, `timeZone`, `isPartial`, and `trackingStartedAt`. `isPartial` is true only for the deployment week when tracking began after its Monday boundary; it becomes false for the next complete week. Old mutations are not guessed or backfilled. Production and Sandbox remain Token-selected and isolated, and current-week reads do not require an active season.

## Debug Sandbox

A Debug login always maps leaderboard responses to:

```text
space = sandbox
```

Sandbox scores, global aggregates, and `requestId` records are isolated by streamer × game. They remain available across a new Debug Session for the same streamer and game, but are not shared across streamers or games. Board and season configuration still comes from platform administration. Discover simulation players first and stop if a response space does not match the current login mode.

## Errors and recovery

| HTTP | Error | Client action |
| --- | --- | --- |
| 401 | `INVALID_ACCESS_TOKEN` / `INVALID_ACCESS_TOKEN` | Stop and replace credentials through Session refresh or login |
| 400 | `INVALID_REGION` | Use a supported region; write region comes from account authority |
| 400 | `INVALID_LIMIT` | Use the endpoint's documented pagination range |
| 400 | `INVALID_SCORE_FIELD` | Use `score1`–`score10`; ranking lists require it explicitly |
| 400 | `INVALID_MUTATIONS` / `INVALID_SCORE` | Correct the full ten-field mutation; do not retry unchanged |
| 400 | `REGION_REQUIRED` | Add `region` for a regional rank query |
| 400 | `INVALID_CURSOR` | Drop the cursor and start from the first page |
| 404 | `SEASON_NOT_FOUND` | Refresh season authority and check `seasonKey` |
| 409 | `CURRENT_SEASON_UNAVAILABLE` | Wait for an active season or contact an administrator |
| 409 | `SEASON_NOT_ACTIVE` | Refresh board and season state |
| 409 | `IDEMPOTENCY_CONFLICT` | Compare the original request and season; do not write again |
| 413 | Request body too large | Split into at most 20 players and no more than 128 KiB |
| 500 | `INTERNAL_ERROR` | Preserve `requestId`, record context, and contact the platform |

Use exponential backoff with jitter for reads after network or 5xx errors. Retry a write only with the exact original context, content, and `requestId`. If the Game API Token expires, perform one serialized game Session refresh and use the new Token; never replay an uncertain one-use refresh Token.

## Release checklist

- Keep `credentials.gameApi.token` in memory and rotate it with the game Session.
- Validate `boardKey`, current season, score fields, regions, and `space` before writing.
- Send exactly ten decimal-string scores for every player.
- Use one stable UUID per logical mutation and replay it only with identical data.
- Treat Production and Sandbox as separate spaces.
- Bind cursors to the exact query that created them.
- Test absent seasons, 20-player / 128 KiB limits, idempotent retries, conflicts, regional/global ranks, and Token expiry.
- Log only non-secret context such as `gameId`, `boardKey`, actual `seasonKey`, status, and `requestId`; never log an Authorization header.
