API overview
The API lets you read players, detections, servers and analytics, and act on them, from your own tooling: a staff panel, a ticket bot, a spreadsheet export, a SIEM.
Endpoint reference:
- Players: list, search, detail, sessions, activity, kicks and bans
- Detections and replays: list, review, dismiss, stats, replay files
- Servers and network: servers, plan, dashboard stats, analytics, audit log
To have detections pushed to you instead of polling, see webhooks.
Base URL
https://api.thorium.ac/api/v1Every path in these docs is relative to it.
Your plan
The API is included on every paid plan. A key on the Free plan authenticates and then
receives 403 plan_feature on every call.
Quick start
- Create a key under Networks → your network → API Keys. Give it a name and pick its
scopes. The key starts with
thor_and is shown once; Thorium stores only a hash of it. - Copy your network id. It is the id in the dashboard URL for the network
(
/dashboard/networks/<id>), and it is required on every request. - Make a request:
export THORIUM_API_KEY="thor_..."export ORG_ID="5b1d8a52-3c1e-4f7a-9d2b-6e0c4a8f1b37"
curl -s -H "Authorization: Bearer $THORIUM_API_KEY" \ "https://api.thorium.ac/api/v1/players/count?orgId=$ORG_ID"{ "count": 1843 }Authentication
Send the key as a bearer token on every request:
Authorization: Bearer thor_...A request acts as the member who created the key, limited to the key’s scopes. Anything the key does is recorded in the audit log under that member’s name.
The network id is always required
Every request must carry the network id, as orgId in the path or query string (the server
endpoints call it organizationId). It is never inferred, because the member who created a
key may belong to several networks and guessing which one they meant is how data ends up in
the wrong place. A key sent another network’s id is refused with 403 organization_mismatch,
even if its owner is a member there.
Scopes
A key holds a subset of its creator’s permissions, chosen when it is created. It can never exceed them: if the creator loses a permission, or leaves the network, the key loses it too.
New keys get every read scope except ViewAuditLogs unless you choose otherwise.
| Scope | Grants |
|---|---|
ViewNetworks | Plan and usage, dashboard stats |
ViewServers | Server list and connection state |
ViewPlayers | Players, sessions, online list, activity |
ViewAlerts | Detections, detection stats, replays |
ViewDetections | Analytics |
ViewBans | Reserved; no endpoint needs it yet |
ViewAuditLogs | Audit log |
ManageDetections | Mark detections reviewed, dismiss them |
AddBans | Ban or kick a player |
ManageBans | Unban a player |
ManageServers | Register and rename servers |
Keys cannot be granted billing, member, role, network-deletion or API-key permissions. A key can never create another key.
Conventions
- JSON request and response bodies,
camelCasefield names. - Ids are UUIDs. Player
idis Thorium’s id for the player on your network;minecraftUuidis the Minecraft account UUID. Useidin paths. - Timestamps are ISO 8601 in UTC, e.g.
2026-09-28T18:04:05.123Z. Query parameters that take a time (since,startDate,endDate) accept the same format. - Enums are strings, e.g.
"game": "Minecraft". - Player-controlled text (usernames, report reasons) is returned as stored. Escape it before rendering it anywhere that interprets markup.
Paging
List endpoints take page (from 1) and pageSize, and return a pagination object:
"pagination": { "currentPage": 1, "pageSize": 50, "totalPlayers": 1843, "totalPages": 37, "hasNextPage": true, "hasPreviousPage": false}The total’s name follows the resource (totalPlayers, totalDetections). pageSize defaults
to 50 and is capped at 100; out-of-range values are clamped rather than rejected. The activity
and audit endpoints use a flatter shape, described on their pages.
How far back you can read
Detections, stats, analytics, activity and replays are returned through your plan’s history
window: 90 days on Pro, 120 on Scale and Enterprise. Evidence is kept for 120 days on every
plan, so upgrading widens the window over data that is already there. Responses that are
limited by the window say so in a history object:
"history": { "days": 90, "retentionDays": 120, "since": "2026-06-30T18:04:05Z" }See plans and billing.
Rate limits
Limits are per key, per endpoint path, per minute, so one integration cannot spend another’s
budget. Paths include ids: GET /players/{a} and GET /players/{b} are counted separately.
Query strings are not part of the path.
The limits below are the Starter numbers. Higher plans multiply every one of them:
| Plan | Multiplier |
|---|---|
| Starter | 1x |
| Network | 2x |
| Pro | 3x |
| Scale | 5x |
| Enterprise | 5x, or higher by arrangement |
| Endpoints | Requests per minute |
|---|---|
Reads under /players, /servers, /organizations/{orgId}/entitlement and /stats | 60 |
/organizations/{orgId}/analytics | 30 |
Bans, unbans, kicks, acknowledge, dismiss, bulk review, PATCH /servers/{serverId} | 30 |
POST /servers | 10 |
Rate-limited responses carry:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the window |
X-RateLimit-Remaining | Requests left in the window |
X-RateLimit-Reset | Unix seconds when the window resets |
Over the limit you get 429 with a Retry-After header in seconds:
{ "error": "Rate limit exceeded", "limit": 60, "retryAfter": 41 }Errors
Authentication and access refusals are JSON with an error code and a message:
{ "error": "missing_scope", "message": "This key is not scoped for ManageDetections." }| Status | error | Meaning |
|---|---|---|
400 | organization_required | No orgId (or organizationId) was sent |
401 | unauthorized | Missing, malformed, revoked or unknown key |
402 | usage_gated | Player usage has exceeded the plan’s allowance; see message and gatedAt |
403 | organization_mismatch | That network is not this key’s |
403 | missing_scope | The key was not granted the scope this endpoint needs |
403 | plan_feature | The plan does not include the API |
404 | not_public | That endpoint is not part of the public API |
429 | Rate limit exceeded | See rate limits |
Validation and not-found errors from individual endpoints are simpler. Some are JSON with a
message, some are a plain-text sentence:
HTTP/2 404content-type: text/plain; charset=utf-8
Player not found.Branch on the status code, not on the body text.
Example client
A small Node client that pages through every flagged player and waits out rate limits:
const BASE = "https://api.thorium.ac/api/v1";const KEY = process.env.THORIUM_API_KEY;const ORG = process.env.ORG_ID;
async function get(path, params = {}) { const url = new URL(BASE + path); url.search = new URLSearchParams({ orgId: ORG, ...params }); for (;;) { const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }); if (res.status === 429) { await new Promise(r => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000)); continue; } if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json(); }}
let page = 1, flagged = [];for (;;) { const { players, pagination } = await get("/players/paginated", { status: "flagged", page, pageSize: 100 }); flagged.push(...players); if (!pagination.hasNextPage) break; page++;}console.log(flagged.map(p => `${p.username} (${p.alertCount} detections)`).join("\n"));The same in Python:
import os, time, requests
BASE = "https://api.thorium.ac/api/v1"session = requests.Session()session.headers["Authorization"] = f"Bearer {os.environ['THORIUM_API_KEY']}"ORG = os.environ["ORG_ID"]
def get(path, **params): while True: r = session.get(BASE + path, params={"orgId": ORG, **params}, timeout=15) if r.status_code == 429: time.sleep(int(r.headers.get("Retry-After", 5))) continue r.raise_for_status() return r.json()
page, flagged = 1, []while True: body = get("/players/paginated", status="flagged", page=page, pageSize=100) flagged += body["players"] if not body["pagination"]["hasNextPage"]: break page += 1print(len(flagged), "flagged players")