
Webhook Configuration
Receive server lifecycle, command, move, daily summary and player moderation events as they happen instead of polling. Register a webhook, verify the signature correctly, and understand exactly which eleven events exist.
The eleven events
A webhook is a URL we POST to when something happens on one of your servers. There are exactly eleven events you can subscribe to. Nothing else is available, and subscribing to a name that is not on this list is rejected when you register - you will not sit waiting for an event that never fires.
Do not hard-code this table.
GET /api/public/v1/meta/webhook-eventsreturns the same list as JSON and needs no API key, so your client can read it from the server it is actually talking to. If we add an event, your code sees it without a release.
| Event | Fires when |
|---|---|
server.started | The server reached the running state from anything other than a restart, including when its host reports it running after it was started outside the panel. When it was the host's report, the event is held for about 2 minutes first (see below). |
server.stopped | The server reached the stopped state: because someone stopped it, or because its host reported it is no longer running (for example after a crash). The event's reason says which. When it was the host's report, the event is held for about 2 minutes first (see below). |
server.restarted | The server reached the running state as the end of a restart, including when GameGrid restarted it automatically after a crash. |
server.crashed | The server stopped without being asked to, or failed to start. |
console.command_sent | A console command was sent to the server - from the panel or from an API key. |
command_schedule.executed | A scheduled command ran - fired by its schedule, or by a manual Run Now. Carries the run status, so this is how a failing schedule reaches you. |
command_schedule.alert | A scheduled command run ended in a status its schedule alerts on (for example failed or skipped), or the schedule missed several runs in a row. Fires once per run, and the person who created the schedule is emailed as well. |
metrics.daily_summary | Yesterday's summary of the server, once a day: players online at the peak, unique and new players, sessions, time running, availability, crashes and restarts. Counts only, never a player's name. |
player.watched_connected | A player you put On Watch in Metrics connected to one of your servers (while the server's "A player On Watch connects" alert is on, which it is by default). Carries the player's name and the reason you gave; never an address. See Metrics: Moderation. |
player.alt_account_detected | A new possible alternative account was found, only for a server whose "A new possible alternative account is found" alert you switched on. Carries the two player names, the reason and the confidence; never an address. It is a hint, never proof. |
server.migrated | The server finished moving to another host. data.previous_address and data.new_address carry the old and new public address, and data.port_changed says whether the port changed as well as the IP. Update anything that connects by a saved address. |
Three things are deliberately not events:
- Transitional states.
starting,stoppingandrestartingnever fire. You care that a server *is* running, not that it has begun trying, and firing on both would double every real event. - Non-changes. A server reporting "still running" produces nothing.
- Player joins and leaves. Apart from
player.watched_connected, which fires only for a player you put On Watch, these do not exist anywhere on the platform. The server host reports a count and a current list when asked, and nothing else. Deriving joins would mean polling and diffing every server, so it was left out rather than faked. PollGET /servers/{id}/playersif you need it.
There is one more name you will receive but cannot subscribe to: webhook.test, sent only when you press Test (or call POST /webhooks/{id}/test). It is signed exactly like a real delivery, so handle it in your receiver - but registering it is rejected. GET /meta/webhook-events returns it separately, as test_event with subscribable: false, for exactly this reason.
Webhooks are organization-wide, not per-server. A webhook subscribed to
server.crashedfires for every server you own. Filter onserver.idin the payload if you only care about one.
A server that keeps flapping sends one event, not a burst
Sometimes a server's host tells GameGrid, on its own, that a server is running or is no longer running (the event's reason starts with "Live state sync"). When that happens the platform does not send the event at once. It holds it for about 2 minutes first, and two things can happen in that time:
- A newer event for the same server replaces it. The held event is never sent. A server that stops, starts and stops again within a couple of minutes sends you one
server.stopped, about 2 minutes after the last change, instead of three events. - A change that only puts the server back where your webhook last heard it was is dropped. If the server stopped and started again and your webhook was last told it was running, neither event is sent.
A start, stop or restart that a person or GameGrid did on purpose (in the panel, through the API, or by GameGrid itself, for example when it brings a crashed server back) is never delayed and is always sent. The hold applies to every webhook; there is no setting to turn it off for one.
A held-back event that was replaced or dropped is never sent, but the delivery history still lists it: coalesced is true, status stays pending with 0 attempts, and superseded_by is the id of the delivery that replaced it (its own id when the server simply came back to where it was). It is not stuck, so do not wait for it. You can still send it yourself with the redeliver route below.
Each event needs the scope that reads it
A key can subscribe only to events whose data it could read through the API itself, and a webhook keeps receiving an event only while the key that created it still can:
server.started,server.stopped,server.restarted,server.crashedandserver.migratedneedservers:read.console.command_sentneedsconsole:read.command_schedule.executedandcommand_schedule.alertneedcommand_schedules:read.metrics.daily_summaryneedsmetrics:read.player.watched_connectedneedsplayer_metrics:read.player.alt_account_detectedneedsplayer_metrics:alts.
Subscribing without the scope is refused with insufficient_scope, naming each event and the scope it needs; a scope whose account permission the key's owner no longer holds is refused with permission_revoked.
Registering a webhook
Create one with POST /api/public/v1/webhooks (scope webhooks:write):
{
"url": "https://your-app.example.com/hooks/gamegrid",
"events": ["server.started", "server.stopped", "server.crashed"]
}Your account can hold up to 100 webhooks. The 101st registration is refused with webhook_limit_reached (409), and the answer's details carries the limit and your current count; delete one you no longer use, then register again. Webhooks you already have keep working.
The response contains a signing secret, shown once and never retrievable afterwards. Store it before you close the response. If you lose it, delete the webhook and create a new one.
Your URL has to satisfy a few rules, and a rejection tells you which one failed:
| Rule | What it means |
|---|---|
https_required | The URL must use https://. Plain http:// is refused. |
malformed_url / empty_host | It must be a complete absolute URL with a hostname. |
private_address | It must point somewhere publicly reachable. Loopback, private ranges, link-local (including cloud metadata addresses), multicast, and numeric spellings like https://2130706433/ are all refused. |
platform_host / platform_address | It must not point at GameGrid itself: no wdcgamegrid.com address or name under it, and no hostname of yours that resolves to one of our addresses. |
The address check runs on every delivery attempt, not just at registration. If a hostname is later re-pointed at a private address, deliveries stop. A webhook that worked yesterday can start failing because your DNS changed. Registration checks the address written in the URL; each delivery also checks the addresses the hostname actually resolves to, and refuses to connect if any of them is private. That is there to protect the platform, so do not treat it as protection for your own infrastructure.
A webhook stops with the key that made it
Every webhook records the API key that created it (or that last changed its URL or events). It sends events only while that key is active, its owner is still an active member of your account, and the key still holds each event's scope. Revoke the key and its webhooks stop; rotating a key moves its webhooks to the new key, so a rotation does not stop them. A key limited to particular servers cannot create or change webhooks at all, because a webhook covers every server.
A webhook created before 8 October 2026 has no recorded key. It keeps receiving the server events (servers:read) and nothing else; create it again with a key to subscribe to more.
You can see every webhook on your account, and the key behind each, at Account > API Keys, under Webhooks. The member who created a webhook, or an account owner or administrator, can delete it there.
Verifying the signature
Every delivery is signed. Verify it before you trust the payload - the URL is public, so anyone who finds it can POST to it.
Two headers matter: X-Webhook-Signature (HMAC-SHA256, hex) and X-Webhook-Timestamp (Unix seconds).
One rule for every request. Automatic deliveries, the manual test ping and manual redeliveries all sign
timestamp + "." + body, with the timestamp sent inX-Webhook-Timestamp. Earlier versions of this article told you to also accept a signature over the body alone for test pings and redeliveries. Remove that fallback: a body-only signature carries no timestamp, so it cannot be protected against replay.
Sign the raw bytes of the request body. Re-serialising parsed JSON changes key order or whitespace and the signature will not match.
Node
const crypto = require("crypto");
function verifyWebhook(rawBody, headers, secret) {
const received = headers["x-webhook-signature"];
const timestamp = headers["x-webhook-timestamp"];
if (!received || !timestamp) return false;
// Reject anything older than 5 minutes to limit replay.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return false;
const hmac = (payload) =>
crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex");
const expected = Buffer.from(hmac(`${timestamp}.${rawBody}`), "utf8");
const got = Buffer.from(received, "utf8");
return expected.length === got.length && crypto.timingSafeEqual(expected, got);
}
// Express: capture the RAW body, not the parsed object.
app.post("/hooks/gamegrid",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyWebhook(rawBody, req.headers, process.env.WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
res.status(200).send("ok"); // answer fast
handle(JSON.parse(rawBody)); // then do the work
});Python
import hashlib, hmac, time
def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
received = headers.get("X-Webhook-Signature")
timestamp = headers.get("X-Webhook-Timestamp")
if not received or not timestamp:
return False
try:
age = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if age > 300: # 5-minute replay window
return False
key = secret.encode("utf-8")
def digest(payload: bytes) -> str:
return hmac.new(key, payload, hashlib.sha256).hexdigest()
expected = digest(timestamp.encode("utf-8") + b"." + raw_body)
return hmac.compare_digest(expected, received)What a delivery looks like
Every delivery is a POST with this body shape:
{
"event": "server.crashed",
"occurred_at": "2026-09-09T02:14:07.881Z",
"server": { "id": "srv_...", "name": "Community Survival" },
"data": { },
"delivery_id": "..."
}And these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | HMAC-SHA256, hex |
X-Webhook-Timestamp | Unix seconds |
User-Agent | GameGrid-Webhooks/1 |
X-Webhook-Event | The event name. Not sent on the test ping. |
X-Webhook-Delivery | The delivery id. Not sent on the test ping. |
Respond 2xx quickly and do the work afterwards. Any status from 200 to 299 counts as success; anything else - including a 3xx redirect - is a failure. You have 10 seconds before the attempt times out.
Retries and delivery history
A delivery is attempted 5 times in all - the first try and 4 retries - over roughly 2 hours 36 minutes:
| After attempt | Next try in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
If attempt 5 fails too, the delivery is marked failed. The webhook itself is never switched off automatically - if your receiver was down for a day, you come back to a working webhook and some lost events, not a silently disabled integration.
Two failures skip the retries entirely because repeating them cannot help: a URL whose written address fails the check at send time, and a webhook with no usable signing secret. A hostname that resolves to a private address is refused like any other failed attempt, and retried on the schedule above.
GET /webhooks/{id}/deliveries (scope webhooks:read) returns the delivery history: one entry per delivery with its status, the number of attempts, the time and HTTP status of the last attempt, and when it was queued. History is kept for 7 days, so pull anything you need for an investigation before it ages out. The route is paginated, so the full 7 days is reachable. Each delivery carries coalesced and, when it is true, superseded_by (see "A server that keeps flapping sends one event, not a burst").
To re-send one: POST /webhooks/{id}/deliveries/{deliveryId}/redeliver (scope webhooks:manage). A re-send that fails does not stop a delivery that is still being retried automatically: it keeps its status and attempt count, and the automatic retries carry on. A re-send that works marks the delivery delivered.
Managing webhooks
| Method and path | Scope |
|---|---|
GET /webhooks | webhooks:read |
POST /webhooks | webhooks:write |
GET /webhooks/{webhookId} | webhooks:read |
PATCH /webhooks/{webhookId} | webhooks:manage |
DELETE /webhooks/{webhookId} | webhooks:write |
POST /webhooks/{webhookId}/test | webhooks:manage |
GET /webhooks/{webhookId}/deliveries | webhooks:read |
POST /webhooks/{webhookId}/deliveries/{deliveryId}/redeliver | webhooks:manage |
`webhooks:write` is not enough to manage a webhook. It covers create and delete. Update, test and redeliver all need
webhooks:manage. Give any key that administers webhooks all three ofwebhooks:read,webhooks:writeandwebhooks:manage.Who sees the full URL. A webhook URL can hold a secret in its path (a Discord webhook URL does).
GET /webhooksandGET /webhooks/{webhookId}show the fullurlonly to a key withwebhooks:manage. Any other key sees the address followed by/(the URL had no path or query) or/[hidden](it had one). A key limited to particular servers cannot read webhooks at all: the list, one webhook and its deliveries answer403 api_key_server_restricted, as the routes that change them already did.
When changing a subscription with PATCH, send the list under events. An older field name is still accepted for compatibility, but sending both is an error rather than one taking precedence - use events.
When there is no event for what you want
Only eleven events exist. For everything else, poll - and GET /servers/{id}/realtime-stats and the config-event status route are on a 600-requests-per-minute tier precisely so that polling them is reasonable.
| You want to know when | Poll this |
|---|---|
| An API key is being misused | GET /account/audit - 24-hour trail, includes rejected calls |
| A player joins or leaves | GET /servers/{id}/players |
| A backup finished | GET /servers/{id}/backups/{backupId}, with the id that creating the backup returned (it is the backup's id; a backup has no job id): its state goes from queued and in_progress to verified (finished, and it can be restored) or failed. Or GET /servers/{id}/backups |
| A restore finished | GET /servers/{id}/backups/restore-jobs/{restoreJobId} |
| A setting changed | GET /servers/{id}/settings/history or /settings/drift |
| How far a migration has got | GET /servers/{id}/migration-status - the server.migrated event tells you when it has finished |
| A scheduled event applied or reverted | GET /servers/{id}/config-events/status |
| A key is about to expire | GET /account/keys and read expires_at |
| A server is over its resource limits | GET /servers/{id}/realtime-stats or /metrics |
There is no webhook for wipes, reinstalls or other destructive actions, so a webhook cannot be your alarm for a misused key.
GET /account/auditis the tool for that - poll it on a schedule.