Guides

Webhooks

AdminUpdated Sep 22, 2026

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

  1. Create a webhook with your endpoint URL (it must be https://).

  2. Copy the signing secret. It is shown once.

  3. In your endpoint, verify the x-arcadey-signature header against the raw request body using that secret.

  4. Return any 2xx status within 6 seconds.

  5. 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:

  1. 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 *).

  2. 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

url

Yes

A valid URL, https:// only, at most 500 characters

events

No

An array of event names from the catalogue below. Leave it out, or send [], to subscribe to all events. Don't send "*" in this array, because it's rejected.

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

event

string

The event name, the same value as the x-arcadey-event header

data

object

Event-specific payload (below)

sentAt

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

gameId

string

The game

gameVersionId

string

The version that was decided. It's the versionId the Publisher SDK returns and the version id in GET /v1/me/games/:id.

slug, title

string

The game's current slug and title

reasonCodes

string[]

Reason codes recorded with the decision. Often empty for an approval.

automated

boolean

true when the automated pipeline decided, false when a human moderator did

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 published and 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

rating

integer

1–5

title

string or null

Up to 120 characters

body

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"
}
  • winners lists the finishers placed within the tournament's winning places (rank up to maxWinners), 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.

  • userId is 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

content-type

application/json

x-arcadey-event

The event name, for example game.approved

x-arcadey-signature

sha256= followed by the lowercase hex HMAC-SHA256 of the raw body

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", 200

If 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 2xx response. Your response body is ignored.

Failure

Any non-2xx status, a timeout, or a connection/TLS error

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.

  • statusCode is your server's HTTP status, or null if no response arrived (timeout, DNS, TLS or connection error).

  • error is a short description of a network-level failure (at most 300 characters). It's null when 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.created fires again every time a player edits a review.

  • game.reported fires 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

game.approved / game.rejected

gameVersionId + event. A moderator can overturn an earlier decision on the same version, so if you get both events for one version, trust the one with the later sentAt.

tournament.ended

tournamentId (a tournament settles once)

review.created

gameId + a hash of rating, title and body

game.reported

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:

  1. 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-signature on every request, with a constant-time comparison.

  • Reject requests whose sentAt is 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.created text as untrusted user content: escape it before you render it anywhere.


Related

Was this page helpful?
Webhooks