Skip to main content
Webhook Configuration - visual guide

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-events returns 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.

EventFires when
server.startedThe 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.stoppedThe 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.restartedThe server reached the running state as the end of a restart, including when GameGrid restarted it automatically after a crash.
server.crashedThe server stopped without being asked to, or failed to start.
console.command_sentA console command was sent to the server - from the panel or from an API key.
command_schedule.executedA 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.alertA 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_summaryYesterday'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_connectedA 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_detectedA 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.migratedThe 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, stopping and restarting never 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. Poll GET /servers/{id}/players if 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.crashed fires for every server you own. Filter on server.id in 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.crashed and server.migrated need servers:read.
  • console.command_sent needs console:read.
  • command_schedule.executed and command_schedule.alert need command_schedules:read.
  • metrics.daily_summary needs metrics:read.
  • player.watched_connected needs player_metrics:read.
  • player.alt_account_detected needs player_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:

RuleWhat it means
https_requiredThe URL must use https://. Plain http:// is refused.
malformed_url / empty_hostIt must be a complete absolute URL with a hostname.
private_addressIt 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_addressIt 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 in X-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:

HeaderValue
Content-Typeapplication/json
X-Webhook-SignatureHMAC-SHA256, hex
X-Webhook-TimestampUnix seconds
User-AgentGameGrid-Webhooks/1
X-Webhook-EventThe event name. Not sent on the test ping.
X-Webhook-DeliveryThe 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 attemptNext try in
11 minute
25 minutes
330 minutes
42 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 pathScope
GET /webhookswebhooks:read
POST /webhookswebhooks:write
GET /webhooks/{webhookId}webhooks:read
PATCH /webhooks/{webhookId}webhooks:manage
DELETE /webhooks/{webhookId}webhooks:write
POST /webhooks/{webhookId}/testwebhooks:manage
GET /webhooks/{webhookId}/deliverieswebhooks:read
POST /webhooks/{webhookId}/deliveries/{deliveryId}/redeliverwebhooks: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 of webhooks:read, webhooks:write and webhooks:manage.

Who sees the full URL. A webhook URL can hold a secret in its path (a Discord webhook URL does). GET /webhooks and GET /webhooks/{webhookId} show the full url only to a key with webhooks: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 answer 403 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 whenPoll this
An API key is being misusedGET /account/audit - 24-hour trail, includes rejected calls
A player joins or leavesGET /servers/{id}/players
A backup finishedGET /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 finishedGET /servers/{id}/backups/restore-jobs/{restoreJobId}
A setting changedGET /servers/{id}/settings/history or /settings/drift
How far a migration has gotGET /servers/{id}/migration-status - the server.migrated event tells you when it has finished
A scheduled event applied or revertedGET /servers/{id}/config-events/status
A key is about to expireGET /account/keys and read expires_at
A server is over its resource limitsGET /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/audit is the tool for that - poll it on a schedule.