Webhooks
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Webhooks
Webhooks send a signed HTTPS POST to your server when something happens to your games on Cool GPT Games: a build is approved or rejected in moderation, a player reports your game, a player writes a review, or one of your tournaments ends. You can use them to post to your team chat, open a ticket, or update your own records.
This guide covers setup, every event and its payload, signature verification, and how delivery works. For a compact lookup table, see Webhooks reference.
Quick start
Create a webhook with your endpoint URL (it must be
https://).Copy the signing secret. It is shown once.
In your endpoint, verify the
x-arcadey-signatureheader against the raw request body using that secret.Return any
2xxstatus within 6 seconds.Click Test in the dashboard, or call the test endpoint, and check the delivery log.
Registering a webhook
From the dashboard
Go to the Developers page on coolgptgames.com (you must be signed in). In the Webhooks section:
Enter your Endpoint URL. 2. Tick the Events you want. Leave them all unticked to subscribe to every event (the webhook is then listed with
*).Click Add webhook and copy the signing secret from the box that appears. Store it with your other server secrets.
Each webhook in the list has Test, Deliveries and Delete buttons.
From the API
Use an API key with the webhooks scope (see API keys & scopes). A signed-in dashboard session also works.
curl -X POST https://api.coolgptgames.com/v1/dev/webhooks \
-H "Authorization: Bearer $COOLGPT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/coolgpt",
"events": ["game.approved", "game.rejected", "review.created"]
}'Response 201 Created:
{
"webhook": {
"id": "0192a4c1-6f7e-7b3a-9c21-5d8e0f4a1b2c",
"url": "https://example.com/hooks/coolgpt",
"events": ["game.approved", "game.rejected", "review.created"],
"secret": "whsec_3f9a1c0e7b2d4a6f8e1c3b5d7f9a2c4e6b8d0f1a3c5e7b9d"
},
"availableEvents": ["game.approved", "game.rejected", "game.reported", "review.created", "tournament.ended", "payout.paid"]
}Body field | Required | Rules |
|---|---|---|
| Yes | A valid URL, |
| No | An array of event names from the catalogue below. Leave it out, or send |
When you subscribe to all events, the webhook is stored and listed with events: ["*"]. It then also receives any events added in the future.
Limits: at most 10 webhooks per account. The 11th returns 400 too_many_webhooks.
The secret looks like whsec_ followed by 48 hex characters. The create response is the only place it ever appears. List calls never return it. If you lose it, create a new webhook and delete the old one.
The Publisher SDK wraps these endpoints as createWebhook, listWebhooks, deleteWebhook and testWebhook.
Listing and deleting
curl https://api.coolgptgames.com/v1/dev/webhooks \
-H "Authorization: Bearer $COOLGPT_API_KEY"{
"webhooks": [
{
"id": "0192a4c1-6f7e-7b3a-9c21-5d8e0f4a1b2c",
"url": "https://example.com/hooks/coolgpt",
"events": ["game.approved", "game.rejected", "review.created"],
"active": true,
"at": "2026-09-20T14:03:11.482Z"
}
],
"availableEvents": ["game.approved", "game.rejected", "game.reported", "review.created", "tournament.ended", "payout.paid"]
}at is the time the webhook was created. Only webhooks with active: true receive deliveries.
curl -X DELETE https://api.coolgptgames.com/v1/dev/webhooks/$WEBHOOK_ID \
-H "Authorization: Bearer $COOLGPT_API_KEY"
# → { "ok": true }Deleting stops deliveries immediately and also removes that webhook's delivery log.
There's no update endpoint. To change a URL or event list, create a new webhook and delete the old one.
Which events you receive
A webhook only receives events about your own account's content: games you created, and tournaments you created. Each event goes to every active webhook on your account that is subscribed to it, directly or through all-events.
Event catalogue
Every delivery has the same envelope:
{
"event": "<event name>",
"data": { },
"sentAt": "2026-09-21T10:15:42.117Z"
}Field | Type | Meaning |
|---|---|---|
| string | The event name, the same value as the |
| object | Event-specific payload (below) |
| string | ISO 8601 UTC time when the delivery was built. It's covered by the signature. |
IDs are opaque strings. Don't parse them.
game.approved
Sent when a submitted version of your game is approved, whether by the automated moderation pipeline or by a human moderator. The approved version goes live: on a first publish the game becomes published, and on an update the new version replaces the one players were getting.
{
"event": "game.approved",
"data": {
"gameId": "01929f3b-2a10-7c44-8e5b-1f6d3a9c0e21",
"gameVersionId": "0192a3e7-4b19-7d02-9a6c-3e8f1b5d7c90",
"slug": "sky-hopper",
"title": "Sky Hopper",
"reasonCodes": [],
"automated": true
},
"sentAt": "2026-09-21T10:15:42.117Z"
}Field | Type | Notes |
|---|---|---|
| string | The game |
| string | The version that was decided. It's the |
| string | The game's current slug and title |
| string[] | Reason codes recorded with the decision. Often empty for an approval. |
| boolean |
|
A version the pipeline can't decide on its own is held for human review. No webhook is sent at that point; you get game.approved or game.rejected (with automated: false) once a moderator decides.
game.rejected
Sent when a submitted version is rejected, by the automated pipeline or by a human moderator. It has the same fields as game.approved.
{
"event": "game.rejected",
"data": {
"gameId": "01929f3b-2a10-7c44-8e5b-1f6d3a9c0e21",
"gameVersionId": "0192a3e7-4b19-7d02-9a6c-3e8f1b5d7c90",
"slug": "sky-hopper",
"title": "Sky Hopper",
"reasonCodes": ["broken_not_playable", "misleading_metadata"],
"automated": false
},
"sentAt": "2026-09-21T10:15:42.117Z"
}What a rejection does depends on whether the game is already live:
First publish: the game's status becomes
rejected. Fix the problems and upload a new version.Update to a live game: only the new version is rejected. The game stays
publishedand keeps serving the version players already had.
The reasons for both automated and human decisions appear on the version in your game's manage view (GET /v1/me/games/:id, versions[].review).
reasonCodes can be empty. Possible values: nsfw_sexual, nsfw_nudity, graphic_violence, gore, hate_symbols, harassment, ip_infringement, trademark, broken_not_playable, blank_screen, not_a_game, low_effort_duplicate, exact_duplicate, malicious_code, cryptominer, data_exfiltration, phishing_ui, misleading_metadata, spam, contact_info_spam, gambling, age_inappropriate, economy_abuse, other.
Reviewer notes are not included in the webhook. Read them from the version's review.notes in the manage view.
game.reported
Sent when a player (signed in or not) files an abuse report against your game.
{
"event": "game.reported",
"data": {
"gameId": "01929f3b-2a10-7c44-8e5b-1f6d3a9c0e21",
"reasonCode": "broken_not_playable"
},
"sentAt": "2026-09-21T10:15:42.117Z"
}reasonCode is one value from the list under game.rejected. The reporter's identity and free-text details aren't included.
review.created
Sent when a player submits a star rating with a written review for your game. A rating without review text doesn't send it.
{
"event": "review.created",
"data": {
"gameId": "01929f3b-2a10-7c44-8e5b-1f6d3a9c0e21",
"rating": 4,
"title": "Great little platformer",
"body": "Tight controls, but level 7 is brutal."
},
"sentAt": "2026-09-21T10:15:42.117Z"
}Field | Type | Notes |
|---|---|---|
| integer | 1–5 |
| string or | Up to 120 characters |
| string | Up to 4,000 characters, never empty |
Each player has one review per game. Editing a review sends review.created again with the new content. The payload has no review ID or reviewer, so you can't tell an edit from a new review. Reviews that the content filter blocks are never sent.
tournament.ended
Sent when a tournament you created settles: the final ranks are fixed and prizes (virtual coins) are paid out.
{
"event": "tournament.ended",
"data": {
"tournamentId": "0192a0de-55c1-7f02-b6aa-7e3c9d10f4b8",
"name": "Weekend Speedrun",
"gameId": "01929f3b-2a10-7c44-8e5b-1f6d3a9c0e21",
"winners": [
{ "userId": "01928c77-0b3e-7d15-a9f2-4c6e8b1d3a50", "rank": 1, "prizeCoins": 500 },
{ "userId": "01928c80-91aa-7e6b-8c03-2d5f7a9e1b64", "rank": 2, "prizeCoins": 300 },
{ "userId": "01928c95-3cd4-7a88-b1e7-6f0a2c4d8e97", "rank": 3, "prizeCoins": 200 }
]
},
"sentAt": "2026-09-21T10:15:42.117Z"
}winnerslists the finishers placed within the tournament's winning places (rankup tomaxWinners), in rank order. It can be shorter, or empty, if fewer players scored. Entrants who joined but never scored aren't included. Players with exactly the same result share a rank and split the prizes for the places they occupy, so in a tie the list can hold more entries than there are winning places.userIdis a platform user ID, not a display handle.Coins are the platform's virtual currency, not real money.
Timing: a background job settles ended tournaments every 30 seconds, and viewing or listing a tournament after its end time settles it straight away. Expect the webhook shortly after the scheduled end, not exactly at it. - A tournament that is canceled doesn't send
tournament.ended.
See Tournaments for how ranking and prize splits work.
payout.paid
You can subscribe to payout.paid, but the platform doesn't send this event at the moment.
webhook.test
Sent when you click Test in the dashboard or call POST /v1/dev/webhooks/:id/test. It goes only to that webhook, whatever events it subscribes to. You can't subscribe to it by name, and no other event triggers it.
{
"event": "webhook.test",
"data": { "message": "This is a test event from Cool GPT Games." },
"sentAt": "2026-09-21T10:15:42.117Z"
}The test endpoint returns { "ok": true } once the attempt has been made, whether or not your server accepted it. Check the delivery log for the result.
Request format
Each delivery is a single HTTP request:
POST /hooks/coolgpt HTTP/1.1
Host: example.com
content-type: application/json
x-arcadey-event: review.created
x-arcadey-signature: sha256=5d41402abc4b2a76b9719d911017c592ae9f0c0b1d2e3f4a5b6c7d8e9f0a1b2c
{"event":"review.created","data":{"gameId":"…","rating":4,"title":"…","body":"…"},"sentAt":"2026-09-21T10:15:42.117Z"}Header | Value |
|---|---|
|
|
| The event name, for example |
|
|
The x-arcadey- prefix comes from the platform's earlier name and is the correct, current header name. (HTTP header names are case-insensitive, and most frameworks lowercase them.)
Verifying signatures
Verify every delivery before you act on it. The signature proves that the request came from Cool GPT Games and that the body wasn't changed.
The algorithm:
expected = "sha256=" + hex( HMAC_SHA256( key = <your full secret string, including "whsec_">,
message = <raw request body bytes> ) )Key: the secret exactly as you received it, including the
whsec_prefix, used as UTF-8 bytes. Don't strip the prefix or hex-decode the secret. - Message: the raw body bytes as received. Don't parse the JSON and re-serialize it, because whitespace and key order must match exactly. - Compare your result with the header using a constant-time comparison.There's no timestamp header and no signature version besides
sha256=.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.COOLGPT_WEBHOOK_SECRET; // "whsec_…"
function verify(rawBody, header, secret) {
if (typeof header !== "string") return false;
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use the RAW body for this route (no express.json() before it).
app.post("/hooks/coolgpt", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("x-arcadey-signature"), SECRET)) {
return res.status(401).send("bad signature");
}
const { event, data, sentAt } = JSON.parse(req.body.toString("utf8"));
// Optional replay guard: sentAt is covered by the signature.
if (Date.now() - Date.parse(sentAt) > 5 * 60 * 1000) {
return res.status(400).send("stale");
}
res.status(200).send("ok"); // acknowledge fast…
queueForProcessing(event, data); // …and do the real work afterwards
});
app.listen(3000);Python (Flask)
import hashlib
import hmac
import json
import os
from datetime import datetime, timezone
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["COOLGPT_WEBHOOK_SECRET"] # "whsec_…"
def verify(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header:
return False
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/hooks/coolgpt")
def coolgpt_webhook():
raw = request.get_data() # raw bytes; do not use request.json for verification
if not verify(raw, request.headers.get("x-arcadey-signature"), SECRET):
abort(401)
payload = json.loads(raw)
sent_at = datetime.fromisoformat(payload["sentAt"].replace("Z", "+00:00"))
if (datetime.now(timezone.utc) - sent_at).total_seconds() > 300:
abort(400) # optional replay guard
enqueue(payload["event"], payload["data"]) # do slow work off the request
return "ok", 200If verification fails for a delivery you expected to be valid, check three things. First, make sure your framework hasn't already parsed or changed the body. Second, make sure you're using the full secret, prefix included. Third, make sure you're checking against the secret of the right webhook: each webhook has its own.
Delivery, timeouts and retries
Behaviour | Value |
|---|---|
Attempts | One. There are no automatic retries. |
Success | Any |
Failure | Any non- |
Parallelism | An event goes to all your subscribed webhooks at the same time |
Ordering | Not guaranteed |
Manual redelivery | Not available |
A failed delivery is not retried and can't be resent. Plan for that:
Respond fast. Verify the signature, store or queue the payload, return
200, then do the slow work. Anything that runs close to 6 seconds risks a lost event.Don't redirect. Point the webhook at the final URL. Redirect handling isn't guaranteed to keep the request as a signed
POST.Reconcile important state. Webhooks are notifications, not a source of truth. If a missed event matters, for example a rejection, also check the state periodically through the API (see REST API overview). - Watch your delivery log (below) for failures.
Delivery log
Every attempt is logged. Use the Deliveries button in the dashboard, or:
curl https://api.coolgptgames.com/v1/dev/webhooks/$WEBHOOK_ID/deliveries \
-H "Authorization: Bearer $COOLGPT_API_KEY"{
"deliveries": [
{
"id": "0192a4d9-1e20-7a51-8f3c-9b0d2e4f6a18",
"webhookId": "0192a4c1-6f7e-7b3a-9c21-5d8e0f4a1b2c",
"event": "review.created",
"statusCode": 200,
"ok": true,
"error": null,
"createdAt": "2026-09-21T10:15:42.301Z"
},
{
"id": "0192a4d2-7b44-7c10-a2e9-4d6f8b0c1e37",
"webhookId": "0192a4c1-6f7e-7b3a-9c21-5d8e0f4a1b2c",
"event": "game.reported",
"statusCode": null,
"ok": false,
"error": "This operation was aborted",
"createdAt": "2026-09-21T09:58:03.910Z"
}
]
}The log returns the 25 most recent attempts, newest first.
statusCodeis your server's HTTP status, ornullif no response arrived (timeout, DNS, TLS or connection error).erroris a short description of a network-level failure (at most 300 characters). It'snullwhen your server responded.The log doesn't include the request body or your response body.
Logging is best-effort. In rare cases an attempt may be missing from the log.
Idempotency and duplicates
Deliveries have no unique delivery ID or event ID, and sentAt is different on every delivery. You can still receive what is logically the same event more than once. For example:
review.createdfires again every time a player edits a review.game.reportedfires for every report, including several reports from the same player.During secret rotation (below), two webhooks may point at the same URL and each receive a copy.
Make your handler safe to run twice. Key your side effects on the natural fields:
Event | Suggested dedupe key |
|---|---|
|
|
|
|
|
|
| Usually treat each one as a new report and count them |
Rotating the signing secret
Secrets can't be rotated in place. To rotate with no downtime:
Create a new webhook with the same URL and events. Save the new secret. 2. Deploy your endpoint so that it accepts a signature that matches either secret:
const SECRETS = [process.env.COOLGPT_WEBHOOK_SECRET_NEW, process.env.COOLGPT_WEBHOOK_SECRET_OLD].filter(Boolean); const valid = SECRETS.some((s) => verify(req.body, req.get("x-arcadey-signature"), s));While both webhooks exist, each event arrives twice, once signed with each secret. Your dedupe logic handles this. 3. Delete the old webhook. 4. Remove the old secret from your server.
If a secret leaks, delete that webhook immediately, then create a new one. Deleting stops deliveries at once.
Security checklist
Verify
x-arcadey-signatureon every request, with a constant-time comparison.Reject requests whose
sentAtis too old (for example, over 5 minutes) to limit replays.Keep the secret out of source control and client-side code.
Serve the endpoint over HTTPS with a valid certificate (required).
Treat
review.createdtext as untrusted user content: escape it before you render it anywhere.
Related
Webhooks reference: endpoints, event table, headers and algorithm at a glance - API keys & scopes: the
webhooksscopeTournaments: when tournaments settle and how prizes split - Packaging & publishing: review, approval and your game's status - Publisher SDK:
createWebhook,listWebhooks,deleteWebhook,testWebhookErrors & limits: error envelope and rate limits