GameGrid
Webhook Configuration - visual guide

Webhook Configuration

Receive server lifecycle, command and move events as they happen instead of polling. Register a webhook, verify the signature correctly, and understand exactly which eight events exist.

The eight events

A webhook is a URL we POST to when something happens on one of your servers. There are exactly eight 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.

EventFires when
server.startedThe server reached the running state from anything other than a restart.
server.stoppedThe server reached the stopped state.
server.restartedThe server reached the running state as the end of a restart.
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.
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. 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.

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.

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"]
}

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.

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. Note also that the check looks at the address written in the URL, not what it resolves to - a routable-looking hostname is accepted even if DNS points it somewhere private, so do not treat this as protection for your own infrastructure.

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 failed delivery is retried 5 times over roughly 8.5 hours:

After attemptNext try in
11 minute
25 minutes
330 minutes
42 hours
56 hours

After the last attempt 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 that fails the address check at send time, and a webhook with no usable signing secret.

GET /webhooks/{id}/deliveries (scope webhooks:read) returns the delivery history - status code, response body and timing for every attempt. 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.

To re-send one: POST /webhooks/{id}/deliveries/{deliveryId}/redeliver (scope webhooks:manage).

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.

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 eight 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 /jobs/{jobId}, 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.