Command Manager API
Schedule recurring console commands from your own code - list, create, edit, toggle, run and audit schedules, with every field, limit and status a schedule can carry.
What these routes are for
These routes are the Command Manager without the panel. Anything you can build on the Command Manager tab - an hourly save, a five-step restart sequence with warnings, an item for every player online - you can create, edit and audit from a script instead.
They sit inside the Customer API and follow all of its conventions: same base URL, same bearer token, same error shape, same rate limits, same pagination.
Base URL: https://wdcgamegrid.com/api/public/v1
Read the catalog before you write anything. Command IDs, parameter names and the all-players selector are per game and are not guessable.
GET /servers/{id}/command-catalogis the authoritative list for the server you are pointed at.
Your first request
Ask what this server can be told to do:
curl -H "Authorization: Bearer gsk_YOUR_KEY_HERE" \
https://wdcgamegrid.com/api/public/v1/servers/{serverId}/command-catalogA five-minute first script:
1. GET /servers - your servers and their ids.
2. GET /servers/{id}/command-catalog - the commands this game supports, and their parameters.
3. POST /servers/{id}/command-schedules - create something harmless, like a world save.
4. POST /servers/{id}/command-schedules/{scheduleId}/run - fire it once, now, without waiting for the cron.
5. GET /servers/{id}/command-schedules/{scheduleId}/log - read what actually happened.
Step 4 then step 5 is the loop worth learning. It tells you whether a schedule works in seconds instead of at 4am tomorrow.
Scopes
| Scope | Grants |
|---|---|
command_schedules:read | List and read schedules, read the command catalog, read execution history |
command_schedules:write | Create, update and delete schedules; toggle them on and off |
command_schedules:control | Run a schedule immediately - this sends a command to a live server |
All three are enabled by default on a new API key. Narrow them when you create or edit the key in the panel.
:controlis the one to think about. A key that only reports on schedules needs:readand nothing else, and cannot then be used to fire astopat your server.
Read the command catalog
GET /servers/{id}/command-catalog — scope command_schedules:read
Returns every command available for this server's game, grouped by category, with the parameters each one takes.
{
"gameKey": "seven-days-to-die",
"version": 2,
"allPlayersSelector": null,
"commands": [
{ "id": "saveworld", "label": "Save World", "syntax": "saveworld",
"description": "Saves the game world", "category": "server",
"warning": null, "params": [] },
{ "id": "buffplayer", "label": "Buff Player", "syntax": "buffplayer {player} {buff}",
"category": "player", "params": [
{ "key": "player", "label": "Player", "type": "text", "required": true, "playerTarget": true },
{ "key": "buff", "label": "Buff", "type": "text", "required": true } ] }
]
}| Field | Means |
|---|---|
allPlayersSelector | The game's own word for "everyone", or null. "@a" on Minecraft; null on the other four |
commands[].id | What you send as commandType |
commands[].syntax | The template. {braces} are parameter keys |
commands[].warning | Present when the command is destructive or behaves oddly on a schedule. Show it to your user |
params[].playerTarget | true means this parameter takes a player, and can be expanded to everyone online |
Catalog sizes differ per game, and a command that exists on one game may not exist on another:
| Game | Commands | allPlayersSelector | Player-target commands |
|---|---|---|---|
| 7 Days to Die | 46 | null | 10 |
| Windrose | 25 | null | 9 |
| Minecraft | 24 | "@a" | 10 |
| Palworld | 11 | null | 4 |
| Valheim | 7 | null | 1 |
Windrose has no broadcast, say or chat command at all, so a Windrose schedule cannot message players. Do not assume every game has a way to announce something.
List schedules
GET /servers/{id}/command-schedules — scope command_schedules:read
Returns every schedule on this server.
| Field | Means |
|---|---|
id, name | Identity |
command | The rendered command string for a single-command schedule |
command_type, command_params | What it was built from |
steps | null on a single-command schedule; an ordered array on a multi-step one |
cron_expression, timezone | When it fires, and in which timezone the cron is read |
enabled | Whether it fires at all |
next_run_at | The next firing, computed server-side |
last_run_at, last_run_status, last_run_output, last_error | The most recent run |
run_when, min_players | The run condition |
notify_on | Which outcomes raise an alert |
overlap_policy, catchup_window_minutes, jitter_seconds, missed_tolerance | Reliability settings |
consecutive_misses, last_run_ms, running_until | Scheduler bookkeeping you can read |
last_run_status
| Value | Means |
|---|---|
success | Sent, and the server replied |
unverified | Sent down a channel that carries no reply. Treat as "sent", not as "worked" |
error | Not delivered, or the server rejected it |
skipped | A run condition turned this occurrence away. Not a failure |
late | More consecutive runs missed than missed_tolerance allows |
unverifiedis normal on Minecraft, whose console returns nothing. It is honesty, not a fault - see Command Manager Troubleshooting.
Step objects
| Field | Means |
|---|---|
commandType, commandParams | What this step runs |
command | The rendered string |
delaySeconds | Seconds to wait *before* this step. Ignored on the first step |
targetAllPlayers | Whether this step expands to every online player |
playerParamKey | Which parameter is the player target. Present only when targetAllPlayers is true |
Create a schedule
POST /servers/{id}/command-schedules — scope command_schedules:write — returns 201 with the created schedule.
A single command
{
"name": "Daily World Save",
"commandType": "saveworld",
"commandParams": {},
"cronExpression": "0 4 * * *",
"timezone": "America/New_York",
"enabled": true
}Several commands in order
Send steps instead of commandType. Up to 20, run sequentially on one trigger.
{
"name": "Daily Maintenance",
"steps": [
{ "commandType": "say", "commandParams": { "message": "Saving world..." } },
{ "commandType": "save-all", "commandParams": {} },
{ "commandType": "say", "commandParams": { "message": "World saved" } }
],
"cronExpression": "0 4 * * *",
"timezone": "America/New_York"
}With waits between the steps
delaySeconds is how long to wait before that step. It is ignored on the first step, and capped at one hour.
{
"name": "Nightly Restart",
"steps": [
{ "commandType": "say", "commandParams": { "message": "Restart in 30 minutes" } },
{ "commandType": "say", "commandParams": { "message": "Restart in 10 minutes" }, "delaySeconds": 1200 },
{ "commandType": "save-all", "commandParams": {}, "delaySeconds": 540 },
{ "commandType": "stop", "commandParams": {}, "delaySeconds": 60 }
],
"cronExpression": "30 3 * * *",
"timezone": "America/New_York",
"runWhen": "server_online",
"overlapPolicy": "skip",
"notifyOn": ["failure"]
}A sequence that waits is still running while it waits. That is what
overlapPolicyis for - four steps spanning half an hour really can still be going when the schedule next comes due.
Give to every online player
Set targetAllPlayers on a step and name which parameter is the player, then leave that parameter out of commandParams - the platform fills it in.
{
"name": "Daily Diamond Drop",
"steps": [
{ "commandType": "give",
"commandParams": { "item": "diamond", "count": 64 },
"targetAllPlayers": true,
"playerParamKey": "player" }
],
"cronExpression": "0 4 * * *"
}playerParamKey must name a parameter the catalog marks playerTarget: true. Omit it and the first player-target parameter is used.
What happens at execution time depends on the game:
| Game | Sends | Identifier substituted |
|---|---|---|
| Minecraft | One command | @a - the game resolves "everyone" itself |
| Palworld | One per player | Platform ID (steam_, gdk_, ps5_) |
| 7 Days to Die | One per player | Entity ID - digits, because a name can contain a space |
| Valheim | One per player | Session ID, which changes on every reconnect |
| Windrose | One per player | Player name - no numeric handle exists |
Each expanded command gets its own row in the execution history, naming the player it ran for.
Nobody online is a no-op, not a failure. A roster that cannot be read is an error and nothing is sent - an unreachable server must never be mistaken for an empty one, or a giveaway reports success having given nothing to anybody.
Every field a schedule can carry
Everything below is accepted by both POST (create) and PATCH (update). On a PATCH, fields you leave out keep their current values.
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | — | Required. 1-100 characters |
cronExpression | string | — | Required. 5-field cron, up to 120 characters |
timezone | IANA name | UTC | The timezone the cron is read in |
enabled | boolean | true | A disabled schedule stays stored and never fires |
commandType | string | — | Single-command schedules. Mutually exclusive with steps |
commandParams | object | {} | Values for that command's parameters |
steps | array | — | 1-20 step objects. Send null on a PATCH to make it single-command again |
steps[].delaySeconds | number | — | 0-3600. Wait before this step. Ignored on the first |
steps[].targetAllPlayers | boolean | false | Expand this step to every online player |
steps[].playerParamKey | string | first match | Which parameter is the player target |
runWhen | enum | always | always, server_online, players_online |
minPlayers | number or null | null | 1-1000. A floor below which the run is skipped |
notifyOn | array | [] | Up to 3 of failure, skipped, success |
overlapPolicy | enum | skip | skip, queue, allow - when it comes due mid-run |
catchupWindowMinutes | number | 15 | 0-1440. How late a run may still happen |
jitterSeconds | number | 0 | 0-900. Start inside a random window, not on the minute |
missedTolerance | number | 1 | 0-50. Consecutive misses before the schedule is flagged late |
What the reliability fields actually do
| Field | Why it exists |
|---|---|
overlapPolicy | Two overlapping restarts is a corrupted world. skip drops the occurrence; queue waits for the previous run; allow runs both |
catchupWindowMinutes | A restart arriving four hours after it was announced is worse than one that did not happen. Past the window the run is recorded as missed rather than fired late. 0 means "only at the moment it was due" |
jitterSeconds | Ten servers that all save at 04:00 hit the same disk in the same second. Spread them |
missedTolerance | One blip should not wake anybody. A schedule that has quietly stopped firing should be impossible to miss |
Updating a schedule that had been flagged
lateclears the flag, so something you have just fixed stops reporting itself as broken.
Check a schedule before you save it
GET /servers/{id}/command-schedules/preview?cronExpression=&timezone=
Returns the expression as a sentence, plus the next five times it will actually fire.
{
"valid": true,
"description": "Every Monday at 4:00 AM",
"nextRuns": ["2026-09-14T08:00:00.000Z", "2026-09-21T08:00:00.000Z",
"2026-09-28T08:00:00.000Z", "2026-10-05T08:00:00.000Z",
"2026-10-12T08:00:00.000Z"],
"timezone": "America/New_York",
"error": null
}nextRuns are ISO-8601 UTC timestamps, worked out on the server that will run the schedule. That is the whole point of the route: computing them again on the client gives a second opinion that agrees right up until a daylight-saving boundary.
An expression that will not parse, or that fires more often than the five-minute minimum, comes back with valid: false, an empty nextRuns, and the reason in error - the same message the create route returns as a 400.
This one is a panel route, not an API-key route. It lives under
/api/v1/..., is authorised by your panel session rather than agsk_key, and is read-only. There is no API-key equivalent yet, so to validate an expression with a key,POSTthe schedule and read the400.
Read, update, toggle and delete
| Route | Method | Scope | Returns |
|---|---|---|---|
/command-schedules/{scheduleId} | GET | :read | The schedule, or 404 |
/command-schedules/{scheduleId} | PATCH | :write | 200 with the updated schedule |
/command-schedules/{scheduleId} | DELETE | :write | 200 with { "deleted": true } |
/command-schedules/{scheduleId}/toggle | POST | :write | 200 with { "enabled": boolean } |
A 404 also covers a schedule that exists but belongs to a different server, so you cannot probe for ids across servers.
Deleting cannot be undone. If you only want it to stop firing, toggle it off instead - the schedule and its history stay.
Run one now
POST /servers/{id}/command-schedules/{scheduleId}/run — scope command_schedules:control
Starts the schedule's command immediately, whatever its cron says. The run happens in the background, so the answer comes back at once with the run's id. Returns 202 Accepted:
{ "status": "started", "runId": "…" }The outcome is not in this response. Read it from the execution history or the runs list below, where every row of this run carries that run_id, or subscribe to the webhook.
While a run of the same schedule is still going you get 409 Conflict and nothing is sent twice.
This is a real command against a live server, which is why it needs its own scope. A run-now on a
stopschedule stops the server.
Execution history
GET /servers/{id}/command-schedules/{scheduleId}/log — scope command_schedules:read
Most recent first, 50 rows per page (?limit= up to 200). For the next page pass ?before= with the executed_at of the last row you have.
| Field | Means |
|---|---|
id, schedule_id | Identity |
run_id | The run this row belongs to: every step of one run, and its summary, share it. It is the runId a run-now returns |
command | The command as expanded at execution - variables resolved, one row per player on an all-players step |
status | success, unverified, error or skipped |
output | What the server replied, where the channel carries a reply |
error | Why it failed |
step_index | null on the schedule-level summary row; 0-19 on per-step rows |
executed_at | When |
`command` is the single most useful field here. It is what actually went to the server, not the template stored on the schedule. A message that went out with
{players}still in it, or a player name split on a space, is visible immediately.
A multi-step run produces one row per step that fired, plus a summary row with step_index: null carrying the overall status.
An unverified row's output reads "Sent to the server console. This channel carries no reply, so the game did not confirm it." A skipped row's output carries the reason the condition gave.
GET /servers/{id}/command-schedules/{scheduleId}/runs — scope command_schedules:read
The same history grouped by run, newest run first, 20 runs per page (?limit= up to 50). Each run has its runId, overall status, startedAt, finishedAt, the summary row and its steps. For the next page pass ?cursor= with the nextCursor of the page you have.
Log rows are kept for 90 days.
Variables in text parameters
Any text value in commandParams may contain a variable. They are resolved on our side, per step, at the moment that step runs - so a value read by step 1 and a value read by step 5 twenty minutes later are both current.
| Token | Becomes |
|---|---|
{players} | The number of players online |
{playerNames} | The online players, comma separated |
{server} | The server name |
{time} / {date} | The current time and date, in the schedule's timezone |
{nextRun} | When the schedule next fires |
{randList:a,b,c} | One of the options at random |
{randNum:1:100} | A whole number in the range |
{ "commandType": "say",
"commandParams": { "message": "Restart at {nextRun} - {players} online, save your progress" } }If the roster cannot be read at that moment, {players} resolves to ? and {playerNames} to an empty string, rather than reporting zero players online because a socket blinked.
Full reference: Command Manager Variables.
Webhooks
Every execution - scheduled or run-now - fires command_schedule.executed. Subscribe to it and you are told the outcome of every run without polling the log.
The payload carries the schedule id, the command, the status, output, error, executed_at, the run_id and the number of steps.
A run that ends in a status the schedule's alert settings name (for example error, or skipped) also fires command_schedule.alert, once per run, and emails the person who created the schedule.
See Webhooks for registering an endpoint and verifying signatures.
Limits
| Limit | Value |
|---|---|
| Minimum interval between firings | 5 minutes |
| Schedules per server | 50 |
| Steps per schedule | 20 |
| Wait before a step | 3600 seconds |
| Custom command string | 500 characters |
| Schedule name | 100 characters |
| Cron expression | 120 characters |
| Execution history returned | 50 entries |
An interval shorter than the minimum returns 400 with "Minimum schedule interval is 5 minutes".
Errors
Same shape as the rest of the Customer API - branch on code, never on message.
| Status | When |
|---|---|
400 | Invalid cron, unknown command type, missing required parameter, a value outside a documented range, the 50-schedule cap, or a minimum-interval violation |
401 | Missing or invalid API key |
403 | The key lacks the scope, or the server is not yours |
404 | Schedule or server not found |
409 | The server is suspended, so writes are blocked |
429 | Rate limit exceeded - read Retry-After |
A 400 from POST is also how you validate a cron expression with an API key, since the preview route needs a panel session.
The same schedules, elsewhere
| Where | Article |
|---|---|
| The panel | Command Manager Guide |
| Discord | Scheduling Commands from Discord |
| Variables reference | Command Manager Variables |
| When something is wrong | Command Manager Troubleshooting |
A schedule created through the API is the same object the panel edits. There is no separate API-only schedule.
