The API

Start, stop and read your servers from a script, a Discord bot or Home Assistant. One key from your account page, one URL, plain JSON.

10 min read · Reference · Last updated September 2026 ← All guides

What's here

  • Making a key, the three levels, limiting a key to some servers
  • Every route, what it sends and what comes back, plus the event feed a bot reads
  • The errors and what each one means you should do
  • Examples in curl, Python and Node, a Home Assistant command and a cron line

It's the web dashboard, as an API. A call goes to us, we pass it to the machine that runs the server over the same connection the web dashboard uses and the app answers. So the machine has to be on and the app open (or the Linux agent running), the same as for the dashboard. Nothing has to be port forwarded and nothing listens on your PC.

Keys

The API comes with Standard (bought outright, from the Microsoft Store, or included with Plus or a cloud server) and with a Linux subscription. Make a key on your account page under Billing & Keys. Give it a name (what will use it, so the audit log reads well), a level and, if you like, an expiry and a list of servers. The key is shown once. Copy it then, it can't be shown again, only revoked.

Every request carries it as a bearer token:

Authorization: Bearer mcsm_...

The three levels are the roles the dashboard already has, so a key can never do something a person with that role couldn't:

LevelWhat it reaches
Read and controlEvery read, start, stop, restart, kill, console commands, kicking a player
ManageThe above plus the settings, properties, backups, players, mods, plugins, the schedule and tasks
Full accessEverything you can do yourself, including deleting servers, cloning, importing, the relay and staff accounts

A key with a server list reaches those servers and nothing else. A key without one reaches every server on the account, including ones you make later. Either way, every call is written to the server's audit log under the key's name, so "who restarted it at 4am" has an answer.

If you're polling, every ten seconds is plenty. For anything that happens on the server (a start, a crash, a player joining) read the event feed with a long poll, or use the webhook on the Notifications tab, which posts to any URL the moment it happens.

Tiers

The API has tiers. Which one you're on decides how hard you can hit it. Lite has no API at all. Standard is what a Standard licence, Plus, a cloud server or a Linux subscription gives you. It's the one nearly everyone is on. Enterprise is for a hosting company running many customers through the API: it isn't on sale yet, so until it is an account goes on it by agreement (email us, the terms say what that covers).

TierWho's on itRequests a minute, per keyKeys an account
LiteThe free app on its ownNo APINone
StandardStandard, Plus, a cloud server, a Linux subscription12010
EnterpriseHosting companies, by agreement until the plan is on sale1,200100

Every answer carries X-Api-Tier and X-RateLimit-Limit headers. /v1/me says the tier in words, so a client can read its ceiling without guessing. Over the limit is a 429 with the limit in the message. The event feed's long poll counts as one request however long it waits, so a bot that listens rather than polls uses a few calls a minute.

Talking to it

The base is https://api.mcservermanager.com/v1. Requests and replies are JSON. A reply is the same data the web dashboard shows for that tab, so what you see on the dashboard is what you get here.

Errors are a status and a body with a sentence and a code:

{ "error": "The machine that runs this server is offline. Open MC Server Manager there, or check the agent.", "code": "machine_offline" }
StatusCodeMeans
401invalid_keyNo key, or one we don't recognise. Check the header
401key_revoked key_expiredThe key was revoked or has expired. Make a new one
403standard_requiredThe account no longer has Standard or a Linux subscription. The keys come back the moment it does
403no_accessThe key's server list doesn't include that server
403forbiddenThe key's level doesn't allow that operation
403standard_onlyThat's a Standard feature and the machine is on Lite
404unknown_serverNo server with that id on the account (ids are on GET /v1/servers)
409refusedThe machine refused the action, the body says why (already running, port in use, stop it first)
426update_appThe app on that machine is older than the API. Update to 4.0 or newer, or the latest Linux agent
429rate_limitedOver your tier's limit on this key, 120 a minute on Standard. The message says the number. Slow down
503machine_offlineThe machine isn't connected. Open the app there or check the agent
504no_replyThe machine didn't answer within 30 seconds. It may still be doing what you asked

Who am I

GET/v1/me answers the account's email, the key's own row (name, prefix, level, server list, when it was made, when it expires) and the tier the key is on, with its requests a minute and keys an account. Handy for checking a key works.

Servers

GET/v1/servers lists every server the key can reach, on every machine on the account:

{ "servers": [ {
  "id": "a1b2c3d4-...", "name": "Survival", "type": "Paper", "version": "1.21.1", "port": 25565,
  "running": true, "starting": false, "asleep": false, "players": 3, "online": true,
  "machine": { "kind": "pc", "name": "Gaming PC" }
} ] }

online is whether the machine is connected right now. machine.kind is pc for a Windows PC running the app, linux for a machine running the agent, cloud for a server we host. asleep is the sleep-when-empty state, a start or a player joining wakes it.

GET/v1/servers/{id} is one row from that list.

Start, stop and the console

RouteSendsAnswersLevel
POST/v1/servers/{id}/startnothing{ "ok": true, "action": "start" }, or 409 with why notRead and control
POST/v1/servers/{id}/stopnothingthe sameRead and control
POST/v1/servers/{id}/restartnothingthe sameRead and control
POST/v1/servers/{id}/killnothingthe same. Kill ends the process without saving, stop is the normal wayRead and control
POST/v1/servers/{id}/command{ "command": "say Restart in 5 minutes" }{ "ok": true }. The command's output lands on the consoleRead and control
GET/v1/servers/{id}/console?lines=100up to 300 lines{ "lines": [ "[12:00:01] [Server thread/INFO]: Done (3.2s)!", ... ] }, the newest lastRead and control

A start answers when the process is launched, not when the server is ready. Watch the console for the Done line, or GET /v1/servers/{id} for running.

Players

GET/v1/servers/{id}/players answers who's on and the lists:

{ "onlinePlayers": [ "Steve" ], "whitelistEnabled": true,
  "whitelist": [ { "name": "Steve", "uuid": "..." } ], "ops": [ { "name": "Alex", "uuid": "...", "level": 4 } ],
  "bannedPlayers": [ { "name": "...", "reason": "...", "created": "..." } ], "bannedIps": [ { "ip": "...", "reason": "...", "created": "..." } ] }
RouteSendsLevel
POST/v1/servers/{id}/players/{name}/kick{ "reason": "..." }, optional. The server has to be runningRead and control
POST/v1/servers/{id}/players/{name}/ban{ "reason": "..." }, optionalManage
POST/v1/servers/{id}/players/{name}/unbannothingManage
POST/v1/servers/{id}/players/{name}/op and /deopnothingManage
POST/v1/servers/{id}/players/{name}/whitelist and /unwhitelistnothingManage

Each answers the updated players payload, so you don't need a second call to see the change. Bedrock servers keep their allowlist and permissions the same way, the routes are the same.

GET/v1/servers/{id}/players/history is who has ever played here, most recently seen first, from the join and leave lines the app reads off the console:

{ "now": 1789574400, "count": 2, "players": [
  { "name": "Steve", "xuid": null, "firstSeen": 1789400000, "lastSeen": 1789574000, "sessions": 12, "playSeconds": 43200, "online": true },
  { "name": "Alex", "xuid": null, "firstSeen": 1789300000, "lastSeen": 1789500000, "sessions": 3, "playSeconds": 5400, "online": false } ] }

Times are unix seconds. A player who's on now has their current session counted in playSeconds up to now. Bedrock players carry their xuid. The list holds the last 500 people seen.

Resources

GET/v1/servers/{id}/resources is the live reading, what the Resources tab shows:

{ "cpu": 12.4, "ramUsageMB": 2140, "ramAllocatedMB": 4096, "ramPercent": 52, "heapUsedMB": 1830, "heapMaxMB": 4096,
  "machineRamMB": 32768, "isStarting": false, "uptimeSeconds": 8123, "uptime": "2h 15m", "diskUsage": "1.2 GB",
  "tps": 20, "mspt": 4.1, "entityCount": 312, "loadedChunks": 890, "hasTpsData": true }

tps, mspt, entityCount and loadedChunks need the stats mod the app installs on Paper, Forge and Fabric, hasTpsData says whether they're real. heapUsedMB is null until the JVM has answered once.

GET/v1/servers/{id}/resources/history?hours=24 is the last day, one reading every 30 seconds while the server ran, averaged down to about 120 points unless you add &full=1:

{ "hours": 24, "full": false, "now": 1789574400, "stepSeconds": 30, "keepHours": 24, "count": 2880, "hasTps": true,
  "samples": [ [ 1789488000, 11.2, 2100, 20, 2 ], ... ] }

Each sample is [unix time, cpu percent, ram MB, tps or null, players on]. A gap in the times is a gap in running, the server was off.

Backups

RouteDoesLevel
GET/v1/servers/{id}/backupsthe list: backups and preRestore, each { fileName, sizeMB, createdDate, isValid, validationError }, plus autoBackupOnStop, localKeep, cloudKeepRead and control
POST/v1/servers/{id}/backupsmakes one. It runs in the background and answers straight away, poll the list for it. Works on a running server, the world is held still while it zipsManage
POST/v1/servers/{id}/backups/{fileName}/restorerestores that one. The server has to be stopped. A safety copy of what was there goes into preRestore firstManage
DELETE/v1/servers/{id}/backups/{fileName}deletes that oneManage
POST/v1/servers/{id}/backups/foldersets where the backups are written on the machine: { "folder": "D:\\Backups\\Survival" }, the full path of an empty folder outside the server, on another drive so a dead disk doesn't take the backups with it. The backups already there move with it. An empty string puts them back in the server's own folder. The list answers with backupsFolder (as set) and backupsDir (in use)Full access

Properties

GET/v1/servers/{id}/properties answers server.properties as { "properties": { "motd": "...", "max-players": "20", ... } }, every value a string as it is in the file.

PATCH/v1/servers/{id}/properties with { "properties": { "motd": "Back at 6", "max-players": 30 } } changes just those and leaves the rest alone, then answers the whole file. Most properties are read at the next start.

Schedule and tasks

GET/v1/servers/{id}/schedule is the Schedule tab: the restart mode, the times, restart when empty, the crash restart setting, sleep when empty.

GET/v1/servers/{id}/tasks is the task list, each with its trigger, its action, what it says in words (whenText, whatText) and when it last ran. POST/v1/servers/{id}/tasks/{taskId}/run runs one now (Manage).

Worlds, mods and plugins

GET/v1/servers/{id}/worlds, GET/v1/servers/{id}/mods and GET/v1/servers/{id}/plugins answer the lists those tabs show: worlds with their size and which one is active, mods and plugins with their file name and whether they're enabled.

Updates

Newer builds of what's installed, from Modrinth and CurseForge, never outside the server's own Minecraft version and loader. A candidate has to list the server's exact version and, for a mod, its loader, or it isn't offered. The app knows where it installed each jar from. A jar you dropped in yourself is matched by its hash where the sites know it.

RouteDoesLevel
GET/v1/servers/{id}/updateschecks every installed mod or plugin, the answer is below. A site that can't be reached puts its rows in failed, never in upToDateManage
POST/v1/servers/{id}/updatesapplies updates. Body { "fileName": "sodium-0.5.jar" } for one, an empty body for all of them. The server has to be stopped, a jar in use can't be replaced. The check runs again first, so what's applied is what the sites offer right now. Answers { done: [ { fileName, newFileName, name, version } ], failed: [ { fileName, error } ], summary }. Updating everything on a big server can take longer than the 30 second window, then you get a 504 while it carries on and the next check shows what landedManage
GET/v1/servers/{id}/modpackthe CurseForge pack this server was made from and the newest build of it for the same Minecraft version, or tracked: false for a server not made from a pack through the appManage
POST/v1/servers/{id}/modpack/updatemoves the server to that build: { "fileId": 6543210 } from the check. A backup is made first, the pack's mods and configs are replaced, the world, server.properties, ops, whitelist and bans are kept, mods you added yourself go with the old build and are in the backup. A build for another Minecraft version is refused. It runs in the background and answers straight away, the console shows each stepManage
{ "kind": "mod", "checkedAt": "2026-09-16T20:00:00Z", "checked": 3, "summary": "1 has an update",
  "updates": [ { "fileName": "fabric-api-0.100.0+1.21.1.jar", "name": "Fabric API", "provider": "modrinth", "projectId": "P7dR8mSH",
                 "installedVersion": "…", "latestVersion": "0.110.0+1.21.1", "latestId": "…", "latestFileName": "fabric-api-0.110.0+1.21.1.jar", "datePublished": "…" } ],
  "upToDate": [ "sodium-0.6.0.jar", "lithium-0.13.0.jar" ], "unknown": [ "homemade.jar" ], "failed": [] }

kind is mod or plugin from the server's type. unknown is jars from neither site, they can't be checked. The pack check answers { tracked, current: { provider, projectId, fileId, name, version }, latest: { fileId, name, version, datePublished, releaseType, serverPack } | null, error }.

Server List Beta

Your servers on the Server List, the public list of servers run with the app. What you write is yours, whether it's running and how many are on is read off the server. A listing made here is the same one the app, the web dashboard and the Discord bot edit.

RouteDoesLevel
GET/v1/nexusyour listings and the servers you could list. A key limited to some servers only sees thoseRead and control
POST/v1/nexus/{id}lists the server, or updates its listing. The body is belowFull access
DELETE/v1/nexus/{id}takes it off the listFull access
{ "title": "Oak Valley SMP", "description": "A friendly survival server, no resets, land claims on.",
  "tags": [ "Survival", "SMP" ], "address": "play.oakvalley.net",
  "discord": "discord.gg/abc123", "website": "oakvalley.net",
  "votePrompt": true, "votePromptHours": 4, "votePromptDaily": 1, "sharePlayers": false }

A title of 3 to 60 characters, a description of 20 to 500, one to five tags from Survival, SMP, Creative, Vanilla, Modded, Hardcore, Skyblock, Minigames, PvP, Roleplay, Economy, Crossplay, Whitelist, Family friendly or 18+. Then the address players type to join (a name or an IP, with :port if it needs one). discord has to be an invite and website a web address, both optional. Five listings an account.

votePrompt has the app tell each player in game, once they've been on ten minutes, that they can vote for the server, no more often than every votePromptHours (2 to 24) and at most votePromptDaily times a day (1 to 5). sharePlayers adds who plays here to the shared player record. Both are off unless you turn them on. Leaving either out of an update keeps what's there. The answer is { success, listing, url }. An account that hasn't verified its email is refused with unverified.

Everything else

The web dashboard does a lot more than the routes above: settings, mods on and off, worlds, the file manager, cloud backups, the relay, cloning. All of it is reachable as POST/v1/servers/{id}/op with the dashboard's own operation as the body, { "type": "scheduleSleep", "enabled": true, "minutes": 15 }. The key's level applies exactly as it does on the dashboard.

The catch: those names and fields are the dashboard's own, so they can change between versions without notice. The routes above are the promise, this is the escape hatch. If you find yourself relying on one, tell us and we'll give it a route.

Events

What happened, for a bot that wants to say so: a start, a stop, a crash, a player joining or leaving, a scheduled task. The machine pushes them to us while something is reading, we keep the last 200 an account and you read them by sequence number. Nothing is pushed while nobody's asking, so an account nobody polls costs nothing.

RouteAnswersLevel
GET/v1/servers/{id}/events?since=&wait=the events on that serverRead and control
GET/v1/events?since=&wait=the events on every server the key reaches, on every machineRead and control

The first call goes without since and answers next only, nothing stored, so a bot that starts up doesn't replay last night. Then poll with since set to the next you were given and you get everything after it, oldest first, up to 100 at a time, with a new next. Keep next and you never miss one or see one twice, even across your own restarts. since=0 is everything we have.

wait is a long poll: with nothing new, the call holds for up to that many seconds (25 at most) and answers the moment an event lands, or empty when the time's up. A bot on wait=25 in a loop hears about a join within a second and makes three calls a minute doing it.

{ "events": [ {
  "seq": 418, "at": 1789574400, "serverId": "a1b2c3d4-...", "event": "playerJoined",
  "server": { "name": "Survival", "type": "Paper", "version": "1.21.1", "address": "play.example.com:25565", "port": 25565, "players": 3, "uptimeSeconds": 5400 },
  "data": { "title": "Player Joined", "text": "Steve joined Survival", "player": "Steve", "players": ["Steve"] },
  "kind": "pc:..."
} ], "next": 418 }

The names and the server block are the webhook's, so a bot reads one shape whichever way an event reached it. Every event's data carries title and text, the same words the webhook and the Discord embed use, so a bot can post text as it is.

EventAlso in data
started restarting stoppednothing more
crashedexitCode
playerJoined playerLeftplayers (the names, a list, since a few joining at once come as one event) and player (the first of them)
tasknothing more, the title and text are a scheduled task's Send a notification, as you wrote it

at is unix seconds, when it happened on the machine. kind is which machine sent it. address is what players connect to, null while the machine can't say. server.players is how many are on, uptimeSeconds is null when the server isn't running.

The app on the machine has to be 4.0 or newer (or the Linux agent its latest release) for events to flow. An older one still answers the routes, it just never pushes anything into them. The machine pushes only while something is polling, so keep polling: a bot that stops for an hour and comes back starts fresh with next, what happened in between wasn't pushed. If you'd rather be told than ask, the webhook on a server's Notifications tab posts the same events to any URL the moment they happen, signed with a secret if you set one.

Examples

Start a server from a shell:

curl -X POST https://api.mcservermanager.com/v1/servers/SERVER_ID/start \
  -H "Authorization: Bearer mcsm_..."

Restart every night at 4am, one line in crontab:

0 4 * * * curl -s -X POST https://api.mcservermanager.com/v1/servers/SERVER_ID/restart -H "Authorization: Bearer mcsm_..."

Who's on, in Python:

import requests

API = "https://api.mcservermanager.com/v1"
H = {"Authorization": "Bearer mcsm_..."}

for s in requests.get(f"{API}/servers", headers=H).json()["servers"]:
    if not s["running"]:
        continue
    players = requests.get(f"{API}/servers/{s['id']}/players", headers=H).json()
    print(s["name"], players["onlinePlayers"])

A Discord bot command in Node (discord.js), stopping the server if nobody's on:

const API = 'https://api.mcservermanager.com/v1';
const headers = { Authorization: 'Bearer mcsm_...', 'Content-Type': 'application/json' };

async function stopIfEmpty(serverId) {
  const players = await fetch(`${API}/servers/${serverId}/players`, { headers }).then(r => r.json());
  if (players.onlinePlayers.length > 0) return `${players.onlinePlayers.length} still on, leaving it`;
  const res = await fetch(`${API}/servers/${serverId}/stop`, { method: 'POST', headers }).then(r => r.json());
  return res.ok ? 'Stopping' : res.error;
}

The same bot's feed, one long poll after another, posting each event to a channel:

let next = (await fetch(`${API}/events`, { headers }).then(r => r.json())).next;
for (;;) {
  const page = await fetch(`${API}/events?since=${next}&wait=25`, { headers }).then(r => r.json());
  for (const ev of page.events) {
    if (ev.event === 'playerJoined') channel.send(`${ev.data.players.join(', ')} joined ${ev.server.name} (${ev.server.players} on)`);
    else if (ev.event === 'crashed') channel.send(`${ev.server.name} crashed, exit code ${ev.data.exitCode}`);
    else channel.send(ev.data.text);
  }
  next = page.next;
}

Home Assistant, a button that starts the server (configuration.yaml):

rest_command:
  start_minecraft:
    url: https://api.mcservermanager.com/v1/servers/SERVER_ID/start
    method: POST
    headers:
      Authorization: "Bearer mcsm_..."

And a sensor for the player count, read every minute:

sensor:
  - platform: rest
    name: Minecraft players
    resource: https://api.mcservermanager.com/v1/servers/SERVER_ID
    headers:
      Authorization: "Bearer mcsm_..."
    value_template: "{{ value_json.players }}"
    json_attributes: [running, online, name]
    scan_interval: 60

Limits worth knowing

  • The machine has to be connected. A PC that's asleep or an app that's closed answers 503, the same as the dashboard would show it offline.
  • 30 seconds for an answer. Almost everything answers in well under a second, a backup of a big world or a restore can take longer and answers as soon as it's started.
  • 120 requests a minute a key and ten keys an account on Standard, more on Enterprise. Poll gently, the event feed's long poll waits for you so a bot needs three calls a minute, not sixty.
  • The settings reply never carries the machine's IP addresses to a key. If a script needs the address players use, it's on the Network tab of the dashboard. The relay address is in the settings reply when a relay is on.

Something missing? If a route you need isn't here, or a reply doesn't carry a field you'd expect, tell us. The dashboard already does most things, giving one a route is usually quick.