NBTiktokDOCS / Developer documentation

v2 · Developer guide

DOC / 04

Player Attributes API

Versioned attributes, field-level season resets, atomic batch mutations, and Production/Debug isolation.

The Player Attributes API stores versioned, game-specific player state such as level, experience, energy, character, and compact progression. Use dedicated systems for leaderboard scores, currency ledgers, inventories, task history, and other relational or auditable data.

Public base URL:

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

The public OpenAPI 3.1 description is available at /openapi/game-api-v2.yaml. Never call container ports, /internal/*, or /admin/api/*.

#Integration flow

  1. Call POST /api/game-client/v2/login with mode: "production" or mode: "debug".
  2. Read the short-lived credentials.gameApi.token from the response.
  3. Fetch the active schema. Do not hard-code attribute definitions in a released client.
  4. Read one player or up to 100 players. An unwritten player receives defaults with revision: "0".
  5. Give every mutation a unique requestId; apply set, increment, or max operations.
  6. Atomically rotate credentials at Session refreshAt. After an uncertain network result, retry only the exact original mutation.

#Endpoints and authentication

CapabilityMethod / path
Get the active schemaGET /openapi/v2/player-attributes/schema
Get one playerGET /openapi/v2/player-attributes/players/:playerId
Query players in a batchPOST /openapi/v2/player-attributes/query
Mutate players atomicallyPOST /openapi/v2/player-attributes/mutations

Every request requires:

Authorization: Bearer <gameApiToken>
Accept: application/json

JSON requests also require Content-Type: application/json. Stable error bodies use:

{
  "error": {
    "code": "ERROR_CODE",
    "requestId": "018f1f6d-7b2a-7e34-a6cc-5e5f3fb70420",
    "details": {}
  }
}

#Login, refresh, and credential scope

POST /api/game-client/v2/login
Content-Type: application/json
{
  "email": "player@example.com",
  "password": "password",
  "gameId": "game_one",
  "mode": "production",
  "clientBuild": 120
}

The response contains the credential used by this API:

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

The server derives gameId, data space, streamerId, authorization, and Session from the Token. No request can override that context. credentials.gameApi.token is distinct from the Session access Token, one-use refresh Token, and WebSocket Ticket. Never mix, persist, log, share, or put it in a URL.

POST /api/game-client/v2/session/refresh returns new credentials.sessionAccess, credentials.gameApi, and credentials.refresh. Serialize refreshes, install the complete set atomically and immediately stop actively using old credentials. See the Interactive Game Integration API for the full Session contract.

#Production, Sandbox, and player IDs

  • A Production login selects production; state is isolated by game × regionCode × playerId.
  • A Debug login selects sandbox; states are additionally isolated by streamer and game.
  • A path, query, or body cannot change spaces.
  • In Production, use payload.player.sourcePlayerId from the canonical event.
  • Sandbox accepts only simulation players registered for the current streamer and game; otherwise it returns SANDBOX_PLAYER_NOT_FOUND.
  • playerId is an opaque 1–256 character string. Percent-encode it as one UTF-8 path segment.

Production and Sandbox never read or modify each other's states or idempotency records. The signed Game API Token fixes regionCode to SG, MY, TH, ID, PH, or TW; no path, query, or body may override it. Every success response returns that top-level regionCode, and each player state includes the stored displayName.

Every Production mutation must include a 1–200 character playerName, which Game persists with the player in that region. Sandbox mutations must omit playerName; the registered simulation player supplies its name.

#Schema and field model

Administrators publish the schema. Clients may only use published fields; they cannot create fields dynamically. This version supports up to 100 top-level scalar fields:

TypeJSON valueOptional constraintsOperations
integernumberminimum, maximum; JSON safe integerset, increment, max
numbernumberfinite value, minimum, maximumset, increment, max
stringstringminLength, maxLengthset
booleanbooleannoneset
enumstringunique non-empty enumValuesset

A key matches ^[a-z][a-z0-9_]{0,63}$. Every field has a valid default. The final state is a JSON object that must satisfy the active schema and is limited to 64 KiB when stored. Do not use a JSON number for balances or identifiers that need greater precision.

writeOperations may be empty, which makes the field read-only to game clients. When an administrator omits it from a definition, it defaults to ["set"].

Every field must include seasonReset. integer and number support keep, reset_to_default, or retain_percentage; other types support only keep or reset to default. basisPoints is an integer from 0..10000; 50 means 0.5%. Production state is reset in the same transaction as the game-level season transition, while Sandbox state is never reset. Numeric retention uses new = default + (old - default) × basisPoints / 10000; integer results round toward the default. Each of the six Production regions is calculated independently.

The 64 KiB limit has two guards. PLAYER_ATTRIBUTES_TOO_LARGE means the complete current state produced by this mutation already exceeds the limit. PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGE means the current state still fits but the conservative maximum-size bound for any future season-reset projection exceeds 64 KiB. The latter is a 409 that blocks the mutation, schema publication, or transition before un-settleable state enters a season.

Every successful response includes the canonical game season at the top level: seasonId, seasonKey, startsAt, endsAt, and status. All boards and player attributes share this timeline. Closing a season without transitioning does not reset scores or attributes.

#Get the active schema

GET /openapi/v2/player-attributes/schema
Authorization: Bearer <gameApiToken>
{
  "gameId": "game_one",
  "space": "production",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "fields": [
    {
      "key": "level",
      "title": "Level",
      "description": "Current player level",
      "type": "integer",
      "defaultValue": 1,
      "minimum": 1,
      "maximum": 100,
      "writeOperations": ["set", "max"],
      "seasonReset": { "strategy": "retain_percentage", "basisPoints": 50 }
    },
    {
      "key": "exp",
      "title": "Experience",
      "type": "integer",
      "defaultValue": 0,
      "minimum": 0,
      "writeOperations": ["set", "increment", "max"],
      "seasonReset": { "strategy": "reset_to_default" }
    },
    {
      "key": "class",
      "title": "Class",
      "type": "enum",
      "defaultValue": "warrior",
      "enumValues": ["warrior", "mage", "archer"],
      "writeOperations": ["set"],
      "seasonReset": { "strategy": "reset_to_default" }
    }
  ],
  "defaultAttributes": {
    "level": 1,
    "exp": 0,
    "class": "warrior"
  },
  "jsonSchema": {
    "type": "object",
    "properties": {
      "level": { "type": "integer", "title": "Level", "default": 1, "minimum": 1, "maximum": 100 },
      "exp": { "type": "integer", "title": "Experience", "default": 0, "minimum": 0 },
      "class": { "type": "string", "title": "Class", "default": "warrior", "enum": ["warrior", "mage", "archer"] }
    },
    "required": ["level", "exp", "class"],
    "additionalProperties": false
  },
  "writeRules": {
    "level": ["set", "max"],
    "exp": ["set", "increment", "max"],
    "class": ["set"]
  }
}

Refetch after login, refresh, or when a response reports a new schemaVersion. Do not poll at high frequency.

#Get one player

GET /openapi/v2/player-attributes/players/tiktok-user-123
Authorization: Bearer <gameApiToken>
{
  "gameId": "game_one",
  "space": "production",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "player": {
    "regionCode": "SG",
    "playerId": "tiktok-user-123",
    "displayName": "Alice",
    "revision": "7",
    "attributes": { "level": 12, "exp": 1350, "class": "mage" },
    "updatedAt": "2026-08-20T08:00:00.000Z"
  }
}

A valid but unwritten player returns a virtual default without causing a write:

{
  "gameId": "game_one",
  "space": "production",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "player": {
    "regionCode": "SG",
    "playerId": "new-player",
    "displayName": "Alice",
    "revision": "0",
    "attributes": { "level": 1, "exp": 0, "class": "warrior" },
    "updatedAt": null
  }
}

Treat revision as an opaque decimal string, not a JavaScript Number. Reads project defaults for fields added by a newer schema without writing the state.

#Batch query

POST /openapi/v2/player-attributes/query
Authorization: Bearer <gameApiToken>
Content-Type: application/json
{
  "playerIds": ["player-1", "player-2"]
}

The request contains 1–100 unique IDs and is at most 128 KiB. Response order matches request order. Invalid input fails the whole request.

{
  "gameId": "game_one",
  "space": "sandbox",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "players": [
    {
      "regionCode": "SG",
      "playerId": "player-1",
      "displayName": "Alice",
      "revision": "4",
      "attributes": { "level": 5, "exp": 300, "class": "mage" },
      "updatedAt": "2026-08-20T08:00:00.000Z"
    },
    {
      "regionCode": "SG",
      "playerId": "player-2",
      "displayName": "Alice",
      "revision": "0",
      "attributes": { "level": 1, "exp": 0, "class": "warrior" },
      "updatedAt": null
    }
  ]
}

#Atomic batch mutations

POST /openapi/v2/player-attributes/mutations
Authorization: Bearer <gameApiToken>
Content-Type: application/json
{
  "mutations": [
    {
      "requestId": "7747fe63-ed9d-4e8f-ae15-c90d42f20f0a",
      "playerId": "tiktok-user-123",
      "playerName": "Alice",
      "expectedRevision": "7",
      "operations": [
        { "field": "exp", "operation": "increment", "value": 50 },
        { "field": "level", "operation": "max", "value": 12 }
      ]
    }
  ]
}

Constraints:

  • 1–20 unique players per batch; body at most 128 KiB;
  • UUID v1–v5 requestId, unique in the batch and new for every new business change;
  • Production requires a 1–200 character playerName; Sandbox must omit it;
  • optional decimal-string expectedRevision; an unwritten state has revision "0";
  • 1–20 operations per player with no repeated field;
  • each field must exist, permit the operation, receive the correct JSON type, and remain valid after the operation; an integer increment/max operand must itself be a JSON safe integer.

set assigns the submitted value. Numeric-only increment adds it. Numeric-only max keeps the greater value. If supplied, expectedRevision must equal the current version. If omitted, the server locks and applies operations to the latest state; this is useful for commutative increments/maxima, not for a replacement that depends on an earlier read.

The entire batch is one transaction. Any validation, revision, or idempotency failure rolls back every player. Each successful player revision increases exactly once.

{
  "gameId": "game_one",
  "space": "production",
  "regionCode": "SG",
  "schemaVersion": 2,
  "season": { "seasonId": "018f2474-0c41-7bd8-9caf-2676c116b112", "seasonKey": "2026-s2", "startsAt": "2026-08-01T00:00:00Z", "endsAt": "2026-09-01T00:00:00Z", "status": "active" },
  "mutations": [
    {
      "requestId": "7747fe63-ed9d-4e8f-ae15-c90d42f20f0a",
      "regionCode": "SG",
      "playerId": "tiktok-user-123",
      "displayName": "Alice",
      "revision": "8",
      "attributes": { "level": 12, "exp": 1400, "class": "mage" },
      "updatedAt": "2026-08-20T08:01:00.000Z"
    }
  ]
}

#Idempotency and the 30-day window

Production idempotency spans all Production data, so a requestId cannot be reused across games. Sandbox scope is the current streamer and game. Treat every UUID as globally unique.

  • For 30 days, the same requestId with exactly the same context, player, revision, and operations returns the original result without applying twice.
  • Reusing a requestId with different content returns 409 IDEMPOTENCY_CONFLICT.
  • Records expire after 30 days. Never intentionally reuse a UUID after expiry.
  • For reliable live events, deterministically derive one UUID per affected player from eventId; ACK the source event only after the mutation succeeds.

#Error reference

HTTPCodeAction
400INVALID_PLAYER_IDFix empty, oversized, duplicate, or invalidly encoded IDs.
400INVALID_REQUEST_BODYSupply a parseable JSON POST body with the required root shape.
400INVALID_PLAYER_IDSSupply 1–100 unique IDs; Sandbox IDs must be UUIDs.
400INVALID_PLAYER_ATTRIBUTE_MUTATIONSFix the batch, UUID, or operation shape.
400INVALID_PLAYER_NAMEProduction requires a trimmed 1–200 character playerName.
400INVALID_EXPECTED_REVISIONSupply a non-negative bigint decimal revision string.
400PLAYER_ATTRIBUTE_NOT_DEFINEDRefresh the schema and use a published field.
400DUPLICATE_PLAYER_ATTRIBUTE_OPERATIONMutate each field at most once per player.
400PLAYER_ATTRIBUTE_OPERATION_NOT_ALLOWEDUse an operation allowed by the field.
400PLAYER_ATTRIBUTE_VALUE_INVALIDFix the JSON type or enum value.
400PLAYER_ATTRIBUTE_VALUE_OUT_OF_RANGEFix the numeric range or string length.
400PLAYER_ATTRIBUTES_INVALIDThe resulting state is not a usable JSON object.
400PLAYER_ATTRIBUTES_TOO_LARGEKeep the resulting complete state at or below 64 KiB.
401INVALID_ACCESS_TOKENSupply a valid Bearer header.
401INVALID_ACCESS_TOKENRefresh/login; Token, Session, authorization, or context is invalid.
404GAME_NOT_FOUNDThe Token's game or active schema does not exist.
404SANDBOX_PLAYER_NOT_FOUNDUse a simulation player registered in this Sandbox.
409CURRENT_SEASON_UNAVAILABLEThe game has no active canonical season; ask an administrator to configure or activate one.
409PLAYER_ATTRIBUTE_SEASON_RESET_TOO_LARGECurrent state fits, but a future season-reset projection exceeds 64 KiB; adjust defaults or reset rules.
409PLAYER_ATTRIBUTE_REVISION_CONFLICTRead current state and recompute.
409IDEMPOTENCY_CONFLICTReconcile the earlier request; do not blindly change UUID.
413REQUEST_BODY_TOO_LARGESplit the request below 128 KiB.
500INTERNAL_ERRORPreserve the request ID and retry an unexpected server failure safely.

Malformed JSON or unsupported media types may be rejected before the business route. Public-edge rate limiting may return HTTP 429 with a body outside the stable JSON error contract. Check HTTP status first, then read error when JSON is available; never parse human text.

#Safe retries

  • Retry GET after network, 429, or 5xx failures with exponential backoff and jitter.
  • Retry a mutation only with the exact original content and requestId; never mint a new UUID for an uncertain result.
  • Do not retry 400/404 before correction. On revision conflict, read and recompute. Reconcile idempotency conflicts.
  • On 401, perform one serialized Session refresh and replay with the new Game API Token. Return to login if refresh fails.

#curl, TypeScript, and Unity examples

curl --fail-with-body \
  -H 'Authorization: Bearer <gameApiToken>' \
  -H 'Accept: application/json' \
  'https://<platform-origin>/openapi/v2/player-attributes/players/tiktok-user-123'
async function addExp(origin: string, token: string, playerId: string, playerName: string, revision: string) {
  const response = await fetch(`${origin}/openapi/v2/player-attributes/mutations`, {
    method: "POST",
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
    body: JSON.stringify({ mutations: [{
      requestId: crypto.randomUUID(), playerId, playerName, expectedRevision: revision,
      operations: [{ field: "exp", operation: "increment", value: 50 }],
    }] }),
  });
  const body = await response.json();
  if (!response.ok) throw new Error(`${response.status} ${body.error ?? "UNKNOWN_ERROR"}`);
  return body.mutations[0];
}
using System;
using System.Collections;
using System.Text;
using UnityEngine.Networking;

[Serializable] public class Op { public string field; public string operation; public int value; }
[Serializable] public class Mutation { public string requestId; public string playerId; public string playerName; public string expectedRevision; public Op[] operations; }
[Serializable] public class Envelope { public Mutation[] mutations; }

public static IEnumerator AddExp(string origin, string token, string playerId, string playerName, string revision) {
    var json = UnityEngine.JsonUtility.ToJson(new Envelope { mutations = new[] {
        new Mutation { requestId = Guid.NewGuid().ToString(), playerId = playerId, playerName = playerName,
            expectedRevision = revision, operations = new[] { new Op { field = "exp", operation = "increment", value = 50 } } }
    }});
    using var request = new UnityWebRequest(origin + "/openapi/v2/player-attributes/mutations", "POST");
    request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
    request.downloadHandler = new DownloadHandlerBuffer();
    request.SetRequestHeader("Authorization", "Bearer " + token);
    request.SetRequestHeader("Content-Type", "application/json");
    yield return request.SendWebRequest();
    if (request.result != UnityWebRequest.Result.Success) throw new Exception(request.responseCode + " " + request.downloadHandler.text);
}

Unity JsonUtility cannot deserialize a dynamic attributes object. Use an audited JSON library or generate strongly typed DTOs from the schema.

#Security and privacy

  • Never put passwords, Tokens, payment credentials, raw profiles, live nicknames, or unnecessary sensitive data in attributes.
  • Do not log player IDs, values, Tokens, or full responses. Log a redacted request ID, code, and latency where needed.
  • Call only the public HTTPS origin. Never embed administrator credentials or send Bearers in query strings.
  • This API stores current state, not an accounting ledger. Model paid currency, ownership, audit history, and large collections separately.

#Production checklist

  • Login and refresh atomically install the current credentials.gameApi.token.
  • The client fetches schema after login/refresh and on schema-version changes.
  • Production uses canonical sourcePlayerId; Debug uses only current Sandbox players.
  • Revisions stay strings; writes that depend on an earlier read send expectedRevision.
  • Every new mutation gets a unique UUID and is retained until its result is known.
  • Uncertain writes replay the original request without changing requestId.
  • 401, 409, 429, 5xx, and Retry-After are handled explicitly.
  • Batch and 128 KiB limits are enforced before sending; path IDs are encoded.
  • Logs, analytics, and crash reports contain no Token or sensitive attribute values.