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.
Updated September 2026
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, Relay Starter or a cloud server) and with a Linux subscription. Make a key on your account page under Billing and 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, Relay Starter, 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, Relay Starter, 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,
"group": "Network", "machine": { "kind": "pc", "name": "Gaming PC", "id": "pc:4f1c..." }, "chat": true
} ] }
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. group is the server list group it's in, null for none. machine.id names the machine for the network routes. chat is whether the app on that machine has the chat bridge.
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 and the web dashboard 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.example.net",
"discord": "discord.gg/your-invite", "website": "example.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.
A group of servers, or every server the key reaches, at once and across every machine on the account. A group is the server list group, by name in any case, on every machine that has one called that. A backend linked to a proxy counts as in the proxy's group. Each route takes group and machine (a machine.id from the server list) to narrow it, in the body for a POST and the query for a GET. Leave both out for every server.
| Route | Does | Level |
|---|---|---|
| GET/v1/network/groups | the groups, each { name, servers, running, machines: [ { kind, name } ] }, plus the run going on now if this key may read it | Read and control |
| POST/v1/network/broadcast | shows { "message": "Restart in 5 minutes", "group": "Network" } in the chat of every running server. Java and Bedrock servers get say, a BungeeCord proxy gets alert. A proxy is left out when game servers in the same send carry the message. Velocity has no command for it. Answers a result per server, below | Read and control |
| GET/v1/network/players?q=notch | finds a player by name, any part of it, at least 2 letters, in every server's player history on every machine. Below | Read and control |
| POST/v1/network/start | starts the stopped servers in network order: game servers first, the proxies last. Answers 202 with the run | Read and control |
| POST/v1/network/stop | stops the running servers, the proxies first. 202 with the run | Read and control |
| POST/v1/network/restart | stops the running servers in stop order, then starts the same ones in start order. 202 with the run | Read and control |
| GET/v1/network/runs/{runId} | the run as it stands | Read and control |
| POST/v1/network/runs/{runId}/cancel | stops the run where it is. What was already sent isn't undone, the server it's waiting on is still reported as it lands and `cancelling` is true until then | Read and control |
{ "message": "Restart in 5 minutes", "counts": { "sent": 2, "skipped": 1, "offline": 1 }, "results": [
{ "id": "...", "name": "Hub", "machine": "Gaming PC", "status": "skipped", "reason": "backends" },
{ "id": "...", "name": "Lobby", "machine": "Gaming PC", "status": "sent", "reason": null },
{ "id": "...", "name": "Minigames", "machine": "Linux box", "status": "offline", "reason": "machine" } ] }
A status is sent, offline (the server isn't running, or reason: "machine" when its machine is off), skipped (backends, noCommand for Velocity, update for an app or agent from before network actions), denied, failed or missing.
{ "query": "notch", "players": [
{ "name": "Notch", "serverId": "...", "serverName": "Lobby", "serverType": "Paper", "machine": "Gaming PC", "machineKind": "pc:4f1c...",
"online": true, "lastSeen": 1789574000, "firstSeen": 1789400000, "sessions": 12, "playSeconds": 43200, "rank": 0 } ],
"machines": [ { "kind": "pc:4f1c...", "name": "Gaming PC", "status": "ok", "searched": 3 }, { "kind": "device:...", "name": "Linux box", "status": "offline", "searched": 0 } ] }
The whole name comes first (rank 0), then names starting with it (1), then names holding it (2). Within each, whoever's on now comes first, then the most recently seen. A machine that couldn't be searched is in machines with why, so a missing player is never taken as not found on a machine that wasn't asked. 200 results at most.
A run goes on in your account's hub, not in your script, so it finishes if the script stops. Each server is waited for before the next one goes: up and ready for a start (5 minutes at most), stopped for a stop (2 minutes at most), then the run moves on either way. One run at a time an account, a second answers 409 run_in_progress with the one going. A key reads and cancels the runs it started. A key at Full access with every server reads them all.
{ "run": { "id": "7d1e...", "action": "restart", "scope": { "group": "Network", "machine": null }, "state": "running",
"createdAt": 1789574400000, "finishedAt": null, "cancelling": false, "counts": { "done": 1, "working": 1, "waiting": 2 },
"steps": [ { "serverId": "...", "name": "Hub", "machine": "Gaming PC", "machineKind": "pc:4f1c...", "kind": "stop", "status": "done", "error": null, "message": null },
{ "serverId": "...", "name": "Lobby", "machine": "Gaming PC", "kind": "stop", "status": "working", ... },
{ "serverId": "...", "name": "Lobby", "kind": "start", "status": "waiting", ... },
{ "serverId": "...", "name": "Hub", "kind": "start", "status": "waiting", ... } ] } }
state is running, done or cancelled. A step's status is waiting, working, done (error: "already" when it was there before its turn), failed (error is refused with the machine's message, machineOffline, noReply, update or gone), timedOut, skipped (update, or notStopped for a restart's start whose stop didn't finish) or cancelled. Times are unix milliseconds. A server whose app or agent is from before network actions is skipped with update, update it and it joins in.
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 |
| GET/v1/stream?events= | the same events pushed down a WebSocket as they land, see below | 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.
For a bot or a script that stays connected, GET /v1/stream?events= does the same over a WebSocket and is the better choice, events come down it the moment they land with no polling at all. Open it with your key as Authorization: Bearer mcsm_... and events set to the next you were last given, or now. You get everything after it first, then each event as it happens, as { "t": "events", "events": [ ... ], "next": 419 }, the same shape a poll answers. Keep next as before and a reconnect picks up where it left off. Send {"t":"ping"} every 30 seconds to keep it open and {"t":"renew"} every few minutes, the renew is what keeps the machines pushing. We close a stream after an hour (4001) or when its key is revoked (4003) and say so first in a last { "t": "closed", "code": 4001, "reason": "reconnect" }, so dial again and carry on. Without the upgrade the route answers 426.
{ "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.
The game's chat, for a bridge to Discord or anywhere else, with no plugin or mod on the server. The app on the machine reads chat, joins, leaves, deaths and advancements off the console and pushes them to us while something is reading, the same way the events work. It's a feed of its own beside the events, so a busy chat never pushes a crash out of yours.
| Route | Sends | Answers | Level |
|---|---|---|---|
| GET/v1/chat?since=&wait= | nothing | chat on every server the key reaches | Read and control |
| GET/v1/servers/{id}/chat?since=&wait= | nothing | chat on that server | Read and control |
| POST/v1/servers/{id}/chat | { "author": "Bob", "text": "hello from Discord" } | { "ok": true, "via": "tellraw" } | Read and control |
Reading works exactly like events: the first call without since answers next only, then poll with since and wait up to 25. We keep the last 300 lines an account, up to 100 come back a call.
{ "chat": [ {
"seq": 77, "at": 1792324800, "serverId": "a1b2c3d4-...", "type": "chat",
"player": "Steve", "text": "anyone up for the nether?", "kind": "pc:..."
} ], "next": 77 }
type | text |
|---|---|
chat emote | what the player said (an emote is /me) |
join leave | empty, player says who |
death advancement | the game's own line, Steve was slain by Zombie |
Vanilla, Paper, Purpur, Spigot, Fabric, Quilt, Forge and NeoForge chat is read in all their console shapes. Bedrock prints joins and leaves, plus its chat only when the server itself prints it. A chat plugin that changes the console's chat line can hide chat from it.
Sending puts one line in the game for everyone, shown as [Discord] Bob: hello from Discord. Java gets tellraw, a server older than 1.7.2 gets say, Bedrock gets its own tellraw. The text is made one line, colour codes and invisible characters come out, text is cut at 256 characters and author at 32. The app builds the command itself, so nothing in the text can run as a command of its own. A proxy has no chat, a stopped server answers 400 with why and an app on the machine from before the chat bridge answers 426 update_app. GET /v1/servers says which servers can bridge: "chat": true on the row.
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.