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 |
|
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). |
404 |
| No such game, version or upload (also returned for a deleted game). |
409 |
| The game was delisted or removed after a DMCA notice, so it can't take new builds or be rolled back. |
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 |
|
Endpoint index
Method | Path | Purpose |
|---|---|---|
| Runtimes accepting uploads, with limits | |
| Create a game (draft) | |
| Update game metadata | |
| Unpublish / delete a game | |
| List your games | |
| Manage view: versions, moderation results, analytics | |
| 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 |
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 |
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 |
| boolean | No | Default |
| string | No | ≤ 2,000 chars |
| string | No | Human-verification token from the website form. API clients omit it. If sent, it must be valid. |
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" } }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, 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.""clears a text field. Omitting a field leaves it unchanged.
Response 200: { "ok": true }
Errors: 400 validation_error, 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. There is no self-serve undo. Works on drafts too.
Auth: API key (publish) or session (owner)
Response 200: { "ok": true }
Errors: 401, 403, 404.
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"
}
]
}status is one of draft, scanning, pending_review, published, rejected, delisted, dmca_removed. See the status reference in Packaging & publishing your game.
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"
},
"versions": [
{
"id": "v_…",
"versionNumber": 2,
"status": "live",
"realtimeEnabled": false,
"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
}
],
"analytics": {
"earningsTotalUsd": 0,
"byDay": [{ "date": "2026-09-19", "validPlays": 120, "impressions": 80, "grossUsd": 0, "creatorShareUsd": 0 }],
"reportsOpen": 0
}
}Field | Values |
|---|---|
|
|
|
|
| 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: live means approved and now current, failed means rejected, passed means waiting for a human.
Errors: 401, 403, 404 (also for deleted games).
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_…",
"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 | — | ≤ 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 }
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. Poll the new version'sstatusin the same response instead (live,failedorpassed). Approval swaps the new version in. A rejection, or a hold for human review, affects only the new version.
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=…",
"version": { "id": "v_…", "versionNumber": 3, "status": "passed" }
}playUrl expires about 15 minutes after it's issued.
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. |
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.
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. For an update to a game that's already live, poll the new version's status instead of the game's (see POST /v1/upload/complete).
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
for (let i = 0; i < 60; i++) {
const { game: g, versions } = await api("GET", `/v1/me/games/${game.id}`);
if (["published", "rejected", "pending_review"].includes(g.status)) {
console.log(g.status, versions[0]?.moderation, versions[0]?.review);
break;
}
await new Promise((r) => setTimeout(r, 3000));
}