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

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"
}
  • amountUsd is 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

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

x-arcadey-delivery

Delivery id — the same on every retry and manual redelivery. Use it to dedupe.

x-arcadey-attempt

Attempt number for this delivery, starting at 1

user-agent

CoolGPTGames-Webhooks/1.0

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

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

Failure

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

Redirects

Not followed. A 3xx is a failed attempt — register the final URL.

Body

Identical on every attempt, including sentAt (the time the event was queued)

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.

  • status is pending (more attempts to come), succeeded or failed (gave up).

  • statusCode is your server's HTTP status from the last attempt, 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).

  • nextAttemptAt is 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.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?