Skip to content

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, plain http:// and private or internal addresses are refused.
  • Redirects are not followed. A 3xx counts 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"
}
FieldMeaning
vPayload version. Currently 1.
alert_idThe detection’s id. Use it to deduplicate.
player.idThorium’s player id. player.uuid is the Minecraft UUID.
alert_typeThe check that fired, e.g. MCSpeed.
actionflag, warn, kick or ban.
confidence0 to 1.
vlViolation level for this check.
evidenceCheck-specific values. The keys vary by check; do not depend on them.
atWhen the detection was raised, UTC.

Headers

HeaderMeaning
X-Thorium-Eventalert.v1, or report.v1 for the report webhook
X-Thorium-Alert-IdSame as alert_id
X-Thorium-TimestampUnix seconds when this attempt was signed
X-Thorium-Signaturesha256= followed by a lowercase hex HMAC
X-Thorium-Attempt1 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, with action set to report and a report object:
{
"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:

  1. Build the string {X-Thorium-Timestamp}.{raw request body}, using the body exactly as received, before any JSON parsing.
  2. Compute HMAC-SHA256 of that string, keyed with the whole secret including the whsec_ prefix, and hex-encode it in lowercase.
  3. Compare sha256=<your hex> to X-Thorium-Signature in constant time.
  4. 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 2xx quickly and do the work asynchronously.
  • 5xx, 408, 429 and timeouts are retried with backoff, up to five attempts. Retry-After is honoured. Any other 4xx is treated as a deliberate refusal and not retried.
  • Expect the same alert_id more 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.