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
Sent when a creator payout has been transferred to you.
{
"id": "0192a4d9-1e20-7a51-8f3c-9b0d2e4f6a18",
"event": "payout.paid",
"data": {
"payoutId": "0192a4c8-3d11-7f02-b6a4-1c7e9d0b5a63",
"amountUsd": 42.17,
"currency": "usd",
"periodStart": "2026-08-01",
"periodEnd": "2026-08-31",
"paidAt": "2026-09-05T12:00:03.418Z",
"transferId": "tr_1QxSomeStripeTransferId"
},
"sentAt": "2026-09-05T12:00:03.500Z"
}amountUsdis the amount transferred to you, in US dollars.It's sent once, when the transfer succeeds. A payout that fails and later succeeds sends it then.
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 sends immediately, makes a single attempt with no retries, and returns { "ok": true, "delivery": { … } } describing the result. It returns 409 webhook_disabled if the webhook is switched off.
Request format
Each delivery is a single HTTP request:
POST /hooks/coolgpt HTTP/1.1
Host: example.com
content-type: application/json
user-agent: CoolGPTGames-Webhooks/1.0
x-arcadey-event: review.created
x-arcadey-signature: sha256=5d41402abc4b2a76b9719d911017c592ae9f0c0b1d2e3f4a5b6c7d8e9f0a1b2c
x-arcadey-delivery: 0192a4d9-1e20-7a51-8f3c-9b0d2e4f6a18
x-arcadey-attempt: 1
{"id":"0192a4d9-1e20-7a51-8f3c-9b0d2e4f6a18","event":"review.created","data":{"gameId":"…","rating":4,"title":"…","body":"…"},"sentAt":"2026-09-21T10:15:42.117Z"}Header | Value |
|---|---|
|
|
| The event name, for example |
|
|
| Delivery id — the same on every retry and manual redelivery. Use it to dedupe. |
| Attempt number for this delivery, starting at |
|
|
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
Deliveries are queued when the event happens and sent by a background worker, so your endpoint's speed never slows down the action that triggered it.
Behaviour | Value |
|---|---|
Attempts | 7 — the first within ~5 seconds, then retries |
Retry schedule | +1 min, +5 min, +30 min, +2 h, +8 h, +24 h after each failure (about 34 hours in total) |
Timeout | 10 seconds per attempt |
Success | Any |
Failure | Any non- |
Redirects | Not followed. A |
Body | Identical on every attempt, including |
Parallelism | An event is queued for all your subscribed webhooks at once |
Ordering | Not guaranteed |
Manual redelivery | Available, see below |
After the 7th failed attempt the delivery is marked failed and is not retried automatically. You can still redeliver it by hand.
Auto-disable. If every attempt to a webhook fails for 3 days straight and at least 5 deliveries in a row have failed, we switch that webhook off, notify you in the dashboard (and by email if you have email on), and stop queueing events for it. Queued deliveries pause rather than being dropped. Fix your endpoint, then switch it back on with the toggle in the dashboard or PATCH /v1/dev/webhooks/:id — that also clears the failure streak and resumes anything queued. Any successful delivery resets the streak.
Practical advice:
Respond fast. Verify the signature, store or queue the payload, return
200, then do the slow work.Dedupe on
x-arcadey-delivery. Retries and manual redeliveries reuse the same id. - Don't redirect. Point the webhook at its final URL. - Reconcile important state. Webhooks are notifications, not a source of truth. If a missed event matters, check the state periodically through the API (see REST API overview). - Watch your delivery log (below) and keep an eye out for the auto-disable notification.
Redelivering by hand
curl -X POST "https://api.coolgptgames.com/v1/dev/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/redeliver" \
-H "Authorization: Bearer $COOLGPT_API_KEY"Sends that delivery again immediately, with the same delivery id and body. A finished delivery gets one extra attempt; one that's still retrying keeps its remaining automatic retries. Responses: { "ok": true, "delivery": { … } }, or { "id": …, "status": "pending", "queued": true } if the worker happens to be sending it right then. Errors: 409 webhook_disabled, 409 delivery_not_redeliverable (deliveries recorded before retries existed have no stored body), 404 for an unknown webhook or delivery.
Editing a webhook
curl -X PATCH "https://api.coolgptgames.com/v1/dev/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $COOLGPT_API_KEY" \
-H "content-type: application/json" \
-d '{"active": true}'Body: any of active, url (https only), events. Returns { "webhook": { … } }. Needs the webhooks scope.
Delivery log
Every delivery, with each of its attempts, 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",
"status": "succeeded",
"ok": true,
"statusCode": 200,
"error": null,
"attempts": 2,
"maxAttempts": 7,
"nextAttemptAt": null,
"lastAttemptAt": "2026-09-21T10:16:45.120Z",
"deliveredAt": "2026-09-21T10:16:45.120Z",
"createdAt": "2026-09-21T10:15:42.301Z",
"attemptLog": [
{ "attempt": 1, "statusCode": 500, "ok": false, "error": null, "durationMs": 412, "manual": false, "nextRetryAt": "2026-09-21T10:16:42.301Z" },
{ "attempt": 2, "statusCode": 200, "ok": true, "error": null, "durationMs": 133, "manual": false, "nextRetryAt": null }
]
},
{
"id": "0192a4d2-7b44-7c10-a2e9-4d6f8b0c1e37",
"webhookId": "0192a4c1-6f7e-7b3a-9c21-5d8e0f4a1b2c",
"event": "game.reported",
"status": "pending",
"ok": false,
"statusCode": null,
"error": "timed out after 10s",
"attempts": 3,
"maxAttempts": 7,
"nextAttemptAt": "2026-09-21T10:28:03.910Z",
"lastAttemptAt": "2026-09-21T09:58:03.910Z",
"deliveredAt": null,
"createdAt": "2026-09-21T09:55:01.220Z",
"attemptLog": []
}
]
}The log returns the 25 most recent deliveries, newest first.
statusispending(more attempts to come),succeededorfailed(gave up).statusCodeis your server's HTTP status from the last attempt, ornullif no response arrived (timeout, DNS, TLS or connection error).erroris a short description of a network-level failure (at most 300 characters).nextAttemptAtis when the next automatic retry is due.The log doesn't include the request body or your response body.
Finished deliveries are kept for 30 days; pending ones are never pruned.
Idempotency and duplicates
Every delivery carries a unique id, in the x-arcadey-delivery header and the body's id field. It stays the same across retries and manual redeliveries, so record it and ignore a delivery id you've already processed. That covers retries after your server responded too slowly, and any redelivery you request.
A separate concern is the same event legitimately happening more than once, with different delivery ids. 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