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.
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.
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:
| Level | What it reaches |
|---|---|
| Read and control | Every read, start, stop, restart, kill, console commands, kicking a player |
| Manage | The above plus the settings, properties, backups, players, mods, plugins, the schedule and tasks |
| Full access | Everything 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.
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).
| Tier | Who's on it | Requests a minute, per key | Keys an account |
|---|---|---|---|
| Lite | The free app on its own | No API | None |
| Standard | Standard, Plus, a cloud server, a Linux subscription | 120 | 10 |
| Enterprise | Hosting companies, by agreement until the plan is on sale | 1,200 | 100 |
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.
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" }
| Status | Code | Means |
|---|---|---|
| 401 | invalid_key | No key, or one we don't recognise. Check the header |
| 401 | key_revoked key_expired | The key was revoked or has expired. Make a new one |
| 403 | standard_required | The account no longer has Standard or a Linux subscription. The keys come back the moment it does |
| 403 | no_access | The key's server list doesn't include that server |
| 403 | forbidden | The key's level doesn't allow that operation |
| 403 | standard_only | That's a Standard feature and the machine is on Lite |
| 404 | unknown_server | No server with that id on the account (ids are on GET /v1/servers) |
| 409 | refused | The machine refused the action, the body says why (already running, port in use, stop it first) |
| 426 | update_app | The app on that machine is older than the API. Update to 4.0 or newer, or the latest Linux agent |
| 429 | rate_limited | Over your tier's limit on this key, 120 a minute on Standard. The message says the number. Slow down |
| 503 | machine_offline | The machine isn't connected. Open the app there or check the agent |
| 504 | no_reply | The machine didn't answer within 30 seconds. It may still be doing what you asked |
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.
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.
| Route | Sends | Answers | Level |
|---|---|---|---|
| POST/v1/servers/{id}/start | nothing | { "ok": true, "action": "start" }, or 409 with why not | Read and control |
| POST/v1/servers/{id}/stop | nothing | the same | Read and control |
| POST/v1/servers/{id}/restart | nothing | the same | Read and control |
| POST/v1/servers/{id}/kill | nothing | the same. Kill ends the process without saving, stop is the normal way | Read and control |
| POST/v1/servers/{id}/command | { "command": "say Restart in 5 minutes" } | { "ok": true }. The command's output lands on the console | Read and control |
| GET/v1/servers/{id}/console?lines=100 | up to 300 lines | { "lines": [ "[12:00:01] [Server thread/INFO]: Done (3.2s)!", ... ] }, the newest last | Read 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.
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": "..." } ] }
| Route | Sends | Level |
|---|---|---|
| POST/v1/servers/{id}/players/{name}/kick | { "reason": "..." }, optional. The server has to be running | Read and control |
| POST/v1/servers/{id}/players/{name}/ban | { "reason": "..." }, optional | Manage |
| POST/v1/servers/{id}/players/{name}/unban | nothing | Manage |
| POST/v1/servers/{id}/players/{name}/op and /deop | nothing | Manage |
| POST/v1/servers/{id}/players/{name}/whitelist and /unwhitelist | nothing | Manage |
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.
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.
| Route | Does | Level |
|---|---|---|
| GET/v1/servers/{id}/backups | the list: backups and preRestore, each { fileName, sizeMB, createdDate, isValid, validationError }, plus autoBackupOnStop, localKeep, cloudKeep | Read and control |
| POST/v1/servers/{id}/backups | makes 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 zips | Manage |
| POST/v1/servers/{id}/backups/{fileName}/restore | restores that one. The server has to be stopped. A safety copy of what was there goes into preRestore first | Manage |
| DELETE/v1/servers/{id}/backups/{fileName} | deletes that one | Manage |
| POST/v1/servers/{id}/backups/folder | sets 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 |
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.
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).
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.
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.
| Route | Does | Level |
|---|---|---|
| GET/v1/servers/{id}/updates | checks every installed mod or plugin, the answer is below. A site that can't be reached puts its rows in failed, never in upToDate | Manage |
| POST/v1/servers/{id}/updates | applies 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 landed | Manage |
| GET/v1/servers/{id}/modpack | the 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 app | Manage |
| POST/v1/servers/{id}/modpack/update | moves 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 step | Manage |
{ "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 }.
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.
| Route | Does | Level |
|---|---|---|
| GET/v1/nexus | your listings and the servers you could list. A key limited to some servers only sees those | Read and control |
| POST/v1/nexus/{id} | lists the server, or updates its listing. The body is below | Full access |
| DELETE/v1/nexus/{id} | takes it off the list | Full 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.
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.
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.
| Route | Answers | Level |
|---|---|---|
| GET/v1/servers/{id}/events?since=&wait= | the events on that server | Read and control |
| GET/v1/events?since=&wait= | the events on every server the key reaches, on every machine | Read 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.
| Event | Also in data |
|---|---|
started restarting stopped | nothing more |
crashed | exitCode |
playerJoined playerLeft | players (the names, a list, since a few joining at once come as one event) and player (the first of them) |
task | nothing 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.
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
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.