
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.
| Event | Fires when |
|---|---|
server.started | The server reached the running state from anything other than a restart. |
server.stopped | The server reached the stopped state. |
server.restarted | The server reached the running state as the end of a restart. |
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. |
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. 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}/playersif you need it.
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.
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:
| 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. |
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 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 failed delivery is retried 5 times over roughly 8.5 hours:
| After attempt | Next try in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 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 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.
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 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 /jobs/{jobId}, 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.
