Skip to content

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:

To have detections pushed to you instead of polling, see webhooks.

Base URL

https://api.thorium.ac/api/v1

Every 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

  1. 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.
  2. 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.
  3. Make a request:
Terminal window
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.

ScopeGrants
ViewNetworksPlan and usage, dashboard stats
ViewServersServer list and connection state
ViewPlayersPlayers, sessions, online list, activity
ViewAlertsDetections, detection stats, replays
ViewDetectionsAnalytics
ViewBansReserved; no endpoint needs it yet
ViewAuditLogsAudit log
ManageDetectionsMark detections reviewed, dismiss them
AddBansBan or kick a player
ManageBansUnban a player
ManageServersRegister 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, camelCase field names.
  • Ids are UUIDs. Player id is Thorium’s id for the player on your network; minecraftUuid is the Minecraft account UUID. Use id in 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:

PlanMultiplier
Starter1x
Network2x
Pro3x
Scale5x
Enterprise5x, or higher by arrangement
EndpointsRequests per minute
Reads under /players, /servers, /organizations/{orgId}/entitlement and /stats60
/organizations/{orgId}/analytics30
Bans, unbans, kicks, acknowledge, dismiss, bulk review, PATCH /servers/{serverId}30
POST /servers10

Rate-limited responses carry:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the window
X-RateLimit-RemainingRequests left in the window
X-RateLimit-ResetUnix 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." }
StatuserrorMeaning
400organization_requiredNo orgId (or organizationId) was sent
401unauthorizedMissing, malformed, revoked or unknown key
402usage_gatedPlayer usage has exceeded the plan’s allowance; see message and gatedAt
403organization_mismatchThat network is not this key’s
403missing_scopeThe key was not granted the scope this endpoint needs
403plan_featureThe plan does not include the API
404not_publicThat endpoint is not part of the public API
429Rate limit exceededSee 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 404
content-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 += 1
print(len(flagged), "flagged players")