API reference
Every route Yggdrasil Panel serves, what it does, and what it takes to call it. The web UI talks to this API and nothing else, so anything the UI can do, you can automate.
Authentication
Yggdrasil accepts two kinds of credential, and one endpoint reads them in a fixed order:
Authorization: Bearer <token>— the header wins if present.- The
ygg_tokencookie. - The
?token=query parameter.
Session cookie
POST /api/auth/login with {"username", "password"} (plus "code" when the account has TOTP
enabled) returns a signed JWT and sets it as a ygg_token cookie. The cookie is HttpOnly,
SameSite=Strict, path /, and lives for the configured session TTL. The same JWT is also returned
in the response body.
Every request carrying a JWT is re-checked against the database: the panel reads the user’s current
role, refuses the request if the account is disabled, and refuses it if the token’s version no
longer matches the account’s token_version. That last check is what makes POST /api/auth/logout
a real revocation — it bumps token_version, invalidating every JWT ever issued to that user.
Because the role is read live, a demotion takes effect on the next request rather than at token
expiry.
API tokens
For automation, mint a token with POST /api/tokens and a body of {"name": "..."}. The response
contains the plaintext token exactly once:
{"id": "…", "name": "home-automation", "token": "ygg_…"}
Only a hash is stored, so a lost token cannot be recovered — delete it and mint another. Tokens
start with ygg_, which is how the auth middleware tells them apart from JWTs. Send one as
Authorization: Bearer ygg_…. GET /api/tokens lists your own tokens with their last-used
timestamp; DELETE /api/tokens/{id} removes one, and you may only delete your own.
Read Gotchas before you hand an API token to anything. A token is not a scoped credential.
WebSocket handshakes
Browsers cannot set headers on a WebSocket handshake, so the three streaming endpoints also accept
?token=<jwt-or-api-token> in the query string. The access log redacts token, access_token, and
api_key query parameters, so a token in a URL does not land in journald — but it does travel in
the request line, so prefer the cookie from a browser and the query parameter only where you have
no alternative.
Cross-origin requests and CSRF
The panel serves its own UI, so it never needs credentialed cross-origin access. CORS allows any
origin but never reflects credentials, and the allowed headers are Accept, Authorization,
Content-Type, and X-CSRF-Token.
On top of SameSite=Strict, the auth middleware rejects POST, PUT, PATCH, and DELETE
whenever the request carries an Origin header whose hostname differs from the request’s host. The
response is 403 {"error": "cross-origin request blocked"}. A browser on the panel’s own origin
matches and passes. Bearer automation — curl, a script, a home assistant — sends no Origin at all,
so the check does not apply to it. The WebSocket upgrader applies the same rule: an empty Origin
or a same-host one is accepted, anything else is refused.
Login rate limits
POST /api/auth/login is limited to 5 attempts per IP per minute; over that you get
429 {"error": "too many login attempts"}. The client IP comes from the real-IP middleware, so a
reverse proxy’s X-Forwarded-For is honoured.
Because a spoofed X-Forwarded-For would sidestep a purely IP-based limit, there is a second,
per-account lockout: 10 failed attempts on one username within 15 minutes locks that username for 15
minutes, regardless of source IP, with 429 {"error": "account temporarily locked due to repeated failed logins; try again later"}. A successful login clears the counter. A wrong password, an
unknown username, a bad TOTP code, and a replayed TOTP code all count as failures.
Permissions
A user is either a global admin or a delegate. Admins bypass every check in the panel — the
permission helpers return true for them before any grant is loaded, and requireAdmin routes
accept them unconditionally.
Everyone else holds grants. A grant is a set of permissions attached to one scope. There are eight permissions:
| Permission | Covers |
|---|---|
server.view |
Seeing the server, its status, stats, metrics, queries, and logs |
server.control |
Start, stop, restart, install, update, wipe, watchdog, config edits |
server.console |
The console stream, RCON, and player moderation |
server.files |
Browsing, reading, editing, uploading, downloading, deleting files |
server.create |
Creating servers within the scope |
server.delete |
Deleting servers |
server.backup |
Listing, running, verifying, restoring, and deleting backups |
server.schedule |
Creating schedules and reading their run history |
And four scopes a grant can hang off:
| Scope | Grant applies to |
|---|---|
global |
Every server |
realm |
Every server in that realm |
gameskill |
Every server built from that rune, in any realm |
server |
That one server |
A check passes when any single grant both contains the permission and covers the target. So a
server.control grant at realm scope lets you restart every server in that realm; a server.files
grant at gameskill scope lets you edit files on every DayZ server you can see, wherever it lives.
List endpoints filter rather than refuse: GET /api/servers returns only servers you can view, and
GET /api/domains and GET /api/schedules filter the same way.
Denied requests return 403 {"error": "forbidden: insufficient permissions"}. requireAdmin
returns 403 {"error": "forbidden"}.
Rune management, realm mutations, user management, and every integration setting are admin-only — a rune controls the Docker runtime (image, command, user, capabilities, devices, mounts), so uploading one is equivalent to root on the host.
Gotchas
Two behaviors here will not match what you assume from the shape of the API. Both are worth reading before you build against it.
API tokens carry no scope of their own
An ygg_ token is not a scoped credential. It is a pointer to a user. When the middleware sees the
ygg_ prefix it looks the token’s hash up in api_tokens, joins to the owning user, and builds
claims from that user’s id, username, and current role. There is no per-token permission subset and
no way to create one: a token minted by an admin is an admin token, and it passes every
requireAdmin route — user management, rune upload, system update, the lot. A token minted by a
delegate carries exactly that delegate’s grants, no more and no less. If you want a narrow
automation credential, create a user with narrow grants and mint the token as that user.
The token lookup filters on disabled=0, so disabling an account does immediately kill its tokens.
Logout does not revoke API tokens
The token_version mechanism that makes logout revoke JWTs does not reach API tokens. The claims
built for an ygg_ token omit the version field entirely, and the API-token branch of the
middleware returns before the code that compares a token’s version against the account’s. Bumping
token_version — which is all POST /api/auth/logout does — therefore has no effect on any ygg_
token. Logging out everywhere leaves every API token live.
To revoke an API token, delete it: DELETE /api/tokens/{id}. To revoke all of a user’s tokens at
once, disable or delete the user.
A delegate can create a schedule they cannot then touch
POST /api/schedules is permission-checked. For a server-scoped schedule it requires
server.schedule on that server, plus the permission the scheduled action would need if you ran it
by hand: server.console for a command, server.control for start/stop/restart/update/wipe,
server.backup for a backup. A rendered player message needs nothing beyond server.schedule. The
table is exhaustive and fails closed — an action with no entry is rejected. Realm- and
global-scoped schedules are admin-only.
The other three mutations are not permission-checked. PUT /api/schedules/{id},
DELETE /api/schedules/{id}, and POST /api/schedules/{id}/run are flat admin-only and return
403 {"error": "admin required"} for everyone else, with no consideration of grants.
So a delegate with server.schedule and server.control on a server can create a nightly restart,
see it in GET /api/schedules, and read its history at GET /api/schedules/{id}/runs — but cannot
edit its cron expression, cannot disable it, cannot delete it, and cannot trigger it early. Only an
admin can do those.
Conventions
Everything is JSON in and JSON out, with Content-Type: application/json.
Errors are a flat object with a single key, returned with the matching HTTP status:
{"error": "forbidden: insufficient permissions"}
JSON request bodies are read through a 1 MiB limit. The reader truncates at the cap rather than
returning a distinct error, so an oversized body surfaces as 400 {"error": "invalid request"} from
the decode. This applies to the pre-auth login endpoint too, which is the point: an unauthenticated
client cannot exhaust memory. A few endpoints have their own limits — rune upload caps the body at
512 KB, file upload is multipart with a 64 MiB form buffer, and the file editor refuses to open
anything over 5 MB.
There is deliberately no global request timeout. The console, log, and install streams are long-lived, and container operations like an image pull or a first start can run for minutes; a blanket timeout dropped both. Individual operations carry their own contexts instead — the AI planner, for instance, gives the model 60 seconds.
Responses set X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
Referrer-Policy: strict-origin-when-cross-origin, and a strict CSP. HSTS is asserted only when the
request arrived over TLS or through a proxy that set X-Forwarded-Proto: https, so plain-HTTP LAN
access does not lock you out.
Worked example
Mint a token in the UI under Settings → API tokens, or over the API from an existing session. Then list your servers:
curl -s -H "Authorization: Bearer ygg_XXXXXXXX" \
https://panel.example.com/api/servers
Each entry carries the server’s id, name, rune, status, allocated ports, tags, and more. Abridged:
[
{
"id": "3f9c…",
"name": "survival",
"gameskill_id": "minecraft-java",
"status": "stopped",
"ports": {"game": 25565, "rcon": 25575},
"perms": ["server.view", "server.control", "server.console"]
}
]
The perms array on each server is the caller’s effective permissions on it — useful for deciding
what to attempt. An admin always gets all eight. Start one:
curl -s -X POST -H "Authorization: Bearer ygg_XXXXXXXX" \
https://panel.example.com/api/servers/3f9c…/start
No Origin header goes out, so the same-origin mutation check does not apply. Follow the console:
websocat "wss://panel.example.com/api/servers/3f9c…/console?token=ygg_XXXXXXXX"
Route reference
The Auth column names the strictest requirement. “Session” means any authenticated caller, JWT or API token. “Admin” means a global admin. A permission name means that permission on the target server, which an admin also satisfies.
Public
These need no credential at all. The status and beacon routes 404 rather than 403 when their feature is switched off, so a disabled status page or beacon receiver is not advertised.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/auth/login |
none | Exchange username/password (+ TOTP code) for a JWT and session cookie |
POST |
/api/auth/passkey/login/begin |
none | Start a WebAuthn login; returns the challenge |
POST |
/api/auth/passkey/login/finish |
none | Complete a WebAuthn login; issues the session |
GET |
/api/version |
none | Build version, repo URL, latest release, and whether an update exists |
GET |
/api/status |
none | Public status board JSON; 404 when the status page is off |
GET |
/status |
none | Public status page HTML; 404 when the status page is off |
GET |
/status.js |
none | The status page’s script, served same-origin for the CSP |
POST |
/api/beacon |
none | Receive an install ping; 404 unless this instance is the collector |
GET |
/api/beacon/count |
none | Installs seen in the last 30 days; 404 unless the collector opted into publishing. count is null below the threshold — “not saying”, not zero |
POST |
/api/auth/forgot |
Public | Start a password reset. Deliberately opaque: the same generic 200 whatever the input, and the mail is sent on a background goroutine, so neither the body nor the response time reveals whether an account or an SMTP config exists |
POST |
/api/auth/reset |
Public | Complete a reset with the emailed token. The token is single-use and short-lived; success revokes every existing session for that account |
Session and account
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/auth/logout |
Session | Clear the cookie and bump token_version, revoking every JWT for the user |
GET |
/api/auth/me |
Session | The caller’s id, username, role, and the scopes they can create servers in |
GET |
/api/auth/2fa |
Session | Whether TOTP is enabled on the caller’s account |
POST |
/api/auth/2fa/setup |
Session | Generate a pending secret and return its otpauth:// URI |
POST |
/api/auth/2fa/enable |
Session | Verify a code against the pending secret and turn TOTP on |
POST |
/api/auth/2fa/disable |
Session | Turn TOTP off; requires a valid code |
GET |
/api/auth/passkey/credentials |
Session | List the caller’s registered passkeys |
POST |
/api/auth/passkey/register/begin |
Session | Start registering a passkey |
POST |
/api/auth/passkey/register/finish |
Session | Finish registering a passkey |
PUT |
/api/auth/passkey/credentials/{id} |
Session | Rename one of the caller’s passkeys |
DELETE |
/api/auth/passkey/credentials/{id} |
Session | Delete one of the caller’s passkeys |
GET |
/api/tokens |
Session | List the caller’s API tokens with last-used times |
POST |
/api/tokens |
Session | Mint an API token; the plaintext is returned once |
DELETE |
/api/tokens/{id} |
Session | Delete one of the caller’s own API tokens |
Claude connector (MCP)
The panel speaks the Model Context Protocol at /api/mcp so Claude can read and drive it — see
Claude connector. The endpoint is Streamable HTTP: a JSON-RPC
message in a POST, one JSON object back, no server-initiated stream (GET and DELETE answer
405, which is how the transport spec says to advertise that).
Authorization is OAuth 2.1 with the panel as its own authorization server, so a client registers itself and the user approves it in a browser. Those endpoints are public by necessity — they are what a client uses before it has a token — and grant nothing until someone presses Allow.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/mcp |
MCP token or API token | JSON-RPC: initialize, ping, tools/list, tools/call |
GET |
/api/mcp/info |
Session | The connector URL, tool count, and whether this address is publicly reachable |
GET |
/api/mcp/connections |
Session | Clients the caller has connected |
DELETE |
/api/mcp/connections/{id} |
Session | Revoke one client’s access immediately |
GET |
/.well-known/oauth-protected-resource |
Public | Resource metadata (RFC 9728) — also served under /api/mcp |
GET |
/.well-known/oauth-authorization-server |
Public | Authorization server metadata (RFC 8414) |
POST |
/oauth/register |
Public | Dynamic client registration (RFC 7591) |
GET |
/oauth/authorize |
Public | The consent screen |
POST |
/oauth/authorize |
Session cookie | Approve or decline; issues the authorization code |
POST |
/oauth/token |
Public (PKCE) | Code → access token, and refresh-token rotation |
An unauthenticated request to /api/mcp answers 401 with
WWW-Authenticate: Bearer resource_metadata="…", which is how a client discovers the flow.
Runes
The API spells a rune gameskill. Reading the catalogue is open to any session because the
create-server form needs it; everything that changes the catalogue is admin-only.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/gameskills |
Session | List runes, each flagged with whether the caller can create servers from it |
GET |
/api/gameskills/{id} |
Session | The parsed rune definition |
POST |
/api/gameskills |
Admin | Upload a rune YAML (body capped at 512 KB) |
POST |
/api/gameskills/import-egg |
Admin | Convert a Pterodactyl egg JSON into a rune |
POST |
/api/gameskills/import-xml |
Admin | Import a rune expressed in XML |
GET |
/api/gameskills/github |
Admin | List runes in a GitHub repo directory. Each entry carries the repo copy’s version, plus installed / installed_version / builtin for the local one |
GET |
/api/gameskills/updates |
Admin | Installed non-builtin runes the catalog has moved past: {updates:[{id,name,installed_version,available_version,download_url}], checked_at, note?}. Matched by rune id against the community catalog; a note means the check couldn’t run, which is not the same as everything being current |
POST |
/api/gameskills/install-from-github |
Admin | Fetch, validate, and store one rune from GitHub |
DELETE |
/api/gameskills/{id} |
Admin | Delete a rune |
GET |
/api/rune-repos |
Admin | Saved rune repositories, the built-in catalog first. has_token says a repo carries its own GitHub token; the token itself is never returned |
POST |
/api/rune-repos |
Admin | Save a repository: {name, repo, path, ref, token?} |
PUT |
/api/rune-repos/{id} |
Admin | Edit one: {name?, path?, ref?, token?}. An omitted or masked token keeps what’s stored, an empty string clears it |
DELETE |
/api/rune-repos/{id} |
Admin | Forget a repository (and its token) |
POST |
/api/gameskills/import-compose |
Admin | Translate an uploaded docker-compose file into a rune and store it. Returns {id, name, warnings} — warnings lists bind mounts to re-add as host mounts and anything the translation dropped. Create the server from the returned id the normal way |
GET |
/api/gameskills/{id}/servers |
Admin | Which installed servers a rune restart would affect, so the UI can name them first — “restart 8 servers” is a different decision from “restart this one” |
POST |
/api/gameskills/{id}/restart-servers |
Admin | Restart every running server using this rune, sequentially in the background. Failures are collected rather than aborting the sweep |
Moving a large server between panels — pull, don’t push. An export streams, so downloading a
multi-gigabyte server works through a tunnel. An upload is a request body, and Cloudflare caps those
at 100 MB (Free/Pro), so pushing a real data directory up through a tunnel cannot work regardless of
what the panel allows. POST /api/panel/remote/import inverts the direction: the receiving panel
fetches the bundle itself, which is a large response and is not capped, and the browser never
carries the data. If both panels share a Tailscale/VPN, give the private address and the copy skips
the internet entirely. Two cautions: the bundle carries decrypted secrets, so pull over HTTPS or a
private network; and because the panel fetches an operator-supplied URL, these routes are admin-only
and audited — they cannot be host-allowlisted, since reaching private addresses is the point.
The GitHub routes read public repositories anonymously. To browse and install from a private
one — or to lift GitHub’s 60-requests-per-hour anonymous limit — store a token via
PUT /api/settings/github; it is then sent as a Bearer credential on GitHub’s own hosts only. Note
that GitHub answers 404 (not 403) for a private repository the caller cannot see, so a listing
that fails without a token is usually a permissions problem rather than a wrong path.
Servers
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/servers |
Session | List servers, filtered to those the caller can view |
POST |
/api/servers |
server.create |
Create a server in a realm from a rune |
GET |
/api/servers/{id} |
server.view |
One server, with the caller’s effective permissions |
PUT |
/api/servers/{id} |
server.control |
Edit name, variable values, resource caps, notes, notes_markdown. Changing realm_id or host_mounts requires admin — both are privilege-scoping fields |
DELETE |
/api/servers/{id} |
server.delete |
Delete the server |
POST |
/api/servers/{id}/clone |
server.view + server.create |
Copy a server’s setup into a fresh server with new ports and an empty data dir |
GET |
/api/servers/{id}/import-data |
server.view |
Whether the rune supports data import and the inputs it expects |
POST |
/api/servers/{id}/import-data |
Admin | Import an existing deployment: multipart upload of the rune’s declared inputs, run in one-shot containers streamed to the build log |
POST |
/api/servers/{id}/install |
server.control |
Run or re-run the install in the background |
POST |
/api/servers/{id}/start |
server.control |
Start the container |
POST |
/api/servers/{id}/stop |
server.control |
Stop the container |
POST |
/api/servers/{id}/restart |
server.control |
Restart the container |
POST |
/api/servers/{id}/safe-restart |
server.control |
Restart after warning players on a countdown |
GET |
/api/servers/{id}/auto-restart |
server.control |
The auto-restart toggle’s current state: {enabled, every_hours, anchor_hour, warn, backup_first, target_id} |
PUT |
/api/servers/{id}/auto-restart |
server.control |
Create, update, or remove the managed auto-restart schedule. every_hours 1–24, anchor_hour 0–23 is the hour the cycle starts from; backup_first requires target_id |
PUT |
/api/servers/{id}/watchdog |
server.control |
Toggle auto-heal for the server |
POST |
/api/servers/{id}/wipe |
server.control |
Delete the rune’s declared wipe paths, optionally backing up first |
GET |
/api/servers/{id}/stats |
server.view |
Live CPU and memory from Docker |
GET |
/api/servers/{id}/logs/export |
server.view |
Download the container log as text/plain. tail (a count or all), since/until (a duration like 2h, or RFC3339), timestamps=true. Streamed, not buffered. The log starts at the current container’s creation — a restart makes a new one, so there is no older history to ask for |
GET |
/api/servers/{id}/install/log/export |
server.view |
Download the buffered install log as text/plain. No range: it is the last 500 lines of the most recent install, held in memory and cleared by a panel restart |
GET |
/api/servers/{id}/metrics |
server.view |
Sampled history over the last N hours (default 24, max 168) |
GET |
/api/servers/{id}/quiet-hours |
server.view |
The calmest hour of day from 14 days of player samples |
GET |
/api/servers/{id}/query |
server.view |
Live game-protocol query: players, map, version |
GET |
/api/servers/{id}/battlemetrics |
server.view |
Online-status summary from BattleMetrics, when an id is configured |
GET |
/api/servers/{id}/reachability |
server.view |
Probe the public connect address to prove the port is forwarded |
GET |
/api/servers/{id}/admin-log |
server.view |
Parsed admin-log events, when the rune supports one |
GET |
/api/servers/{id}/delegates |
Admin | Every user holding a server-scoped grant on this server |
PUT |
/api/servers/{id}/delegates |
Admin | Replace the full set of server-scoped grants on this server |
GET |
/api/servers/{id}/routes |
server.view |
Every public hostname this server answers on, primary first, plus the TCP ports a hostname may be pointed at. The primary lives on the server row; the rest are separate records |
POST |
/api/servers/{id}/routes |
Admin | Add an extra hostname, optionally naming which declared TCP port it targets (empty = the automatic choice: the port called web, else the first TCP one). Rejected if the hostname is already claimed on this panel, or if it is a bare label and no base domain is configured. Provisioned immediately when the server is running, otherwise on next start |
DELETE |
/api/servers/{id}/routes/{routeID} |
Admin | Remove an extra hostname. The live proxy rule and DNS record are deleted before the record is forgotten — the other order loses the only note of what was provisioned |
PUT |
/api/servers/{id}/ports |
Admin | Change host ports after creation: {"ports": {"<name>": <port>}}. Only the listed ports change, each must already exist and each new port must be free. A running server is recreated so it rebinds (and its proxy target is refreshed); a stopped one records the change for next start |
GET |
/api/servers/{id}/activity |
server.view |
The panel’s own recorded history for one server: named player sessions and notable security/health events, over the last N hours (default 7 days, max 30). Both lists are empty for runes that record neither, which the UI uses to hide the tab |
GET |
/api/servers/{id}/crashes |
server.view |
Recent unexpected exits, newest first, over the last N hours (default 7 days, max 30) |
DELETE |
/api/servers/{id}/crashes |
server.control |
Clear the stability history — a dismiss once the warnings have been read |
GET |
/api/crashes/summary |
Any signed-in user | {server_id: count} of unexpected exits in the last N hours (default 24, max 30 days) across every server. Counts genuine faults only — a graceful stop (exit 0/143/130) is not a crash. Backs the flapping badges |
GET |
/api/servers/{id}/app-update |
Any signed-in user | Whether this server’s rune declares an app update, and the button text to use, so the UI shows it only where it exists |
POST |
/api/servers/{id}/app-update |
server.control |
Run the rune’s update script in the background; progress streams to the install-log WebSocket. Serialised against install and import. Audited |
GET |
/api/servers/{id}/jar-update |
server.view |
Whether a newer Paper/Purpur build exists for a Minecraft-Java server’s installed version |
Fleet
Cross-server aggregates for the Dashboard. These are not RBAC-filtered per server: they either
return whole-fleet numbers or, in the case of /fleet/activity, merge the audit log with every
server’s history — which is why that one is admin-gated and the others return only aggregates.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/fleet/summary |
Any signed-in user | How many servers are up, total players online, and the CPU/RAM the containers are using. Figures come from each running server’s most recent sample within the last 15 minutes, so stopped servers drop out |
GET |
/api/fleet/players |
Any signed-in user | Who is online right now across running servers — queried live and in parallel, with player names where the protocol exposes them (A2S/DayZ) and a count otherwise (Minecraft/Bedrock) |
GET |
/api/fleet/metrics |
Any signed-in user | A compact recent CPU series per server (last ~3h, capped) for the inline sparklines on the server list — one round-trip instead of one request per row |
GET |
/api/fleet/activity |
Admin | One merged timeline of everything the panel recorded about itself across every server. Deliberately includes detections the alert policy handled quietly: without them, a detector that silently stopped working and a genuinely quiet fleet look identical |
Host migration
Move things between two running panels. Both bundle formats carry secrets in recoverable form (the target re-encrypts them with its own key), so every route is admin-only and the files should be treated like passwords.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/servers/{id}/export |
Admin | One server as a portable .tar.gz: data dir, rune, variables (secrets decrypted), limits, ports, group, schedules, watchers, notification routing, subdomain |
POST |
/api/servers/import |
Admin | Create a server from an exported bundle. Keeps the source’s host ports when free (reports ports_changed otherwise) and the subdomain unless taken (subdomain_dropped) |
GET |
/api/panel/export |
Admin | Panel configuration as JSON. ?include= any of channels,ai,integrations,network,rune_repos,watchers,users |
POST |
/api/panel/import |
Admin | Merge a panel-settings bundle: existing rows are skipped, never overwritten; AI config and integration keys are applied; returns a per-category summary. API tokens, beacon identity and passkeys never transfer |
GET |
/api/migration/export |
Admin | One archive of settings + servers: ?include= settings groups, ?servers= ids or all |
POST |
/api/migration/import |
Admin | Merge a migration archive: settings first, then each server; ?skip_existing=1 skips name clashes; returns the settings summary plus a per-server report |
Players and moderation
Player actions run over RCON, so they gate on server.console rather than server.control.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/servers/{id}/players |
server.console |
The online player list, or why it is unavailable |
POST |
/api/servers/{id}/players/kick |
server.console |
Kick a player |
POST |
/api/servers/{id}/players/broadcast |
server.console |
Broadcast a message in-game |
POST |
/api/servers/{id}/players/lock |
server.console |
Lock or unlock the server against new joins |
POST |
/api/servers/{id}/rcon |
server.console |
Send a raw RCON command |
GET |
/api/bans |
Admin | List centrally managed bans |
POST |
/api/bans |
Admin | Ban a player on one server or all of them |
DELETE |
/api/bans/{id} |
Admin | Lift a ban |
GET |
/api/violations |
Admin | List violation auto-action rules |
POST |
/api/violations |
Admin | Create a violation auto-action rule |
PUT |
/api/violations/{id} |
Admin | Edit a violation auto-action rule |
DELETE |
/api/violations/{id} |
Admin | Delete a violation auto-action rule |
POST |
/api/servers/{id}/players/ban |
server.console |
Ban a DayZ player by adding their id to ban.txt. DayZ reads it on connect, so it stops them rejoining but cannot remove someone already in-game — DayZ-Linux has no RCon |
Files
All of these resolve the server’s data directory and gate on server.files. Paths are jailed to
that directory, including through symlinks.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/servers/{id}/files |
server.files |
List a directory (?path=) |
GET |
/api/servers/{id}/files/content |
server.files |
Read a file for editing; refuses anything over 5 MB |
PUT |
/api/servers/{id}/files/content |
server.files |
Write a file, snapshotting the previous contents first |
GET |
/api/servers/{id}/files/versions |
server.files |
List snapshots for a path (metadata only) |
GET |
/api/servers/{id}/files/versions/{vid} |
server.files |
One snapshot’s full contents |
DELETE |
/api/servers/{id}/files |
server.files |
Delete a file or directory (?path=) |
POST |
/api/servers/{id}/files/upload |
server.files |
Multipart upload into a directory (send one request per file; the path field preserves sub-folders for a folder upload) |
POST |
/api/servers/{id}/files/mkdir |
server.files |
Create a directory, parents included |
GET |
/api/servers/{id}/files/download |
server.files |
Download a single file |
The editor keeps 10 versions per file and only snapshots UTF-8 files up to 256 KB.
Mods and plugins
Minecraft mods and plugins, resolved against Modrinth. Reading gates on server.view; anything that
writes a jar gates on server.files, because that is what it is doing. Mods take effect on the next
restart, which is a recreate — the API says so rather than letting a caller assume it took.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/servers/{id}/mods |
server.view |
The jars in the mod/plugin folder, identified against Modrinth by sha512, each flagged if a newer build exists for this server’s loader and version. Hand-installed jars are listed as unmanaged |
GET |
/api/servers/{id}/mods/search |
server.view |
Search Modrinth for mods compatible with this server’s loader and version |
POST |
/api/servers/{id}/mods/install |
server.files |
Install a Modrinth project and its required dependencies |
POST |
/api/servers/{id}/mods/update |
server.files |
Update one installed jar (?file=<name>.jar) to the newest compatible build, with dependencies, removing the old file if the name changed |
DELETE |
/api/servers/{id}/mods |
server.files |
Delete a jar by filename (?file=<name>.jar). A query param and not a path segment on purpose: mod filenames contain + and ., which a path segment mangles — removing by path 404s on the very files the panel wrote |
GET |
/api/mods/icon |
Any signed-in user | Proxy a Modrinth icon through the panel, so icons render under the strict CSP (img-src 'self') and the viewer’s IP never reaches Modrinth. The upstream URL is pinned to the Modrinth CDN |
Backups
Backup targets are global configuration and admin-only. Individual backups gate on
server.backup for the server they belong to — for /api/backups/{id}/… the panel resolves the
backup’s server first, then checks.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/backup/targets |
Admin | List configured backup destinations |
POST |
/api/backup/targets |
Admin | Add a backup destination |
PUT |
/api/backup/targets/{id} |
Admin | Edit a destination; a blank password field keeps the stored one |
DELETE |
/api/backup/targets/{id} |
Admin | Remove a backup destination |
POST |
/api/backup/targets/{id}/test |
Admin | Verify a destination’s credentials and reachability |
GET |
/api/backup/browse |
Admin | List a host path’s sub-directories (?path=), read-only — for the local-target folder picker |
GET |
/api/servers/{id}/backups |
server.backup |
List a server’s backups |
POST |
/api/servers/{id}/backup |
server.backup |
Run a backup now |
POST |
/api/backups/{id}/restore |
server.backup |
Restore a backup over the server’s data |
POST |
/api/backups/{id}/verify |
server.backup |
Check one backup’s integrity on demand |
DELETE |
/api/backups/{id} |
server.backup |
Delete a backup |
GET |
/api/settings/backup-verify |
Admin | The automatic backup-verification settings |
PUT |
/api/settings/backup-verify |
Admin | Update the automatic backup-verification settings |
GET |
/api/system/backup-coverage |
Admin | Installed servers with no recent successful backup (default window 7 days) |
GET |
/api/backup-policy |
Admin | The panel-wide nightly backup policy, derived from the managed global schedule (absent = disabled) |
PUT |
/api/backup-policy |
Admin | Create, update or remove that managed schedule — all servers, nightly, to one target |
GET |
/api/backups/{id}/download |
server.backup |
Stream a backup archive from its target to the caller, so a copy can be kept off-panel. Same permission as restore |
Schedules and templates
Read Gotchas — the gates here are not symmetric.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/schedules |
Session | List schedules, filtered to those the caller has server.schedule on; realm/global rows are admin-only |
POST |
/api/schedules |
server.schedule + the action’s own permission |
Create a schedule; realm/global scope is admin-only |
PUT |
/api/schedules/{id} |
Admin | Edit a schedule, or toggle it on and off |
DELETE |
/api/schedules/{id} |
Admin | Delete a schedule |
POST |
/api/schedules/{id}/run |
Admin | Trigger a schedule immediately |
GET |
/api/schedules/{id}/runs |
server.schedule |
The last 50 executions; admin for realm/global schedules |
GET |
/api/templates |
Session | List message templates, built-in and custom |
POST |
/api/templates |
Admin | Create or update a message template |
DELETE |
/api/templates/{id} |
Admin | Delete a message template |
Managed schedule rows — the ones behind the per-server auto-restart toggle — are hidden from
GET /api/schedules and driven through /api/servers/{id}/auto-restart instead.
Users, realms, and permissions
Listing realms is open to any session because the create-server form needs it. Every other route here is admin-only: a realm is a permission scope, so renaming or deleting one moves servers between grant boundaries.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/realms |
Session | List realms. Admins also get server_count per realm; other callers don’t, since the list exists for the create-server form |
POST |
/api/realms |
Admin | Create a realm. name is required and unique — a clash is a 409, not a merge |
PUT |
/api/realms/{id} |
Admin | Rename or re-describe a realm. name is required here too: it writes both columns, so omitting it would blank the name servers are matched against |
DELETE |
/api/realms/{id} |
Admin | Delete a realm. Its servers are detached, not deleted; grants scoped to it stop applying |
GET |
/api/users |
Admin | List users |
POST |
/api/users |
Admin | Create a user |
PUT |
/api/users/{id} |
Admin | Update a user’s password, role, or disabled flag |
DELETE |
/api/users/{id} |
Admin | Delete a user |
POST |
/api/users/{id}/unlock |
Admin | Clear a login lockout for one account. Audited. Harmless when the account is not locked — the reply says which it was |
GET |
/api/users/{id}/permissions |
Admin | A user’s grants |
PUT |
/api/users/{id}/permissions |
Admin | Replace a user’s grants |
GET |
/api/permissions/catalog |
Admin | The assignable permissions and scope types, for building an editor |
POST |
/api/realms/reorder |
Admin | Set each realm’s sort order to its position in the given id list — the manual order the Servers page and pickers follow |
PUT |
/api/realms/{id}/collapsed |
Any signed-in user | Persist a realm group’s folded state. Stored server-side deliberately, so it carries across devices |
Kvasir
Kvasir’s features are opt-in and off until an admin configures a provider. Configuration is admin-only; the advisory endpoints gate on the permission that matches what they reveal.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/ai/config |
Admin | The AI provider configuration |
PUT |
/api/ai/config |
Admin | Update the AI provider configuration |
POST |
/api/ai/config/test |
Admin | Send a trivial prompt to verify credentials and endpoint |
POST |
/api/ai/health-digest |
Admin | An advisory cross-server ops briefing |
POST |
/api/servers/{id}/admin-log/digest |
server.view |
A plain-language read of recent admin-log activity |
POST |
/api/servers/{id}/explain |
server.view |
Explain an error from log text the caller is looking at |
POST |
/api/servers/{id}/config-advice |
server.control |
An advisory review of the server’s configuration |
POST |
/api/ai/plan |
Session | Turn a natural-language request into a previewable plan; executes nothing |
POST |
/api/ai/plan/execute |
Session | Run confirmed actions, re-checking each against RBAC |
GET |
/api/servers/{id}/kvasir-events |
server.view |
A server’s recent proactive-AI reactions — what Kvasir saw, explained, and proposed or applied — over the last N hours (default 7 days, max 30). The in-panel surface, so proposals are not only visible in Discord |
DELETE |
/api/servers/{id}/kvasir-events |
server.control |
Dismiss that history once it has been read |
/api/ai/plan and /api/ai/plan/execute need no explicit permission because they scope themselves:
both build the candidate set from the servers the caller holds server.control on, and the execute
step re-derives that set server-side rather than trusting the posted plan. Both refuse with 400
unless an admin has enabled AI actions.
Watchers
Log-pattern rules scanned against running servers’ container logs (see the monitoring guide). All admin-only — watchers read logs across servers.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/watchers |
Admin | List watchers; ?server_id= narrows to one server plus the global rules |
POST |
/api/watchers |
Admin | Create a watcher (the pattern must compile) |
PUT |
/api/watchers/{id} |
Admin | Update a watcher |
DELETE |
/api/watchers/{id} |
Admin | Delete a watcher |
POST |
/api/servers/{id}/watchers/suggest |
Admin | Ask Kvasir for watcher rules from the server’s rune type and recent log; returns validated proposals, creates nothing |
IP blocking
Blocks an abusive client IP at Cloudflare’s edge (when a zone the panel’s token can see owns the site’s hostname) or in the host firewall via nftables (for a directly-exposed site). Admin-only. Private, loopback, link-local, Tailscale/CGNAT and Cloudflare’s own ranges are always refused, and the nftables rules only ever touch ports 80/443 — never SSH.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/blocks |
Admin | List active blocks (ip, backend, scope, reason, source) |
POST |
/api/blocks |
Admin | Block an IP: {ip, host?, server_id?, reason?}. host/server_id pick the Cloudflare zone; without one it falls back to the host firewall |
DELETE |
/api/blocks/{id} |
Admin | Remove the block from its backend and forget it |
GET |
/api/settings/blocking |
Admin | Blocking config plus whether Cloudflare is configured and nftables is usable |
PUT |
/api/settings/blocking |
Admin | Update {enabled, mode, nft_enabled}; mode is off|propose|auto (how Kvasir’s block_ip suggestion is handled) |
Norn
Norn is the DayZ loot-economy tool. Reading the economy needs server.view; changing it needs
server.control, because these routes rewrite mission files.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/servers/{id}/dayz/economy |
server.view |
Types files, item and lifetime stats, cleanup timers |
GET |
/api/servers/{id}/dayz/mods |
server.view |
Each Workshop mod’s on-disk and upstream status, plus orphan folders |
GET |
/api/servers/{id}/dayz/mod-loot |
server.view |
Installed mods and the types.xml files each ships |
POST |
/api/servers/{id}/dayz/min-lifetime |
server.control |
Raise every item lifetime below a floor, in hours |
POST |
/api/servers/{id}/dayz/globals |
server.control |
Update allowlisted globals.xml cleanup timers |
POST |
/api/servers/{id}/dayz/register-types |
server.control |
Register detected types files in cfgeconomycore.xml |
POST |
/api/servers/{id}/dayz/import-mod-types |
server.control |
Copy a mod’s types.xml into the mission and register it |
POST |
/api/servers/{id}/dayz/reset |
server.control |
Clear saved Norn settings so reinstalls return to vanilla |
PUT |
/api/servers/{id}/dayz/mods/order |
server.control |
Rewrite the mod load order. The body must be a permutation of the current ids — reordering is a separate action from add/remove so a stale client cannot silently wipe the list. Takes effect on the next restart |
GET |
/api/servers/{id}/dayz/mods/search |
server.view |
Search the DayZ Steam Workshop (?q=). Needs a Steam Web API key; returns needs_key=true when none is configured, since paste-by-id still works without one |
POST |
/api/servers/{id}/dayz/mods/suggest |
server.view |
Ask Kvasir to review the mod list for likely-missing dependencies (CF, Dabs Framework) and a sane load order. Advisory only — it never edits the list. Needs the AI configured |
Networking and domains
The domain list is RBAC-filtered like the server list. Every integration setting is admin-only.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/domains |
Session | Per-server subdomain and proxy state, filtered to viewable servers |
GET |
/api/domains/cloudflare |
Admin | Every hostname mapped on the configured Cloudflare tunnel, including ones set up directly in Cloudflare (not just what the panel provisioned) |
GET |
/api/domains/{id}/check |
server.view |
Probe one server’s public URL; the domain is recomputed server-side. For a Cloudflare domain it also reports cf_conflict/cf_target when the hostname’s CNAME points at a different tunnel |
GET |
/api/settings/network |
Session | Public hostname, detected address, and the effective connect address |
PUT |
/api/settings/network |
Admin | Set the public hostname, UPnP toggle, and BattleMetrics token |
GET |
/api/upnp/status |
Admin | Whether UPnP is on and a gateway is reachable |
GET |
/api/settings/unifi |
Admin | The UniFi controller configuration |
PUT |
/api/settings/unifi |
Admin | Update the UniFi controller configuration |
POST |
/api/settings/unifi/test |
Admin | Log in and list rules to confirm it works |
GET |
/api/settings/npm |
Admin | The Nginx Proxy Manager configuration |
PUT |
/api/settings/npm |
Admin | Update the Nginx Proxy Manager configuration |
POST |
/api/settings/npm/test |
Admin | Log in and list proxy hosts to confirm it works |
GET |
/api/settings/cloudflare |
Admin | The Cloudflare tunnel configuration |
PUT |
/api/settings/cloudflare |
Admin | Update the Cloudflare tunnel configuration |
POST |
/api/settings/cloudflare/test |
Admin | Verify the token, resolve the zone, and check the tunnel config |
POST |
/api/panel/remote/servers |
Admin | List the servers on another panel: {url, token}. Each entry adds exists_here so a name clash shows before a long transfer. Nothing is stored |
POST |
/api/panel/remote/import |
Admin | Copy one server from another panel: {url, token, server_id, skip_existing?}. Streams that panel’s export straight into the normal import — see the note below |
GET |
/api/settings/github |
Admin | Whether a GitHub token is stored ({configured}); the token itself is never returned |
PUT |
/api/settings/github |
Admin | Store or clear the GitHub token ({token}; empty clears). Lets the rune browser read private repos and lifts the 60-req/hour anonymous limit. A repository may override it with its own token — see /api/rune-repos — which is the only way to reach two private repos owned by different accounts |
POST |
/api/settings/github/test |
Admin | Verify the stored token and report the account it authenticates as |
GET /api/settings/network is readable by any session because the connect address is what the UI
shows players; writing it is admin-only.
Steam
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/steam/account |
Admin | The authorized Steam account, if any |
POST |
/api/steam/send-code |
Admin | Attempt a login without a Guard code, prompting Steam to email one |
POST |
/api/steam/authorize |
Admin | Complete authorization with the Guard code |
DELETE |
/api/steam/account |
Admin | Remove the stored Steam credentials |
GET |
/api/settings/steam-web-api-key |
Admin | Whether a Steam Web API key is stored — never the value |
PUT |
/api/settings/steam-web-api-key |
Admin | Store (encrypted) or clear it. An empty value clears |
Status page, beacon, and Discord
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/settings/status-page |
Admin | The public status page configuration |
PUT |
/api/settings/status-page |
Admin | Update the public status page configuration |
GET |
/api/settings/beacon |
Admin | The beacon configuration |
PUT |
/api/settings/beacon |
Admin | Update the beacon configuration |
POST |
/api/settings/beacon/test |
Admin | Send a one-off ping to confirm a collector is reachable |
GET |
/api/beacon/stats |
Admin | Collected install counts, when this instance is a collector |
GET |
/api/settings/discord-status |
Admin | The Discord status board configuration |
PUT |
/api/settings/discord-status |
Admin | Update the Discord status board configuration |
POST |
/api/settings/discord-status/post |
Admin | Force an immediate status board refresh |
POST |
/api/settings/beacon/notice |
Admin | Record that the first-run beacon notice was shown and acted on. keep=false switches the beacon off in the same step, so declining is one click rather than a hunt through Settings |
GET |
/api/settings/discord-bot |
Admin | The control-bot config: whether a token is set, and the control-channel id. Never returns the token |
PUT |
/api/settings/discord-bot |
Admin | Update the token (encrypted; only overwritten when a non-null value is sent, so the channel can be saved without re-entering it) and the channel, then reconnect the bot |
The public status board caches for 15 seconds and is served with Cache-Control: public, max-age=15.
Notifications
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/notifications |
Admin | List notification channels |
POST |
/api/notifications |
Admin | Add a notification channel |
DELETE |
/api/notifications/{id} |
Admin | Remove a notification channel |
POST |
/api/notifications/{id}/test |
Admin | Send a test notification |
Audit
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/audit |
Admin | The audit log, filterable |
GET |
/api/audit/export |
Admin | The same filtered log as a CSV download |
System
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/system/info |
Admin | Docker health, object counts, and host CPU/memory/disk figures |
GET |
/api/system/metrics |
Admin | Whole-host samples over the last N hours (?hours=, default 24, max 168) — the machine’s equivalent of a server’s /metrics |
GET |
/api/system/stats |
Admin | Backs the Statistics page: host CPU/memory, a per-server ranking (CPU, memory, and data-directory size), and the disk split into server data, Docker images/volumes/build cache and everything else. A server’s disk_mb is -1 when it has never been measured, which is not the same as 0 — directories are walked hourly. docker is null when the daemon could not be asked, with the reason in docker_error |
POST |
/api/system/prune-images |
Admin | Remove dangling Docker images — untagged, unreferenced, impossible to start a server from — and report {deleted, freed_bytes}. The daemon refuses to remove any image a container still uses, running or stopped. The -a variant, which also takes the images of stopped servers, is deliberately unreachable from the API. Audited as system.prune_images |
GET |
/api/system/os-updates |
Admin | Pending host OS updates: {supported, total, security?, reboot_required, reboot_pkgs?, cache_age_hours?, source}. Read-only — the panel reports, it never applies. security is absent when the host can’t tell, which is not the same as zero. Cached 15 minutes |
POST |
/api/system/update |
Admin | Update the panel to the latest official release |
POST |
/api/system/check-update |
Admin | Force a fresh release check, bypassing the 6-hour cache |
GET |
/api/system/update-status |
Admin | The last update result written by the helper, including failures |
GET |
/api/system/auto-update |
Admin | The opt-in scheduled updater’s settings |
POST |
/api/system/auto-update |
Admin | Update the scheduled updater’s settings |
PUT |
/api/settings/panel-name |
Admin | Set this panel’s display name (shown in the sidebar and browser tab so several panels are distinguishable). Read back on the public GET /api/version as panel_name |
GET |
/api/advisories |
Admin | Published security advisories that apply to this build and have not been dismissed. Pulled from a static file in the project repo — the request carries nothing about the install, and it works with the beacon switched off, which is precisely the install that still needs to hear |
POST |
/api/advisories/{id}/ack |
Admin | Dismiss one advisory for the whole install — the point is that somebody dealt with it, not that everyone read it. Audited as advisory.ack |
GET |
/api/diagnostics |
Admin | An allowlisted bug-report bundle: install-wide counts and versions, never server names, paths, IPs, hostnames, variable values or the beacon’s instance id. The Docker version lookup is time-bounded, since a wedged daemon is exactly the host most likely to be filing the report |
GET |
/api/host/mounts |
Admin | The host’s real mounted drives. Returns an empty list rather than an error on a non-Linux or unreadable host |
GET |
/api/host/browse |
Admin | List a host directory (?path=), read-only and jailed to the paths a bind mount may use. Symlinks are resolved first, so a link cannot smuggle a denied location past the check |
GET |
/api/settings/analytics |
Admin | The operator’s own <head> snippet, and the origins it caused to be allowed. Empty on an install that has never set one — the panel ships no analytics of its own |
PUT |
/api/settings/analytics |
Admin | Store or clear it. The panel derives the origins from the tag’s src/href attributes and widens script-src, connect-src and img-src by exactly those — a strict CSP would otherwise block a third-party tag before it loaded. Inline <script> code is refused: allowing it means 'unsafe-inline' on every page forever. Max 4 KB. Audited as settings.analytics, recording the origins rather than the markup |
GET |
/api/settings/confirm-actions |
Admin | Whether the UI asks before a stop or a restart. Unset reads as on, so existing installs get the prompt without visiting Settings |
PUT |
/api/settings/confirm-actions |
Admin | Turn that confirmation off or on. A UI preference only — what a user is allowed to stop is decided by RBAC, not by this |
GET |
/api/settings/email |
Admin | The SMTP config. Reports only whether a password is stored, never the value |
PUT |
/api/settings/email |
Admin | Update the SMTP config. The password follows the pointer idiom used elsewhere: null keeps the stored one, "" clears it. Encrypted at rest |
POST |
/api/settings/email/test |
Admin | Send a one-off message to prove the settings work. Defaults to the From address when no recipient is given |
Anything not matching /api/ falls through to the embedded SPA, which serves index.html for
unknown paths so client-side routes deep-link correctly. Unknown /api/ paths return 404.
WebSocket endpoints
Four endpoints upgrade to WebSocket. All four authenticate exactly like the rest of the API — the
ygg_token cookie is enough from a browser, and ?token= carries a JWT or an ygg_ API token from
anything that cannot set headers. The upgrader accepts a handshake with no Origin (automation) or
one whose hostname matches the request host, and refuses everything else.
| Path | Auth | Streams |
|---|---|---|
/api/servers/{id}/install/log |
server.view |
Install and build output — buffered history, then live |
/api/servers/{id}/logs |
server.view |
Container logs, live |
/api/servers/{id}/console |
server.console |
Container output, and input sent over RCON or the container’s stdin |
/api/ai/chat/ws |
Any signed-in user | The Dashboard’s Kvasir chat. Answers turns until the client goes away; proposals it makes are confirmed over the separate plan/execute endpoints, never applied from the stream |
/console is the one that writes, which is why it gates on server.console while /logs needs only
server.view. Each message you send it is one command: it goes over RCON when the server’s rune
declares an enabled rcon block, and to the container’s stdin otherwise. Start an install with POST /api/servers/{id}/install and follow /install/log for
progress; the install itself runs in the background and does not block the POST.