
Customer API Reference
The GameGrid Customer API - authentication, errors, rate limits, pagination, idempotency and streaming, plus every endpoint group and what it covers.
What the Customer API is for
The Customer API lets you build your own tools to manage your own game servers. Anything you can do to a server in the control panel - start it, change its settings, read its console, take a backup, install a mod - you can do from a script instead.
Build a Discord bot that restarts your server, a dashboard on a wall-mounted tablet, or a nightly job that backs up and reports. Everything lives under one address: https://wdcgamegrid.com/api/public/v1.
The API is RESTful, uses standard HTTP methods, returns JSON, and is versioned. Breaking changes go to /v2; new fields and new endpoints are not breaking.
`GET /openapi.json` is the reference that is never out of date. It is generated from the live routes, so it describes exactly what the server you are talking to can do. Point Postman, Insomnia or an SDK generator straight at it rather than transcribing this page.
Your first request
Put the key in an Authorization header and call any endpoint:
curl -H "Authorization: Bearer gsk_YOUR_KEY_HERE" \
https://wdcgamegrid.com/api/public/v1/serversYou get back a paginated list of your servers. The id field on each one is what every server-specific endpoint needs - save it.
A five-minute first script:
1. GET /account/keys/current - what your key can do. No scope required, so this works even if the scopes are wrong.
2. GET /account - who you are.
3. GET /servers - your servers, and their ids.
4. GET /servers/{id}/capabilities - what this particular server supports. Read this before calling anything game-specific.
5. GET /servers/{id}/realtime-stats - whether it is up, and how busy.
Steps 1 and 4 answer most "why did I get a 403 or a 501" questions before you have to ask. Get into the habit of reading capabilities first and calling only what they report true.
Authentication
Every request outside /meta/* and /openapi.json carries a bearer token holding an API key:
Authorization: Bearer gsk_XXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXA key is 56 characters: the prefix gsk_, an 8-character key id, an underscore, and a 43-character secret. The full key is shown exactly once, when you create it in the panel. The API never returns a secret again - GET /account/keys returns metadata only, and the 8-character key id is the safe identifier to log or display.
Keys are created, rotated and revoked in the panel, not through this API. There is deliberately no keys:write scope, so a leaked key cannot mint more keys. The one exception is keys:revoke, which lets a key switch itself off.
Cookies are ignored on this prefix. A browser session signed in to the panel gets a 401 here; the two authentication systems never overlap, and the API cannot be called cross-origin from a browser at all. Without a valid bearer token you get a 401, every time.
All authentication failures return the same 401 invalid_credentials - missing, malformed, unknown and wrong-secret keys are indistinguishable on purpose. The two exceptions are an expired key and a rotated key past its grace window, which return 401 token_expired.
See API Key Management for creating, scoping, rotating and revoking keys.
Capabilities - what each server can do
GET /servers/{id}/capabilities reports what this server actually supports, in ten groups: lifecycle, console, logs, files, settings, players, backups, mods, maintenance and economy.
Games differ. A route can exist on the API and still be unsupported on your particular server - that returns 501 capability_unsupported, which is not an error in your code. Read capabilities first and call only what they report true, and you will not write handling for a 501 you could have avoided.
The economy group
Worth its own paragraph, because one of its flags has no other way of being discovered.
| Flag | Means |
|---|---|
shop | Wallets, shop and purchases are available on this server. |
itemCatalogue | The item list can be read from the game. |
linking | A Discord account can be linked to an in-game character. |
automaticDelivery | A purchase reaches the player without a human. False means the sale is recorded as a claim for a server admin to fulfil. |
chatBridge | In-game chat can be mirrored to Discord and back. |
automaticDelivery: falseis not an error state and does not fail a purchase.POST /economy/purchasesucceeds, the wallet is debited and the transaction is recorded — and no item arrives, because a person delivers it. This is the case on Windrose. If you are building a shop bot, read this flag and tell the buyer what to expect.
Two of these follow a switch the customer can flip: on a game whose economy is carried by an add-on, they are all false until the add-on is installed. Windrose is the case today — its economy is provided by Windrose Plus. Read capabilities per request rather than caching them for the life of a session.
Errors - always the same shape
Every error, on every route, comes back like this:
{
"error": {
"code": "insufficient_scope",
"message": "This API key does not have the required scope.",
"request_id": "req_...",
"details": { }
}
}Branch on `code`, never on `message`. Codes are stable and changing one is a breaking change. Messages may be reworded at any time. Quote
request_idin a support ticket and we can find the exact request.
| Code | HTTP | When |
|---|---|---|
invalid_request | 400 | The body, query or path does not match the schema. Also a malformed Idempotency-Key. |
invalid_cursor | 400 | The pagination cursor is not one we issued. |
invalid_credentials | 401 | Missing, malformed, unknown or wrong-secret key. |
token_expired | 401 | The key expired, or a rotation grace period ended. |
insufficient_scope | 403 | The key was never granted this scope. |
permission_revoked | 403 | The scope is on the key, but its owner lost the underlying account permission. |
server_deactivated | 403 | The plan expired. Renew to use the server again - your files stay reachable in the file manager and over FTP. |
server_not_found | 404 | The server does not exist, belongs to someone else, or is outside this key's server restrictions. One response for all three. |
resource_not_found | 404 | A non-server resource was not found. |
route_not_found | 404 | No such route - or the capability is unavailable on your account. If a documented route returns this, contact support. |
state_conflict | 409 | The action conflicts with the current state, e.g. starting a running server. |
server_in_migration | 409 | The server is moving to a new host and cannot be controlled until it lands. |
idempotency_conflict | 409 | The same Idempotency-Key was already used with a different body. |
idempotency_key_in_progress | 409 | A request with that key is still running. |
payload_too_large | 413 | Over the 1 MB body limit. |
unsupported_media_type | 415 | Wrong Content-Type for this endpoint. |
validation_failed | 422 | Syntactically valid but semantically wrong - a port out of range, an unknown webhook event, a rejected webhook URL. |
scope_exceeds_permissions | 422 | You asked for scopes you do not hold (key creation). |
confirmation_required | 422 | A destructive action without a matching confirm value. |
rate_limited | 429 | Too many requests. Read Retry-After. |
internal_error | 500 | Something broke on our side. Quote the request_id. |
capability_unsupported | 501 | The route exists, but this game or server cannot do it. |
upstream_unavailable | 502 | The machine your server runs on could not be reached. |
service_unavailable | 503 | Temporarily unavailable. |
upstream_timeout | 504 | The machine your server runs on did not answer in time. |
GET /meta/error-codes returns this catalogue as JSON and needs no key. Note that server_deactivated is thrown by the platform but is not yet in that catalogue - it is listed above so your client can handle it.
Rate limits
Every route sits in one of eight tiers. All windows are 60 seconds.
| Tier | Requests/min | Covers |
|---|---|---|
read | 300 | Every ordinary read, including the SSE streams themselves |
poll | 600 | realtime-stats and config-event status - meant to be polled |
search | 60 | Server search by name; mod marketplace search |
write | 120 | Ordinary writes |
control | 30 | Lifecycle, maintenance, restore, rollback, config-event start/end |
download | 20 | File and log downloads, metrics export |
upload | 10 | File uploads |
stream | 10 | Minting a stream ticket |
Two extra caps apply. Per-server control: one key can send at most 10 control commands to the *same* server per minute. Organization multiplier: all of an organization's traffic in a tier is capped at 2× the per-key limit.
Every response carries the current state:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The limit for this tier |
X-RateLimit-Remaining | What is left, already counting this request |
X-RateLimit-Reset | Seconds remaining, not a Unix timestamp |
X-RateLimit-Tier | Which of the eight tiers applied |
X-RateLimit-Scope | Which limit was checked: key, key+server, org, or none |
Retry-After | On a 429 only: seconds to wait |
Rejected requests still count against your budget, so backing off properly matters. Read
Retry-Afterrather than retrying immediately.
GET /meta/rate-limits returns the tiers as JSON and needs no key.
Pagination
List endpoints use cursor pagination:
{ "data": [ ], "pagination": { "has_more": true, "next_cursor": "eyJ0Ijo..." } }Pass the cursor back as ?cursor=... to get the next page. Keep going while has_more is true; next_cursor is null once it is false.
| Setting | Value |
|---|---|
| Default page size | 50 |
| Maximum page size | 100 |
| Over the maximum | Clamped to 100 with a normal 200 - not an error |
A malformed, zero or negative limit | 422 validation_failed |
| A cursor we did not issue | 400 invalid_cursor |
GET /account/audit is the exception: it defaults to 100 and allows up to 500, over a fixed 24-hour window.
Not every list is paginated, and the shape varies. Endpoints that return a complete set - backups, files, mods, current players, bundles, mod search - include
paginationwithhas_more: falseand no cursor. Two endpoints return no cursor at all:GET /servers/{id}/logsreturnspagination: null, andGET /servers/{id}/console/historyomits the field entirely. Test for the field's presence rather than assuming it is there, and treat the cursor as opaque - do not build one by hand.
Idempotency
Every POST, PUT, PATCH and DELETE accepts an optional Idempotency-Key header. Send a UUID you generate; anything that is not a valid UUID is rejected with 400 invalid_request.
It is optional everywhere - it is never required. Send it on anything you would not want done twice: creating a backup, the maintenance actions, mod installs, file and backup uploads. Without the header, a retry does the work again.
| Situation | Result |
|---|---|
| Same key, same body | The original response is replayed, with Idempotency-Replayed: true |
| Same key, different body | 409 idempotency_conflict |
| Same key, first request still running | 409 idempotency_key_in_progress |
A stored response can be replayed for 24 hours. Only successful responses are stored - if a request failed, a genuine retry runs again rather than replaying the failure. Bodies are compared with key order ignored, so re-serialising JSON does not break a replay. A key is scoped to one route, so the same UUID cannot accidentally deduplicate a call against a different server.
Destructive actions need typed confirmation
Four actions require you to type something before they run: reinstall, wipe, delete-savegames and update. (verify does not - repairing files is not destructive.)
Send the server's exact current name in a confirm field:
{ "confirm": "Community Survival" }The comparison is exact - no trimming, no case folding. Get it wrong or leave it out and you get 422 confirmation_required, whose details name the action, the field, and the exact value expected:
{ "error": { "code": "confirmation_required", "details": {
"action": "wipe", "expected_field": "confirm",
"expected_value": "Community Survival",
"hint": "Send the server's exact current name in the `confirm` field." } } }This is a deliberate speed bump that a script cannot pass by accident. Ownership is checked before the confirmation, so seeing the expected value in the error means you already had access to that server.
Live console and log streaming
Streaming takes two calls, because a browser EventSource cannot send an Authorization header.
1. Mint a ticket with your normal bearer token: POST /servers/{id}/console/stream-tickets (scope console:read) or POST /servers/{id}/logs/stream-tickets (scope logs:read). You get back a stream_url.
2. Open that stream_url with EventSource.
Tickets last 60 seconds and are single-use. Mint one per connection and do not cache them. A ticket is bound to one key, one server and one kind - a logs ticket will not open a console stream, and trying burns the ticket anyway. A ticket carries exactly the minting key's scopes, no more.
| Console stream event | Payload |
|---|---|
connected | { server_id, status } - sent immediately |
history | The recent buffer, as an array of lines |
console | One line as it arrives |
heartbeat | { ts } every 30 seconds |
error | { error } - the stream then ends |
| Log stream event | Payload |
| --- | --- |
connected | { server_id, status } |
log | { text, timestamp } - the first poll sends the current tail |
gap | { skipped } - more lines arrived than one poll window holds, so some were lost. We say so rather than show you a partial, misordered view. |
There is no fixed stream lifetime and no concurrency cap. A stream stays open until you disconnect or the source ends. Handle
errorand ordinary disconnects by minting a fresh ticket and reopening - because tickets are single-use, every reconnect needs a new one.
Field conventions
All field names are snake_case. All timestamps are ISO 8601 UTC with milliseconds and a Z suffix, e.g. 2026-08-16T02:56:02.261Z. Durations end in _seconds; byte counts end in _bytes; percentages end in _pct.
Booleans are real booleans, never the strings "true" / "false". Some large integers arrive as JSON strings to avoid precision loss - parse them rather than assuming typeof === "number".
null means "no value", never zero. This matters most for player counts and metrics on a stopped server.
Singular resources are returned bare. Collections are wrapped in data - see Pagination above for when pagination is present and when it is not.
What the API deliberately does not do
Some things are panel-only, on purpose. These are not missing - they were left out for a reason:
- Creating or deleting servers. Both change what you pay. That belongs somewhere a human clicks a button and sees the price.
- Spending money. Nothing in this API can trigger a payment, buy a slot or change a card.
- Creating API keys. No scope lets a key create another key, so a leaked key cannot make more.
- Moving servers between paid slots. Commercial state, same reasoning as creating servers.
You can still read your billing summary and the plan list. You just cannot spend from a script.
Discovery endpoints - no key required
These need no authentication at all. Use them to build clients and to check your assumptions against a live server:
| Endpoint | Returns |
|---|---|
GET /meta/scopes | Every scope and what each grants |
GET /meta/error-codes | The error catalogue |
GET /meta/rate-limits | The eight tiers, the per-server control limit and the organization multiplier |
GET /meta/games | Supported games |
GET /openapi.json | The full machine-readable specification |
Endpoint groups at a glance
The API is organised into 27 groups. Counts are operations, not paths - one path like /servers/{id}/backups/{backupId} carries a GET, a PATCH and a DELETE.
| Group | Ops | What it covers |
|---|---|---|
account | 10 | Account, organization, entitlements, roles, keys, audit trail |
billing | 2 | Billing overview and plan list |
bundles | 3 | Server bundles and slot usage |
servers | 6 | List, read, rename, status, real-time stats, migration status |
lifecycle | 4 | Start, stop, restart, kill |
lifecycle-history | 3 | State history and auto-restart |
metrics | 6 | Metrics, export, resource limits, diagnostics |
jobs | 3 | Long-running job status and cancellation |
console | 5 | History, command, clear, stream ticket, stream |
logs | 5 | List, tail, download, stream ticket, stream |
files | 15 | The full file manager, plus FTP and SFTP details |
settings | 10 | Schema, values, validate, history, rollback, drift, worlds |
players | 5 | Player counts and player lists |
discovery | 2 | Server capabilities and the player-list catalogue |
map | 7 | Live map: capabilities, players, entities, fog, leaderboard, share link, land claims |
backups | 8 | List, create, upload, download, label, delete, restore |
backup-schedule | 2 | Read and set the backup schedule |
mods | 10 | Installed mods, marketplace search, install, toggle, reorder |
maintenance | 9 | Verify, reinstall, wipe, delete savegames, update, history |
config-events | 18 | Config presets and scheduled events - a 2x XP weekend, say |
command-schedules | 10 | The command catalog and scheduled console commands: create, update, toggle, run now, history, runs |
economy | 28 | Wallets, shop, purchases, transactions, links, chat bridge |
windrose-plus | 6 | Windrose Plus status, config, enable and disable |
webhooks | 8 | Register, update, test, redeliver, delivery history |
tickets | 4 | Read and open support tickets |
meta | 4 | Unauthenticated discovery |
openapi | 1 | The specification document |
For the full per-endpoint reference with request examples, response shapes, scopes and rate-limit tiers, use GET /openapi.json. It is generated from the live routes, so it is the one reference that cannot drift.
