API

Your own tools open a case and read it back. One key. Your games.

The routes a key opens

Your tools call eight routes. Everything else this server answers is a page for a person in a browser.

  • POST /api/v1/scans opens a case. The rules are the dashboard's: a staff role, an install you own, a game your plan covers. It answers 201 with the case id. It also carries, once, the key the scanned player uses to read their own case.
  • GET /api/v1/scans/{case} reads the case back. The state, the session code once it is issued, when that session was issued, the launcher download for it, the count of findings, and the decision. No finding detail. Detail is read in the dashboard, where every read is on the record.
  • GET /api/v1/scans lists the cases nobody has decided yet, newest first. It is the reconcile a program does after a restart: what is still yours to chase.
  • POST /api/v1/scans/{case}/withdraw closes a case somebody opened in error.
  • POST /api/v1/scans/{case}/access hands out a fresh key for the scanned player, for the case whose first one never reached them. The old one stops working in the same breath.
  • GET /api/v1/installs lists the machines your community owns, which is what a new case names.
  • GET /api/v1/whoami says who your key is: the community, the role, the games it may scan, and the trial if one is running. It also says whether appeal outcomes already go to your webhook.
  • GET /api/v1/players/{player}/scans lists the cases one player was given a code for in your league events, newest first. Each one says its state and its decision, and nothing more. {player} is the player key, and you work it out yourself. Take the player's Discord id or email address. Trim the spaces off the ends and turn A to Z into a to z. Hash three things in a row with SHA-512: the bytes glimmer.league.player.v1, then the tenant number whoami gives you as 8 bytes with the lowest byte first, then that text. The key is the first 16 bytes of the hash, written as 32 lowercase hexadecimal characters. A case opened outside an event has no player key, so it is not in the list. A key from another community lists nothing.

There is no ninth route for you. POST /api/v1/billing/pay exists, and it is Connections Pay's. It is opened by Pay's signature and no API key opens it. It records a paid plan. POST /api/v1/discord/bind exists too, and it is our own Discord bot's. It is opened by the secret that application was registered with, and no API key opens it either. It is how a bot deployment is handed a community's key without a person pasting one through a chat window.

The key

An owner mints a key in the staff dashboard for one named staff member. It is shown once and stored nowhere. There is no second look. It acts as that person and can never do more than they can. Revoking it takes effect on the next request. Only a plan that includes the API mints one. On any other plan the mint is refused with the code no-api-on-plan.

Every plan still gets the plugin package and our Discord bot, and each carries a key. On a plan without the API, that key reaches three routes: opening a case, reading one back, and whoami. Those three are all the plugins and the bot call. Every other route answers 403 with the code no-api-on-plan. This is decided at each request, from the plan your community holds at that moment. A plan that adds the API opens every route to every key your community holds, and a plan that ends closes them again.

That same page lists every key your community was ever issued, the retired ones included, with the day each was minted and who it acts as. An owner retires one there with a button. A key that is out there with nobody holding it is the thing worth ending, and until you can see the list you cannot know there is one.

The key is 32 random bytes written as 64 lowercase hexadecimal characters. It travels in one header: Authorization: Bearer <the 64 characters>. There is no cookie, no form token and no sign-in step. The key is the whole credential, so it deserves the care a password gets. A wrong key is counted against the place it came from. After twenty-four wrong keys in five minutes, that place is refused until the five minutes pass, whatever key it sends. A key that works is never counted, so a plugin polling a case is never slowed by its own polling.

That last sentence is a promise, not an implementation detail. There is no success-path limit: a key that works is never counted against the failure budget, however often it calls. A working key sees attempts-exhausted only while wrong keys from its own address have spent that budget. A second plugin on the same host with a stale key can do that. The other 429 is read-slot-busy. It says this server is reading for somebody else right now, and the remedy is to send the same request again.

Opening a case

POST /api/v1/scans takes JSON. A field this page does not name is refused, not ignored. The whole body is at most 32 KiB.

  • install is the install's number, exactly as the dashboard shows it. It must be one your community owns. Without it, the case enrols a machine nobody has scanned here before, and the scan binds that install to you.
  • target is the player, spelled however you name players. 1 to 512 bytes, and not blank.
  • profile is the game. 1 to 64 characters of letters, digits, - and _. Your plan must cover it, and this server must serve a scan profile of that name.
  • category is exactly one of reported, observed, routine or linked.
  • reason is why, in words, for the record and for the appeal. Not blank, and at most 4096 bytes.

The answer is 201:

{"case": "8f14e45fce0a4b0d9f2a1c3e5d7b6a80", "state": "queued", "accused_capability": "0b7d4c2f9a1e63508d4a7b2c1e9f0356a8d2b4c6e0f19375426ab8c0d2e4f6a1"}

case is 16 bytes as 32 hexadecimal characters. It names the case everywhere else. accused_capability is 32 bytes as 64 characters. The two widths differ on purpose, so a mix-up fails rather than half works. The capability is shown this once and never again. It is the scanned player's own login for their case and their appeal. It is the one thing in this answer that goes to them.

Retrying a create you never saw answered

A create is the one call where a lost answer costs somebody else. The case is open. The capability was minted. The player has no way into their own appeal. So send an Idempotency-Key header with it, and a retry is safe.

The key is yours. 16 to 128 visible ASCII characters, one per create. A random string is the easy answer. The floor is there because a key has to belong to one create. A caller sending 1 would answer their own second player with the first one's case.

First sight opens the case and answers 201. The same key again, inside a day, answers 200. It carries the case that key already opened, and a fresh capability for it. No route anywhere reads a capability back. A day later the key is free again, and it opens a second case. Without the header, two creates are two cases, which is what they have always been.

A key belongs to the create it opened. The same key sent with a different player, game or machine is refused 409 That idempotency key already opened a case for a different scan. A new scan needs a new key., code key-reused. It is not answered with the first create's case. That answer names no player, so nothing in it would tell you it was somebody else's. And the capability in it is that other player's own login for their appeal. The words of your reason are yours to change on a retry. The player, the game and the machine are what the key stands for.

One honest edge, said out loud. If this server dies between recording the case and recording the claim, your retry opens a second case. The other choice was a flag standing in for a record nobody wrote, and this repo does not do that. Close the spare with the withdraw route. That route needs a plan with the API. On any other plan it answers 403 with the code no-api-on-plan, like every route past the three every plan reaches.

Reading it back

GET /api/v1/scans/{case} answers 200:

{"case": "8f14e45fce0a4b0d9f2a1c3e5d7b6a80", "state": "issued", "code": "…", "issued_at": 1758499200, "link": "https://anticheat.lunarwerx.com/download/glimmer-….exe", "findings": 0, "decision": null, "open": true}

code is null until the signing worker has issued the session. Its arrival is the thing worth polling for. It is the code the player enters in the client. The launcher from link carries it in its file name, so there the player only confirms it. findings is a count, never the findings themselves. decision is null until somebody decides.

open is true while the case is still waiting on somebody. It turns false the moment a decision is recorded against it, which is the same moment the case leaves the list below, so one fact answers both questions.

issued_at is when that session was issued, in whole seconds since the Unix epoch, taken from the session this server already recorded. It is null for exactly as long as code is. Before a session is bound to the case there is nothing to time, and a made-up time is worse than none. Measure a reminder from this, not from the moment your own poll first saw a code.

link is the launcher download for that code, built here so that no client ever assembles the URL for itself. It is null when code is, and also when this server serves no launcher of its own.

The states are queued, issued, clear, inconclusive, evidence-expired, flagged, upheld and withdrawn. The decisions are action-taken, no-action, rescan and refused. refused means the community recorded that the player would not run the scan. It is never a finding. These names are a contract. They are only ever added to.

upheld and withdrawn are appeal outcomes, decided by the authority, not by a scan. The scan's own outcomes are the six before them. If your community already hears about an appeal through its own webhook, those two are the states to leave alone rather than post twice.

Listing what is still open

GET /api/v1/scans answers 200:

{"scans": [{"case": "8f14e45fce0a4b0d9f2a1c3e5d7b6a80", "state": "queued", "target": "player#0420", "opened_at": 1758499200}], "next_after": null}

Open means nobody has recorded a decision on it yet, which is the same count your dashboard shows. Newest first, a hundred to a page.

next_after is a case id while your community has another page, and null at the end. Ask again with ?after=<that case id> and you get the next hundred. Follow it until it is null and you have read everything, without ever fetching an empty page.

?after= is the only parameter this route takes. Anything else is refused rather than ignored, because a link this server did not write must not be able to change quietly what you are shown.

The machines you own

GET /api/v1/installs answers 200:

{"installs": [{"install": 1000, "label": "eu-west-1", "activated_at": 1756999200}]}

install is the number a new case names. label is what your operator called that machine when it was activated: a note for people, never a credential. The install's own signing key is not in this answer and never will be.

Withdrawing a case opened in error

POST /api/v1/scans/{case}/withdraw takes {"reason": "..."}, not blank and at most 4096 bytes, and answers the case exactly as reading it back does.

It records what your staff would record in the dashboard: no action, against the person your key acts as, with your reason kept. The case closes, open turns false, and it leaves the open list. No session is ever issued for it afterwards, so nobody is asked to scan for a case you took back.

It does not set the state to withdrawn. That one is an appeal outcome the authority decides, and this is a case being closed rather than a finding being taken back.

Send it twice and the second call answers exactly what the first one did, with nothing recorded again. A retry after a timeout is safe.

Re-handing the player's key

POST /api/v1/scans/{case}/access takes no body and answers 200:

{"case": "8f14e45fce0a4b0d9f2a1c3e5d7b6a80", "accused_capability": "6b1f2d840a3c95e7182d4c6b0e9f3175a2c4e608b9d1f3527046ac8e20d4f6b3"}

It is for the case whose capability never reached the player. Your program stored nothing, or the message went to somebody who had left. One transaction drops the key the case had and commits the new one. So a case never holds two live keys. The copy somebody lost stops opening the appeal the moment this answers. The record says a capability was re-minted, by whom and when. The case itself is unchanged.

There is no route that reads a capability back. A credential a server can hand over twice is a credential a stolen backup hands over. Re-minting is the only path there is, and this is it.

Who your key is

GET /api/v1/whoami answers 200:

{"tenant": 42, "role": "staff", "profiles": ["rust"], "webhook_configured": true, "trial": {"profile": "cs2", "state": "active", "scans_left": 3, "ends_at": 1759499200}, "closed": false}

It adds nothing to what your key can already do. It only tells you what that is, which is how you check a key works. Do not probe with a case id you made up.

role is owner, staff or read-only, and those names are a contract like the states are. profiles is every game you may open a case for right now, however you came to hold it. webhook_configured is whether decided appeals are already posted to your Discord webhook. It is the fact, never the address, so your own bot can tell whether posting an outcome would say it twice. trial is null unless a trial is running, and otherwise names the game, one of active, scans-used or ended, how many scans are left, and when it ends. Scans left is absent once a trial has ended, because out of time and out of scans are different facts. closed turns true once a trial ended more than thirty days ago with no plan held. Then this is the one route that still answers.

The Discord bot's own route Coming

The Discord bot is coming; this route is not open to players or staff yet.

POST /api/v1/discord/bind is not a route your key opens. It is how our own bot deployment is handed a community's key.

An owner mints a handoff on their staff dashboard. It lasts ten minutes. It is spent once. This server kept only its digest. The deployment sends it as {"handoff": "..."}. The bearer token is the client secret its Discord application was registered with. The answer is {"tenant": 42, "api_key": "..."}, once. The key and the spend are one record, because binding is what mints the key. A key recorded without its handoff spent could be minted again. A handoff spent without its key is somebody locked out of the bot they just installed.

The guild being installed into is not in the request, and never will be. The deployment reads it from the token response it exchanged with its own client secret. This server reads it from nowhere at all. The handoff already names the community, and its owner is who minted it.

A community whose bot already holds a key is refused. The sentence is 409 This community's bot already holds a key. Retire that key in the dashboard before binding another. and the code is already-bound. The handoff is not spent. A second key would be live with nobody holding it, and only the owner's retire button can end the first.

When it refuses

A refusal is one plain sentence as text/plain, never JSON. The sentence is for whoever reads your log, and it may be reworded the day it reads better. What your program branches on is the status and one header, x-glimmer-refusal, which carries a short code that is a contract and is never reworded. Two refusals that share a status have different codes.

  • 401 Send the API key as a bearer token. No Authorization header, no Bearer in front of it, or a token that is not 64 hexadecimal characters. Code bearer-required.
  • 403 Request not permitted. The key is unknown or revoked. The person it acts for is no longer staff. The install is not yours, the case is not yours, or your plan does not cover that game. A well-formed case id that belongs to somebody else answers this, not 404. Code denied.
  • 403, in the trial's own words, when a trial is why a game is not covered. Either The trial's scans are used up. A plan continues where it left off, with every case kept. or The trial ended on its last day. A plan continues where it left off, with every case kept. Codes trial-scans-used and trial-ended, which is how to tell those two apart without reading the sentence.
  • 403 Every scan your plan includes this month is sent. Each one comes back thirty days after it went out, or a bigger plan lifts the limit. Opening a case when your plan's scans a month are all sent: Standard includes 300 and Premium 1,500, counted over the last thirty days. Code plan-scans-used.
  • 403 The trial ended more than thirty days ago. Its cases are kept, and readable again under a plan. Your community's trial ended over thirty days ago and it holds no plan. Reading, listing, withdrawing, re-handing and the machine list all answer this. Opening a case answers the trial's own refusal above. Code tenant-closed.
  • 403 Your plan does not include the API, so this key opens a case, reads a case back and says who it is, and nothing more. A plan with the API opens the rest. Your community's plan does not include the API, and this route is not one of the three every plan reaches. Code no-api-on-plan.
  • 400 Request is invalid. A profile, a category or a case id of the wrong shape, or a request the record itself refuses. Code invalid.
  • 409 That idempotency key already opened a case for a different scan. A new scan needs a new key. Your Idempotency-Key opened a case for another player, game or machine, and a key stands for one create. Code key-reused, and a fresh key is the whole remedy.
  • 429 Try again later. Too many wrong keys from one place lately. Until the five minutes pass, that place is refused whatever key it sends. Keys that work are never counted. Code attempts-exhausted.
  • 429 Try again shortly. This server is reading for somebody else and has no slot for you this second. Nothing is wrong with your key. Code read-slot-busy, and sending the same request again is the whole remedy.
  • 408 Request deadline exceeded. The request outlived the server's own deadline. Code deadline.
  • 503 The case service could not complete this request. The service could not finish. Nothing was opened, and the same request can go again. Code unavailable.

A body that is not the JSON above, or one over 32 KiB, is refused before the route runs: a 4xx and no JSON.

One more, and it catches a reader testing against a server of their own. The server expects to sit behind its TLS proxy. A request that did not arrive through that proxy is refused with the same 403 Request not permitted. a bad key gets. Every request to the real address comes through that proxy. Against a bare server of your own, that 403 is the proxy talking, not your key.

What this will not do

Some things a client would reasonably ask for are refused here on purpose. Saying so is fairer than leaving you waiting for them.

  • No lookup by session code. The code is the scanned player's own credential, and a path segment is still a URL. It is written to access logs, to every proxy in front of them, and to the Referer of anything the answer links to. Hold the case id you were handed when you opened the case, and look the case up by that.
  • No appeal link. The capability in the create answer already is the player's login for their appeal. A one-time link would be a second credential in the very place we refuse to carry the first, and one door does not need two keys.
  • No callback when a report lands. Polling is the contract. A push means this server keeps your endpoint and a signing secret for it forever, to deliver a verdict one poll sooner than you would have read it.
  • No finding detail. A count and a decision, never the findings. Detail is read in the dashboard, where every read is recorded against a named person; a poll loop is neither a person nor an audit.
  • No downloaded. Nothing here measures whether the launcher was fetched, and a fact that is not measured is not reported. issued_at is the moment that is real, and it is on the status answer.
  • No key route. An owner lists and retires keys in the staff dashboard, and no key does either. A key that could retire keys could retire its siblings. A credential that ends every other credential is a worse thing to lose than the ones it would end.

With curl

curl -X POST https://anticheat.lunarwerx.com/api/v1/scans \
  -H "Authorization: Bearer $GLIMMER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"install": 1000, "target": "player#0420", "profile": "rust", "category": "reported", "reason": "Reported by two players in one round."}'

Then, until code is there:

curl https://anticheat.lunarwerx.com/api/v1/scans/$CASE \
  -H "Authorization: Bearer $GLIMMER_KEY"

With Python

Nothing to install: this is the standard library. It opens a case, prints the capability that goes to the player, and polls until the code exists.

import json, os, time, urllib.error, urllib.request

BASE = "https://anticheat.lunarwerx.com"
KEY = os.environ["GLIMMER_KEY"]

def call(method, path, body=None):
    request = urllib.request.Request(
        BASE + path,
        method=method,
        data=None if body is None else json.dumps(body).encode(),
        headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as answer:
            return json.load(answer)
    except urllib.error.HTTPError as refused:
        raise SystemExit(str(refused.code) + " " + refused.read().decode().strip())

opened = call("POST", "/api/v1/scans", {
    "target": "player#0420",
    "profile": "rust",
    "category": "reported",
    "reason": "Reported by two players in one round.",
})
print("case", opened["case"])
print("hand the player", opened["accused_capability"])

while True:
    case = call("GET", "/api/v1/scans/" + opened["case"])
    if case["code"]:
        print("code", case["code"], "state", case["state"])
        break
    time.sleep(5)

From an assistant Coming

The AI assistant (MCP) is coming; there is nothing to install yet.

  • The MCP server is coming. It is one small program on a staff PC. It hands Claude or ChatGPT five of the routes above. It opens a case, reads it back, lists what is still open, lists your machines and says who the key is. It never withdraws a case. It has no tool that re-hands a player's key. But asking it to open the same case twice in one hour is a retry, and a retry hands out a fresh key, as above. Its key is minted for it in the staff dashboard and lives in the one file it reads.