Games & uploads API reference
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Games & uploads API reference
This page covers every REST endpoint for creating games, uploading builds, managing versions and media, previewing and rolling back. For the concepts and step-by-step flow, read Packaging & publishing your game first.
Base URL:
https://api.coolgptgames.comFormat: JSON request and response bodies (
Content-Type: application/json), except the media upload and the storage PUT, which take raw bytes.Max JSON body: 1 MB.
Authentication
Unless marked Public, every endpoint needs one of:
Method | How | Notes |
|---|---|---|
API key |
| Best for scripts, CI and agents. No CSRF token needed. |
Signed-in session | The session cookie set by coolgptgames.com | Unsafe methods ( |
Your account must have the creator role. New accounts get it by default. You can only act on games you own.
Scopes
API keys need a scope on most endpoints here. A signed-in session is never scope-limited.
Scope | Endpoints |
|---|---|
|
|
|
|
none |
|
session only |
|
A publish key also satisfies read, so one publish key can upload a build and then poll its verdict. A key without the required scope gets 403 forbidden ("This API key lacks the "publish" scope."). See API keys & scopes.
Errors
Errors look like this:
{ "error": { "code": "cover_required", "message": "Add a cover image before publishing your game.", "details": null } }HTTP |
| When |
|---|---|---|
400 |
| The body or query failed validation. |
400 | endpoint-specific | See each endpoint. |
401 |
| No credentials, or an invalid, revoked or expired key or session. |
403 |
| Not your game, the account isn't a creator, the API key lacks the required scope, or the CSRF token is missing (cookie sessions). |
403 |
| An API key called a route that needs an interactive session (the DMCA counter-notice endpoints). |
404 |
| No such game, version or upload (also returned for a deleted game). Unknown paths use this envelope too. |
409 |
| The game was delisted or removed after a DMCA notice, so it can't take new builds, be rolled back, be appealed or be restored. |
409 | endpoint-specific | Restore and appeal conflicts — see each endpoint. |
413 |
| The body is over the endpoint's limit. |
415 |
| Wrong |
429 |
| Rate limit hit. The general per-IP limit also sends a |
503 |
| API-key verification is temporarily unavailable. Retry with backoff. |
The full catalogue is on Errors & limits.
Rate limits
Limit | Scope | Applies to |
|---|---|---|
100 requests / minute | per IP | every |
5–50 upload starts / hour (by creator level: 1–2 → 5, 3–5 → 8, 6–9 → 12, 10–14 → 20, 15–24 → 30, 25+ → 50) | per account, one allowance shared by both endpoints |
|
60 images / hour | per IP |
|
5 appeals / day | per account |
|
5 counter-notices / day | per account |
|
Endpoint index
Method | Path | Purpose |
|---|---|---|
| Runtimes accepting uploads, with limits | |
| The tags a creator can put on a game | |
| Create a game (draft) | |
| Update game metadata | |
| Unpublish / delete a game | |
| Undo your own delete, within 30 days | |
| Your deleted games and whether each is restorable | |
| List your games | |
| Manage view: versions, moderation results, analytics | |
| One version's state — the cheap thing to poll | |
| Appeal a rejected version | |
| Public game detail (owners can see unpublished) | |
| Public catalog | |
| Reserve a version and get an upload URL | |
| Send the zip bytes to storage | |
| Validate the bundle and start moderation | |
| Reserve a new version (with a changelog and/or achievements) | |
| Private play link for any build | |
| Make an approved earlier version current | |
| Upload a cover, screenshot or loading screen | |
| Remove an image | |
| Reorder screenshots | |
| Your DMCA-removed games and their notices | |
| File a DMCA counter-notice |
Runtimes
GET /v1/runtimes
Public. Lists the runtimes that currently accept uploads (status active or beta).
Response 200
{
"runtimes": [
{
"slug": "html5",
"displayName": "HTML5",
"description": "Browser-native games (Godot, Phaser, Construct, plain JS).",
"acceptedExtensions": [".zip"],
"maxUploadBytes": 104857600,
"status": "active",
"needsEval": true,
"currentShellVersion": "1.0.0"
}
]
}Field | Type | Description |
|---|---|---|
| string | Pass it as |
| string[] | Upload file types (always |
| integer | Maximum zip size, in bytes. Enforced at |
|
| |
| boolean | Whether the runtime's sandbox allows |
| string | null | Platform shell version new uploads are pinned to |
Tags
GET /v1/tags
Public. The curated list of tags a creator may put on a game, and how many one game may carry.
Response 200
{
"tags": [
{ "slug": "pixel-art", "name": "Pixel art" },
{ "slug": "co-op", "name": "Co-op" }
],
"maxPerGame": 5
}Pass slugs from this list as tags on POST /v1/games or POST /v1/games/{id}, and filter the catalog with GET /v1/games?tag=.
Games
POST /v1/games
Creates a game in draft status.
Auth: API key (publish) or session · Headers: optional Idempotency-Key (string, first 128 chars used)
Body
Field | Type | Required | Constraints |
|---|---|---|---|
| string | Yes | 1–120 chars |
| string | No | ≤ 4,000 chars |
| string | No | ≤ 4,000 chars |
| string | No |
|
| string | No | ≤ 160 chars |
| string | No | ≤ 8,000 chars |
|
| No | ≤ 20 items; |
| string[] | No | ≤ 5 items; each ≤ 500 chars and matching |
| string | No | Legacy single website, used only if |
| string[] | No | ≤ 5 slugs from |
| boolean | No | Default |
| string | No | ≤ 2,000 chars |
| string | No | Human-verification token from the website form. API keys are exempt and omit it. A session-authenticated request must send a valid one wherever the check is configured. |
Unknown fields are ignored. The slug is generated from the title plus a random 6-character suffix and is permanent.
Response 201
{ "game": { "id": "g_…", "slug": "space-dodger-3f9a1c", "status": "draft", "tags": ["pixel-art"] } }Response 200 (idempotent replay): same Idempotency-Key as an earlier create on your account:
{ "game": { "id": "g_…", "slug": "space-dodger-3f9a1c", "status": "draft" }, "idempotent": true }Errors: 400 validation_error, 400 captcha_failed, 400 unknown_tag, 400 too_many_tags, 401, 403 (including a key without publish).
curl -X POST https://api.coolgptgames.com/v1/games \
-H "Authorization: Bearer $CGG_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"title":"Space Dodger","categorySlug":"arcade","description":"Dodge asteroids."}'const res = await fetch("https://api.coolgptgames.com/v1/games", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CGG_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ title: "Space Dodger", categorySlug: "arcade" }),
});
const { game } = await res.json(); // { id, slug, status: "draft" }POST /v1/games/{id}
Updates metadata. Only the fields you send change. This doesn't create a version and doesn't trigger moderation.
Auth: API key (publish) or session (owner)
Body: every field is optional. All the POST /v1/games fields except turnstileToken, plus:
Field | Type | Constraints |
|---|---|---|
| boolean | Allow other sites to embed the game (default |
| string | Storage key of the cover. Only a key from this game's own uploaded images ( |
Behaviour notes:
An unknown
categorySlugkeeps the current category.Sending
websitesorwebsiteUrlreplaces the whole website list."websites": []clears it.Sending
tagsreplaces the whole tag set."tags": []clears it. Omitting the field leaves tags unchanged.""clears a text field. Omitting a field leaves it unchanged.
Response 200: { "ok": true }
Errors: 400 validation_error, 400 unknown_tag, 400 too_many_tags, 401, 403, 404 (also for a deleted game).
DELETE /v1/games/{id}
Unpublishes the game: status becomes delisted and it's soft-deleted. Afterwards the game is gone from the catalog, from GET /v1/me/games, and from GET /v1/me/games/{id} and GET /v1/games/{slug} (both return 404). Every write to it (metadata edits, uploads, new versions, preview, rollback and media) also returns 404. Works on drafts too.
You can undo it yourself for 30 days with POST /v1/games/{id}/restore.
Auth: API key (publish) or session (owner)
Response 200
{ "ok": true, "restorableUntil": "2026-10-21T09:00:00.000Z" }Deleting an already-deleted game is idempotent: it returns { "ok": true } and keeps the original deletion time, so a repeated DELETE can't extend or restart the restore window.
Errors: 401, 403, 404.
POST /v1/games/{id}/restore
Undoes your own delete, putting the game back exactly as it was. No body.
Auth: API key (publish) or session (owner)
The status it returns to: a game that was published goes back to published on the same current version (or to draft if it never had one). A game deleted while it was being moderated is re-derived from its newest version, giving published, pending_review, scanning, rejected or draft. Any other prior status is restored as-is.
Response 200
{ "ok": true, "game": { "id": "g_…", "slug": "space-dodger-3f9a1c", "status": "published" } }Errors
HTTP |
| Meaning |
|---|---|---|
409 |
| The game isn't deleted |
409 |
| More than 30 days since the delete |
409 |
| The game was delisted or DMCA-removed before it was deleted. Restore never lifts a takedown. |
409 |
| Deleted before self-serve restore existed, so there's no prior status to return to. Contact support. |
404 |
| No such game, or not yours |
GET /v1/me/deleted-games
Your games deleted in the last 30 days (up to 100, newest first), and whether each can be restored.
Auth: API key (read or publish) or session
Response 200
{
"restoreWindowDays": 30,
"games": [
{
"id": "g_…",
"slug": "space-dodger-3f9a1c",
"title": "Space Dodger",
"deletedAt": "2026-09-21T09:00:00.000Z",
"restorableUntil": "2026-10-21T09:00:00.000Z",
"statusBeforeDelete": "published",
"restorable": true,
"notRestorableReason": null
}
]
}notRestorableReason is game_removed (a takedown) or restore_unavailable (deleted before the feature existed) when restorable is false.
GET /v1/me/games
Your games, newest-updated first (up to 200). Includes drafts, games in review and rejected games. Excludes deleted games.
Auth: API key (read or publish) or session
Response 200
{
"games": [
{
"id": "g_…",
"slug": "space-dodger-3f9a1c",
"title": "Space Dodger",
"status": "published",
"playsTotal": 1832,
"ratingAvg": 4.3,
"publishedAt": "2026-09-18T10:12:00.000Z",
"updateInReview": false
}
]
}status is one of draft, scanning, pending_review, published, rejected, delisted, dmca_removed. See the status reference in Packaging & publishing your game.
updateInReview is true when a live game has a newer version still being scanned or waiting for a human — the case where status alone stays published and tells you nothing.
GET /v1/me/games/{id}
The owner's manage view: full metadata, every version with its moderation result and any human review, plus analytics.
Auth: API key (read or publish) or session (owner)
Response 200
{
"game": {
"id": "g_…",
"slug": "space-dodger-3f9a1c",
"title": "Space Dodger",
"description": "Dodge asteroids.",
"instructions": "Arrow keys to move.",
"status": "published",
"visibility": "full",
"category": { "slug": "arcade", "name": "Arcade" },
"aiGenerated": true,
"aiDisclosure": "Code drafted with an AI assistant.",
"playsTotal": 1832,
"ratingAvg": 4.3,
"ratingCount": 57,
"currentVersionId": "v_…",
"publishedAt": "2026-09-18T10:12:00.000Z",
"createdAt": "2026-09-18T10:05:00.000Z",
"updatedAt": "2026-09-20T08:00:00.000Z",
"tags": ["endless", "fast-paced", "pixel-art"],
"websites": ["https://example.itch.io/space-dodger"]
},
"versions": [
{
"id": "v_…",
"versionNumber": 2,
"status": "live",
"phase": "live",
"realtimeEnabled": false,
"connectHosts": [],
"changelog": "Boss fight rebalanced.",
"sizeBytes": 4194304,
"runtime": "html5",
"runtimeVersion": "1.0.0",
"createdAt": "2026-09-20T07:58:00.000Z",
"isCurrent": true,
"moderation": { "verdict": "auto_approve", "confidence": 0.94, "status": "complete", "error": null },
"review": null,
"appeal": null
}
],
"analytics": {
"earningsTotalUsd": 0,
"byDay": [{ "date": "2026-09-19", "validPlays": 120, "impressions": 80, "grossUsd": 0, "creatorShareUsd": 0 }],
"reportsOpen": 0
}
}Field | Values |
|---|---|
|
|
| The game's tag slugs, sorted |
|
|
| The creator-facing reading of |
| The release notes sent with this upload, or |
| External hosts declared for this version ( |
| Unpacked size, or |
|
|
|
|
|
|
Versions are sorted newest first. Reason codes are listed in Packaging & publishing your game.
When you ship an update to a game that's already live, game.status stays published (still serving the current version) while the new version is moderated. To follow the update, watch the new version's entry, matched by the uploadId you got from init — or poll GET /v1/me/games/{id}/versions/{versionId}, which returns just that version.
Errors: 401, 403, 404 (also for deleted games).
GET /v1/me/games/{id}/versions/{versionId}
One version's state. This is the endpoint to poll after an upload: it does the same work as the manage view for a single version, without the other versions, the revenue series or the report count.
Auth: API key (read or publish) or session (owner)
Response 200
{
"version": {
"id": "v_…",
"versionNumber": 3,
"status": "passed",
"phase": "in_human_review",
"realtimeEnabled": false,
"connectHosts": ["https://api.example.com"],
"changelog": "New level 5.",
"sizeBytes": 4194304,
"runtime": "html5",
"runtimeVersion": "1.0.0",
"createdAt": "2026-09-21T07:58:00.000Z",
"isCurrent": false,
"moderation": { "verdict": "needs_review", "confidence": 0.71, "status": "complete", "error": null },
"review": null,
"appeal": null
},
"gameStatus": "published",
"gameSlug": "space-dodger-3f9a1c"
}version is exactly the object GET /v1/me/games/{id} puts in versions. gameStatus is the game's status, which stays published while an update to a live game is moderated.
Errors: 401, 403, 404 (no such game or version, the version belongs to another game, or the game was deleted).
POST /v1/me/games/{id}/versions/{versionId}/appeal
Appeals a version that moderation rejected. One per version. The moderator sees your note alongside that version's changelog and the original decision.
Auth: API key (publish) or session (owner) · Rate limit: 5 appeals / day / account
Body
Field | Type | Required | Constraints |
|---|---|---|---|
| string | Yes | 10–2,000 chars after trimming. Why the rejection is wrong. |
Response 201
{ "appeal": { "id": "ap_…", "status": "open", "note": "…", "resolutionNotes": null, "createdAt": "…", "resolvedAt": null } }An overturned rejection publishes the version as a normal approval would and sends a game.approved webhook with appeal: true. Either outcome emails and notifies you, and shows up as appeal.status (approved or upheld) on the version.
Errors
HTTP |
| Meaning |
|---|---|---|
400 |
|
|
409 |
| That version wasn't rejected by moderation (a build that failed validation has nothing to overturn) |
409 |
| This version has already been appealed. Upload a new version instead. |
409 |
| A newer version has been uploaded since |
409 |
| The game has been taken down, so its versions can't be appealed |
429 |
| More than 5 appeals today. A refused appeal doesn't spend the allowance. |
403 / 404 | Not your game (or the key lacks |
GET /v1/games/{slug}
Public for published games. For any other status, only the owner gets a response; everyone else gets 404. Deleted games are 404 for everyone.
Auth: optional. To see your own unpublished game, send a session or a key with read (or publish). A key without either gets 404, as if the game didn't exist.
Response 200 (abridged)
{
"game": {
"id": "g_…",
"slug": "space-dodger-3f9a1c",
"title": "Space Dodger",
"description": "…",
"instructions": "…",
"tagline": "…",
"longDescription": "…",
"faq": [{ "question": "…", "answer": "…" }],
"websiteUrl": "https://example.itch.io/space-dodger",
"websites": ["https://example.itch.io/space-dodger"],
"status": "published",
"thumbnailUrl": "https://…/media/g_…/….png",
"loadingScreenUrl": null,
"screenshots": ["https://…/media/g_…/….jpg"],
"playsTotal": 1832,
"ratingAvg": 4.3,
"ratingCount": 57,
"publishedAt": "2026-09-18T10:12:00.000Z",
"runtimeSlug": "html5",
"playUrl": "https://…/index.html?t=…",
"aiGenerated": true,
"aiDisclosure": "…",
"embeddable": true,
"staffPick": false,
"lastUpdate": { "changelog": "Boss fight rebalanced.", "at": "2026-09-20T07:58:00.000Z" },
"currentVersionId": "v_…",
"tags": ["endless", "fast-paced", "pixel-art"],
"featured": false,
"category": { "slug": "arcade", "name": "Arcade" },
"creator": { "handle": "you", "displayName": "You" }
}
}playUrl is null until a version is current. It carries a short-lived access ticket (t=, valid about 15 minutes). Fetch a fresh one rather than storing it. See How games run.
GET /v1/games
Public. The catalog: published, fully visible games only.
Query | Type | Default | Notes |
|---|---|---|---|
| string | — | Category slug |
| string | — | Runtime slug |
| string | — | ≤ 40 chars. A tag slug from |
| string | — | ≤ 120 chars; matches the title |
|
|
| |
| boolean | — | Only games whose current version has realtime enabled |
| number | — | 0–5 |
| integer | 24 | 1–48 |
| string | — |
|
Response 200: { "games": [{ id, slug, title, thumbnailUrl, playsTotal, ratingAvg, ratingCount, creatorHandle, categorySlug, runtimeSlug, publishedAt, featured }], "nextCursor": string | null }
Paging stops at about 10,000 items: nextCursor is null once the next page would cross that line, and a cursor past it is rejected with 400 cursor_out_of_range. Narrow the query instead of paging deeper. See REST API overview → Pagination.
Uploads & versions
The upload flow is always init → PUT → complete. Before you call complete, the game must have a cover (see media).
The CLI (arcadey publish <dir> --cover <image>) and the Publisher SDK (publish({ …, cover })) run the whole flow, cover included, for a new game or an update.
POST /v1/upload/init
Reserves the next version number for a game and returns a presigned storage URL.
Auth: API key (publish) or session (owner) · Rate limit: hourly upload allowance (see Rate limits)
Body
Field | Type | Required | Constraints |
|---|---|---|---|
| string | Yes | A game you own |
| string | Yes | A slug from |
| string | Yes | ≤ 255 chars. Informational only; the upload is always treated as a zip. |
| boolean | No | Default |
| string[] | No | ≤ 50 entries submitted, ≤ 10 after normalising and de-duplicating. Exact public HTTPS hosts. |
| object | No | Declared achievements and XP intent for this version (see XP, achievements & social). Leave it out to keep the game's current achievements unchanged. |
upload/init doesn't take a changelog. Use POST /v1/games/{id}/versions for release notes.
Response 200
{
"uploadUrl": "https://…signed-storage-url…",
"uploadId": "v_…",
"expiresAt": 1790000000000
}Field | Description |
|---|---|
| PUT the zip here within 15 minutes |
| The new version's id. Pass it to complete. |
| Unix time in milliseconds when |
Errors
HTTP |
| Meaning |
|---|---|---|
400 |
| No such runtime |
400 |
| Runtime isn't accepting uploads |
400 |
| A host isn't an exact public HTTPS host, or is a coolgptgames.com origin |
400 |
| More than 10 hosts |
400 |
| Body invalid (including a malformed |
404 |
| Game not found, or deleted |
403 |
| Not your game, or the key lacks |
409 |
| The game was delisted or DMCA-removed and can't take new builds |
429 |
|
|
PUT {uploadUrl}
Send the raw zip bytes straight to storage.
Requirement | Value |
|---|---|
Method |
|
Header |
|
Auth | None. Don't send your API key to the storage URL. |
Body | The zip file bytes |
Expiry | 15 minutes after init |
A non-2xx response comes from the storage provider, not the API. If the URL has expired, start again from init.
curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/zip" --data-binary @my-game.zipconst put = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/zip" },
body: zipBytes, // Buffer, Uint8Array or Blob
});
if (!put.ok) throw new Error(`storage PUT failed: ${put.status}`);POST /v1/upload/complete
Unpacks and validates the uploaded zip synchronously, stores the bundle, and queues automated moderation.
Auth: API key (publish) or session (owner)
Body
Field | Type | Required |
|---|---|---|
| string | Yes |
Response 200
{ "gameVersionId": "v_…", "status": "scanning" }What happens to the game depends on whether it's already live:
First publish (no live version yet): the game's status becomes
scanning. PollGET /v1/me/games/{id}(every few seconds is plenty) until it'spublished,rejectedorpending_review.Update to a live game: the game stays
publishedand keeps serving its current version. Watch the new version instead. Approval swaps the new version in. A rejection, or a hold for human review, affects only the new version.Either way, the simplest thing to poll is
GET /v1/me/games/{id}/versions/{versionId}with theuploadIdfrom init: wait forversion.phaseto leavescanning.
Or subscribe to the game.approved / game.rejected webhooks, which fire for automated and human decisions alike (see Webhooks).
Retries are safe. Calling complete again for a version that has already been processed returns 200 without redoing anything:
{ "gameVersionId": "v_…", "status": "pending_review", "versionStatus": "passed", "idempotent": true }versionStatus is the version's own status. status maps it: live → published, passed → pending_review, failed → rejected, anything else → scanning.
Errors. Once ownership is confirmed, any error marks this version failed. Start again from init.
HTTP |
| Meaning |
|---|---|---|
400 |
| The game has no cover image yet. Only an image uploaded with |
400 |
| No bytes were received for this upload |
400 |
| Zip over the runtime's |
400 |
| Zero-byte upload, or an archive with no files |
400 |
| Not a readable zip |
400 |
| A file type isn't allowed (the message names the file) |
400 |
| Unsafe entry in the archive |
400 |
| More than 2,000 files |
400 |
| More than 300 MB unpacked |
400 |
| A file compresses more than 100:1 |
400 |
| Archive took more than 20 s to read |
400 |
| No |
400 |
| PICO-8 / Twine / Bitsy upload doesn't look like that tool's export (the message includes a re-export hint) |
404 |
| Unknown |
403 |
| Not your game, or the key lacks |
409 |
| The game was delisted or DMCA-removed and can't take new builds |
POST /v1/games/{id}/versions
The same as upload/init for an existing game, plus a changelog. Use it for updates.
Auth: API key (publish) or session (owner) · Rate limit: the same hourly upload allowance as upload/init (one shared allowance)
Body
Field | Type | Required | Constraints |
|---|---|---|---|
| string | Yes | Runtime slug |
| boolean | No | Default |
| string | No | ≤ 2,000 chars. Shown to players as the game's latest update. |
| string[] | No | Same rules as |
| object | No | Declared achievements and XP intent, same as |
Response 201: same shape as upload/init (uploadUrl, uploadId, expiresAt). Then PUT and complete as usual.
Errors: same as upload/init. A deleted game gets 404, and a delisted or DMCA-removed game gets 409 game_removed, before any of your upload allowance is used.
curl -X POST https://api.coolgptgames.com/v1/games/$GAME_ID/versions \
-H "Authorization: Bearer $CGG_API_KEY" -H "Content-Type: application/json" \
-d '{"runtime":"html5","changelog":"New level 5; boss rebalanced."}'GET /v1/me/games/{id}/preview
A private, owner-only play link for any uploaded build, including drafts, builds in review and rejected builds.
Auth: API key (publish) or session (owner)
Query | Type | Notes |
|---|---|---|
| string | Optional. Defaults to the current version, or the newest version if none is current. |
Response 200
{
"playUrl": "https://…/index.html?t=…",
"expiresAt": "2026-09-21T09:15:00.000Z",
"version": {
"id": "v_…",
"versionNumber": 3,
"status": "passed",
"realtimeEnabled": false,
"changelog": "New level 5.",
"isCurrent": false
},
"game": {
"id": "g_…",
"slug": "space-dodger-3f9a1c",
"title": "Space Dodger",
"status": "published",
"runtimeSlug": "html5",
"loadingImageUrl": "https://…/media/g_…/….png"
}
}playUrl expires about 15 minutes after it's issued; expiresAt says when. The game block is there so a player can render its chrome (title, loading image, runtime warm-up) without a second call — the dashboard's preview page uses it to run the build inside the real player in preview mode, where the SDK is answered but nothing is written. See Packaging & publishing your game.
loadingImageUrl falls back to the cover when no loading screen is set, and is null when the game has neither.
Errors
HTTP |
| Meaning |
|---|---|---|
400 |
| That version has no stored build (it failed at complete, or nothing was uploaded) |
400 |
| The runtime is unavailable for preview |
403 |
| Not your game, or the key lacks |
404 |
| No such game, or the game was deleted |
POST /v1/me/games/{id}/versions/{versionId}/rollback
Makes an earlier, already-approved version current immediately, with no re-moderation. The game becomes published with full visibility.
Only a version that was approved and published before (status live) qualifies. A version that cleared the automated checks but is still waiting for a human (passed) doesn't.
Auth: API key (publish) or session (owner) · Body: none
Response 200
{ "ok": true, "currentVersionId": "v_…", "versionNumber": 2 }Errors
HTTP |
| Meaning |
|---|---|---|
400 |
| That version was never approved and published (status isn't |
403 |
| Not your game, or the key lacks |
404 |
| No such game, the game was deleted, or the version doesn't belong to it |
409 |
| The game was delisted or DMCA-removed. A takedown can't be undone by rolling back. |
A rollback also re-activates that version's declared achievements, exactly as approving it would: its set is upserted and any achievement it doesn't declare is deactivated. A version uploaded without a gamification block leaves the catalog unchanged.
Media
POST /v1/games/{id}/media
Uploads one image as the raw request body.
Auth: API key (publish) or session (owner) · Rate limit: 60 / hour / IP · Max body: 5 MB
Query | Values | Default |
|---|---|---|
|
|
|
Header | Value |
|---|---|
|
|
coverandloadingreplace any existing image of that kind.screenshotappends to the gallery (maximum 8).A cover is required before
POST /v1/upload/complete. This endpoint is the only way to set one: the cover must be an image stored under this game's media.
Response 200 (cover or loading)
{ "kind": "cover", "key": "media/g_…/3b1e….png", "url": "https://…/media/g_…/3b1e….png" }Response 200 (screenshot)
{
"kind": "screenshot",
"key": "media/g_…/9a0c….jpg",
"url": "https://…/media/g_…/9a0c….jpg",
"screenshots": ["https://…/media/g_…/1111….jpg", "https://…/media/g_…/9a0c….jpg"]
}Errors
HTTP |
| Meaning |
|---|---|---|
400 |
| Empty body, or the body wasn't sent as raw image bytes |
400 |
| Over 5 MB |
400 |
| Not a PNG, JPEG, GIF or WebP |
400 |
| Already 8 screenshots |
413 |
| Body far over the limit |
415 |
|
|
403 / 404 | Not your game (or the key lacks | |
429 |
| 60 images per hour per IP exceeded |
curl -X POST "https://api.coolgptgames.com/v1/games/$GAME_ID/media?kind=cover" \
-H "Authorization: Bearer $CGG_API_KEY" \
-H "Content-Type: image/png" \
--data-binary @cover.pngimport { readFile } from "node:fs/promises";
const res = await fetch(`https://api.coolgptgames.com/v1/games/${gameId}/media?kind=screenshot`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.CGG_API_KEY}`, "Content-Type": "image/jpeg" },
body: await readFile("shot1.jpg"),
});
const { key, url, screenshots } = await res.json();DELETE /v1/games/{id}/media
Removes a cover, loading screen or screenshot by its storage key.
Auth: API key (publish) or session (owner)
Body
Field | Type | Required | Notes |
|---|---|---|---|
| string | Yes | The |
A key that matches nothing is ignored and still returns 200. Removing the cover means you'll need a new one before your next upload can complete.
Response 200: { "ok": true, "screenshots": ["https://…", "…"] }
Errors: 400 validation_error, 401, 403, 404.
POST /v1/games/{id}/media/reorder
Sets the order of the screenshot gallery.
Auth: API key (publish) or session (owner)
Body
Field | Type | Required | Constraints |
|---|---|---|---|
| string[] | Yes | ≤ 8. Screenshot keys in the order you want. |
Keys that aren't among the game's screenshots are dropped. Any existing screenshot you leave out of keys is removed from the gallery, so always send the full list.
Response 200: { "ok": true, "screenshots": ["https://…", "…"] }
Errors: 400 validation_error, 401, 403, 404.
Copyright (DMCA)
Both endpoints need an interactive session: a counter-notice is a sworn statement, so API keys are refused with 403 session_required. In practice you use the form at coolgptgames.com/dmca/counter-notice; they're documented here so the flow is legible.
GET /v1/me/dmca-removals
Your games removed after a copyright notice, with the notice each one answers and any counter-notice already filed.
Response 200
{
"games": [
{
"id": "g_…",
"slug": "space-dodger-3f9a1c",
"title": "Space Dodger",
"notice": { "id": "…", "claimantName": "…", "claimedWork": "…", "status": "removed", "receivedAt": "…", "actionedAt": "…" },
"counterNotice": { "id": "…", "status": "received", "createdAt": "…", "resolvedAt": null }
}
]
}POST /v1/dmca/counter-notices
Files a counter-notification under 17 U.S.C. §512(g)(3) for one of your DMCA-removed games.
Rate limit: 5 / day / account
Body
Field | Type | Constraints |
|---|---|---|
| string | A DMCA-removed game you own |
| string | 2–200 chars |
| string | 10–1,000 chars — your postal address |
| string | 5–40 chars; digits, spaces and |
| string | A valid email, ≤ 320 chars |
| string | 10–4,000 chars — what was removed and where it appeared |
|
| Under penalty of perjury: removed by mistake or misidentification |
|
| Consent to federal district court jurisdiction and to accept service of process |
| string | 2–200 chars — your typed legal name |
Response 201: { "counterNotice": { "id": "…", "status": "received", "createdAt": "…" } }
You get an acknowledgement email immediately and an outcome email once a human decides. If it's accepted, the game is restored.
Errors
HTTP |
| Meaning |
|---|---|---|
400 |
| A required field is missing or malformed (the two sworn statements must both be |
403 |
| Called with an API key |
409 |
| That game wasn't removed after a DMCA notice |
409 |
| A counter-notice for this game is already being reviewed |
429 |
| More than 5 counter-notices today |
404 |
| No such game, or not yours |
End-to-end example (JavaScript)
Creates a game, adds a cover, uploads a build and waits for the verdict. The key needs the publish scope.
import { readFile } from "node:fs/promises";
const API = "https://api.coolgptgames.com";
const KEY = process.env.CGG_API_KEY;
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...extraHeaders,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error?.message), { code: json.error?.code, status: res.status });
return json;
}
// 1. Create (retry-safe)
const { game } = await api(
"POST", "/v1/games",
{ title: "Space Dodger", categorySlug: "arcade", description: "Dodge asteroids." },
{ "Idempotency-Key": "space-dodger-v1" },
);
// 2. Cover (required before complete)
await fetch(`${API}/v1/games/${game.id}/media?kind=cover`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "image/png" },
body: await readFile("cover.png"),
});
// 3. init → PUT → complete
const { uploadUrl, uploadId } = await api("POST", "/v1/upload/init", {
gameId: game.id, runtime: "html5", filename: "space-dodger.zip",
});
const put = await fetch(uploadUrl, {
method: "PUT", headers: { "Content-Type": "application/zip" }, body: await readFile("space-dodger.zip"),
});
if (!put.ok) throw new Error(`storage PUT failed: ${put.status}`);
await api("POST", "/v1/upload/complete", { uploadId });
// 4. Wait for moderation — poll the VERSION, which works for a first publish
// and for an update to a live game alike.
for (let i = 0; i < 60; i++) {
const { version, gameStatus } = await api("GET", `/v1/me/games/${game.id}/versions/${uploadId}`);
if (!["scanning", "awaiting_upload"].includes(version.phase)) {
console.log(version.phase, gameStatus, version.moderation, version.review);
break;
}
await new Promise((r) => setTimeout(r, 3000));
}