Skip to content

API keys ​

The backend can hand out named API keys with roles, so a tool gets exactly the access it needs and never holds the admin token. Typical case: a Discord support bot that looks up one player's anticheat rows by UserId. It gets a support key, which can read that and nothing else.

  • A key looks like ttk_<id>_<secret>. The id (10 characters of 0-9 a-z) is public: lists and the audit log show it. The secret is shown once, when the key is made; the backend keeps only a SHA-256 hash of the whole key.
  • A key is a bearer token: Authorization: Bearer ttk_.... It never opens an explorer session; browser sign-in (owners as admins, viewers read-only) is unchanged (The explorer: sign in and roles).
  • Each key has a name (a-z 0-9 . _ -, 1-48 characters, unique among active keys), a role, and an optional expiry (1 to 3650 days). An expired or revoked key answers 401, like an unknown one.
  • A backend keeps at most 1,000 keys, revoked ones included (a create past that answers 409).

Roles and scopes ​

A role is a fixed set of scopes:

RoleScopesFor
gameingest:writewhat game servers do (the game key's job)
lookupidentity:resolveturning a UserId into its pids
supportidentity:resolve, players:readsupport tools: one player's allowed rows and summary
analystanalytics:sql, analytics:query, identity:resolvenotebooks and dashboards over the analytics data
adminalleverything the admin token can do, except replacing the owner list (PUT /v1/access) and the trust-link signing keys (PUT /v1/access/keys): those stay with the admin token

What each scope opens:

ScopeRoutes
ingest:writePOST /v1/ingest, /v1/identity, /v1/errors, the game side of /v1/fleet/*, /v1/notify/intents, opened, played
identity:resolveGET /v1/identity/resolve
players:readGET /v1/players/by-uid/{uid}/events and .../summary (below)
analytics:querythe named queries
analytics:sqlthe free-form SQL route
settings:writePATCH /v1/admin/settings (runtime settings; only admin has it)
admineverything else that needs the admin token today, and managing keys

A request outside the key's scopes answers 403 { "error": "forbidden", "need": "<scope>" }.

The CLI ​

The keys create | list | revoke commands ship in the next CLI release (they are on the CLI's main branch, after 0.18.1). The HTTP routes work today with any backend that has named keys.

From the game folder, with TYPETORCH_ADMIN_TOKEN in its .env and backend.url in typetorch.json:

sh
bun run typetorch keys create --name standard-bot --role support --expires 365d
bun run typetorch keys list            # id, name, role, scopes, expires, last used, built-in / revoked
bun run typetorch keys list --all      # revoked keys too
bun run typetorch keys revoke a1b2c3d4e5
  • create prints the key once, with "Store it now: it is not shown again". Put it straight into the tool's secret store or environment. Lost it? Revoke it and create another.
  • --json returns it in key, so a script can pipe it into a secret store without it ever showing on screen: bun run typetorch keys create --name standard-bot --role support --expires 1y --json | jq -r .key | <your secret store's set command>.
  • --expires takes days or years: 90d, 365d, 1y (1 to 3650 days). Without it the key never expires.
  • The CLI never writes the key to disk or a log, and hides it in its own error messages.
  • revoke takes the id (from list), never the key itself. A revoked key fails at once.
  • These sit next to the prod signing key commands (keys init, rotate, resign); they share the keys command, nothing else.

Over HTTP ​

The same over HTTP (admin scope):

RequestAnswer
POST /v1/admin/keys { name, role, expiresDays? }201 { id, key, name, role, scopes, createdAt, expiresAt }: key only here, once
GET /v1/admin/keys (?all=1: revoked too){ keys: [{ id, name, role, scopes, createdAt, createdBy, expiresAt, lastUsedAt, revokedAt, builtIn }] }, never a hash or secret
DELETE /v1/admin/keys/{id}{ ok: true, id, revokedAt }; a built-in id answers 400

Built-in keys: the env tokens ​

The tokens in the backend's environment keep working unchanged, and keys list shows them as built-in keys:

IdVariableRole
env:apiTYPETORCH_API_KEY (the game key)game
env:api-previousTYPETORCH_API_KEY_PREVIOUS (during a rotation)game
env:adminTYPETORCH_ADMIN_TOKENadmin
env:lookupTYPETORCH_LOOKUP_TOKENlookup

They can't be revoked through the API or the CLI: to retire one, change the variable in the backend's environment and restart it (Running the backend: environment).

Per-player endpoints ​

Scope players:read (the support role). The backend binds the player: the UserId comes from the path and every pid from its own identities, so a key can never widen the query. There is no free-form SQL behind them.

GET /v1/players/by-uid/{uid}/events ​

Query: names=ac_flag,ac_action (default: the whole allow-list), since and before (unix ms; default the last 30 days, and now), limit (1-100, default 50). Rows for every pid of that UserId, newest first:

json
{
  "uid": 123456789,
  "known": true,
  "pids": ["p_1a2b3c", "p_9z8y7x"],
  "rows": [
    { "t": 1762876800123, "name": "ac_flag", "kind": "custom", "pid": "p_1a2b3c", "job": "e7873b84-…", "props": { "cat": "speed", "v": 42.1 } },
    { "t": 1762876790000, "name": "join", "kind": "session", "pid": "p_1a2b3c", "job": "e7873b84-…", "props": { "from": "direct" } }
  ],
  "more": false
}

props is always a JSON object. more: true means there are older rows: ask again with before = the last row's t.

GET /v1/players/by-uid/{uid}/summary ​

Query: since (unix ms, default the last 30 days).

json
{
  "uid": 123456789,
  "known": true,
  "pids": ["p_1a2b3c"],
  "since": 1760284800000,
  "byName": { "ac_flag": 12, "ac_action": 3, "ac_honeypot": 0 },
  "byCategory": { "speed": 9, "fly": 3 },
  "sessions": 41,
  "lastSeen": 1762876800123,
  "playtimeSecs": 51840,
  "robuxSpent": 1200,
  "purchases": 4
}
  • byName: a count for every allow-listed name (0 when none).
  • byCategory: the ac_flag rows grouped by their props.cat.
  • sessions: distinct sessions. lastSeen: the newest row of any kind but fleet for those pids, or null.
  • playtimeSecs: each session's first to last row (any kind but fleet), summed, in seconds.
  • robuxSpent and purchases: the Robux and the count of the purchase rows (the framework's native purchase rows) in the range.

For a UserId this backend has never seen, events answers 200 { "uid": …, "known": false, "pids": [], "rows": [], "more": false }, and summary answers with known: false and every count 0. Both work on the DuckDB and the Basin backend, and answer 503 while analytics isn't open yet, like the other analytics reads.

Example, from the bot's host:

sh
curl --max-time 30 -H "Authorization: Bearer $TYPETORCH_SUPPORT_KEY" \
  "https://backend.example.com/v1/players/by-uid/123456789/summary"

The allow-list ​

A support key reads only the event names in the runtime setting players.supportEvents (the backend's Settings page, or TYPETORCH_PLAYERS_SUPPORT_EVENTS, comma separated; at most 64 names; default ac_flag,ac_action,ac_honeypot,join,leave). Some names are special:

NameReads
join, leave, devicethe session rows of that name (device is the player's device row; add it to the list to allow it)
feedbackthe player's feedback rows (the feedback kind), reported with the name feedback
any other namerows of that name of any kind but session, fleet and feedback

Asking for any other name answers 403 { "error": "event_not_allowed", "name": "<name>" }. An admin key (or an owner's session or the admin token) isn't limited by it.

Name your game's support-relevant analytics events to match, or add them to the list.

Rate limit ​

Every named key (ttk_...) has its own token bucket: keys.ratePerMinute requests a minute (runtime setting or TYPETORCH_KEYS_RATE_PER_MINUTE, default 120). Over it the backend answers 429 { "error": "rate_limited" } with Retry-After (seconds); wait that long. A key with the game role isn't limited by it: it keeps the ingest limits game servers have always had. The built-in env tokens keep their own checks.

Audit ​

Every players:read request writes an audit row (action players.read): the key's id, name and role, the route template (/v1/players/by-uid/:uid/events), the status, the time and the client's IP. It never holds the UserId (more people can read the audit log than the data), only a count of pids. Creating and revoking keys are audited too (keys.create, keys.revoke, never the secret). Other uses of a key only update its last used time (at most once a minute).

Released under the MIT License.