Appearance
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>. Theid(10 characters of0-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:
| Role | Scopes | For |
|---|---|---|
game | ingest:write | what game servers do (the game key's job) |
lookup | identity:resolve | turning a UserId into its pids |
support | identity:resolve, players:read | support tools: one player's allowed rows and summary |
analyst | analytics:sql, analytics:query, identity:resolve | notebooks and dashboards over the analytics data |
admin | all | everything 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:
| Scope | Routes |
|---|---|
ingest:write | POST /v1/ingest, /v1/identity, /v1/errors, the game side of /v1/fleet/*, /v1/notify/intents, opened, played |
identity:resolve | GET /v1/identity/resolve |
players:read | GET /v1/players/by-uid/{uid}/events and .../summary (below) |
analytics:query | the named queries |
analytics:sql | the free-form SQL route |
settings:write | PATCH /v1/admin/settings (runtime settings; only admin has it) |
admin | everything 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 a1b2c3d4e5createprints 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.--jsonreturns it inkey, 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>.--expirestakes 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.
revoketakes the id (fromlist), 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 thekeyscommand, nothing else.
Over HTTP
The same over HTTP (admin scope):
| Request | Answer |
|---|---|
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:
| Id | Variable | Role |
|---|---|---|
env:api | TYPETORCH_API_KEY (the game key) | game |
env:api-previous | TYPETORCH_API_KEY_PREVIOUS (during a rotation) | game |
env:admin | TYPETORCH_ADMIN_TOKEN | admin |
env:lookup | TYPETORCH_LOOKUP_TOKEN | lookup |
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: theac_flagrows grouped by theirprops.cat.sessions: distinct sessions.lastSeen: the newest row of any kind butfleetfor those pids, ornull.playtimeSecs: each session's first to last row (any kind butfleet), summed, in seconds.robuxSpentandpurchases: the Robux and the count of thepurchaserows (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:
| Name | Reads |
|---|---|
join, leave, device | the session rows of that name (device is the player's device row; add it to the list to allow it) |
feedback | the player's feedback rows (the feedback kind), reported with the name feedback |
| any other name | rows 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).