Webhooks
Webhooks push detections to your systems as they are raised, instead of polling.
Discord bot
The quickest route. Under Settings → Discord, link a Discord server and pick channels for alerts, reports and logs. Detections below your alert threshold are recorded but not announced.
Alert webhook
Under Settings → Alerts, set Alert webhook to either:
- A Discord webhook URL (
https://discord.com/api/webhooks/…). Each detection is posted as an embed: check, player, server, action, confidence and VL. Available on every plan, and useful when you would rather not add the bot. - Any other HTTPS URL. Each detection is sent as signed JSON, described below. Custom endpoints need the Network plan.
Every alert goes to the webhook, in addition to any bot channel. Player reports have their own report webhook.
Requirements for custom endpoints
https://on port 443. Other ports, plainhttp://and private or internal addresses are refused.- Redirects are not followed. A
3xxcounts as a rejection. - Deliveries come from these addresses, if you need to allowlist them:
140.82.9.108,144.202.10.193,207.148.23.79.
Payload
A POST with Content-Type: application/json:
{ "v": 1, "alert_id": "9a1d0a0e-2f3b-5c4d-8e6f-0123456789ab", "org_id": "22222222-2222-2222-2222-222222222222", "server_id": "11111111-1111-1111-1111-111111111111", "server_name": "Survival 1", "game": "Minecraft", "player": { "id": "33333333-3333-3333-3333-333333333333", "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5", "username": "Steve" }, "alert_type": "MCSpeed", "action": "kick", "confidence": 0.93, "vl": 12.5, "evidence": { "over_ratio": 1.4, "ticks": 6 }, "at": "2026-09-12T18:04:05.123Z"}| Field | Meaning |
|---|---|
v | Payload version. Currently 1. |
alert_id | The detection’s id. Use it to deduplicate. |
player.id | Thorium’s player id. player.uuid is the Minecraft UUID. |
alert_type | The check that fired, e.g. MCSpeed. |
action | flag, warn, kick or ban. |
confidence | 0 to 1. |
vl | Violation level for this check. |
evidence | Check-specific values. The keys vary by check; do not depend on them. |
at | When the detection was raised, UTC. |
Headers
| Header | Meaning |
|---|---|
X-Thorium-Event | alert.v1, or report.v1 for the report webhook |
X-Thorium-Alert-Id | Same as alert_id |
X-Thorium-Timestamp | Unix seconds when this attempt was signed |
X-Thorium-Signature | sha256= followed by a lowercase hex HMAC |
X-Thorium-Attempt | 1 for the first attempt, higher for retries |
Report webhook
Under Settings → Player reports, set Report webhook to receive every in-game
/report as it is made. It takes the same two kinds of URL, with the same plan rules
and requirements as the alert webhook:
- A Discord webhook URL gets an embed with the reported player, who reported them, the server and the reason.
- Any other HTTPS URL gets signed JSON with
X-Thorium-Event: report.v1. The body has the same shape as an alert, withactionset toreportand areportobject:
{ "v": 1, "alert_id": "6bed81c8-5185-4217-925e-0d2b9a4c6084", "org_id": "22222222-2222-2222-2222-222222222222", "server_id": "11111111-1111-1111-1111-111111111111", "server_name": "Survival 1", "game": "Minecraft", "player": { "id": "33333333-3333-3333-3333-333333333333", "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5", "username": "Steve" }, "alert_type": "Report", "action": "report", "confidence": 0, "vl": 0, "evidence": {}, "at": "2026-09-12T18:04:05.123Z", "report": { "reporter": "jeb_", "reason": "flying over spawn" }}player is the reported player. report.reporter is the username of whoever sent the
report and report.reason is what they typed, or empty. Both are player-controlled.
Reports are only sent while /report is switched on. Both webhooks can point at the
same endpoint; tell them apart by X-Thorium-Event.
Verifying deliveries
Once a custom URL is saved, Settings shows its signing secret, beginning
whsec_. The alert and report webhooks share one secret. Discord webhooks are not
signed.
To verify a delivery:
- Build the string
{X-Thorium-Timestamp}.{raw request body}, using the body exactly as received, before any JSON parsing. - Compute HMAC-SHA256 of that string, keyed with the whole secret including the
whsec_prefix, and hex-encode it in lowercase. - Compare
sha256=<your hex>toX-Thorium-Signaturein constant time. - Reject timestamps more than a few minutes from your clock. The timestamp is signed so that a captured delivery cannot be replayed later.
import crypto from "node:crypto";
function verify(secret, headers, rawBody) { const ts = headers["x-thorium-timestamp"]; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(`${ts}.${rawBody}`) .digest("hex"); const given = headers["x-thorium-signature"] ?? ""; return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));}To rotate the secret, clear both webhook URLs and save, then set them again and save. The old secret stops verifying immediately.
Handling deliveries
- Respond
2xxquickly and do the work asynchronously. 5xx,408,429and timeouts are retried with backoff, up to five attempts.Retry-Afteris honoured. Any other4xxis treated as a deliberate refusal and not retried.- Expect the same
alert_idmore than once, not only after a retry: a detection is sent again as its violation level climbs. Deduplicate on it. - Repeated failures pause deliveries to your endpoint for a few minutes before one is tried again. Alerts that arrive while your endpoint is down may be dropped; the dashboard and API keep the full history.
- Treat every field as untrusted input. Usernames and server names are player- or admin-controlled. Escape them before rendering or logging into anything that interprets markup.