API Docs

Games & uploads API reference

AdminUpdated Sep 22, 2026

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

  • Format: 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

Authorization: Bearer ak_… (older arc_live_… keys still work)

Best for scripts, CI and agents. No CSRF token needed.

Signed-in session

The session cookie set by coolgptgames.com

Unsafe methods (POST/PUT/DELETE) must also send an X-CSRF-Token header matching the csrf cookie.

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

publish

POST /v1/games, POST /v1/games/{id}, DELETE /v1/games/{id}, POST /v1/games/{id}/restore, POST /v1/upload/init, POST /v1/upload/complete, POST /v1/games/{id}/versions, GET /v1/me/games/{id}/preview, POST /v1/me/games/{id}/versions/{versionId}/rollback, POST /v1/me/games/{id}/versions/{versionId}/appeal, and all three media endpoints

read

GET /v1/me/games, GET /v1/me/games/{id}, GET /v1/me/games/{id}/versions/{versionId}, GET /v1/me/deleted-games, and your own unpublished game through GET /v1/games/{slug}

none

GET /v1/runtimes, GET /v1/tags, GET /v1/games, and published games through GET /v1/games/{slug} (all public)

session only

POST /v1/dmca/counter-notices, GET /v1/me/dmca-removals — API keys get 403 session_required

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

code

When

400

validation_error

The body or query failed validation. details.fieldErrors names each bad field.

400

endpoint-specific

See each endpoint.

401

unauthorized

No credentials, or an invalid, revoked or expired key or session.

403

forbidden

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

session_required

An API key called a route that needs an interactive session (the DMCA counter-notice endpoints).

404

not_found

No such game, version or upload (also returned for a deleted game). Unknown paths use this envelope too.

409

game_removed

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

payload_too_large

The body is over the endpoint's limit.

415

unsupported_media_type

Wrong Content-Type (for example on media upload).

429

rate_limited

Rate limit hit. The general per-IP limit also sends a Retry-After header.

503

atlas_unavailable

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 /v1/* endpoint

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

POST /v1/upload/init, POST /v1/games/{id}/versions

60 images / hour

per IP

POST /v1/games/{id}/media

5 appeals / day

per account

POST /v1/me/games/{id}/versions/{versionId}/appeal

5 counter-notices / day

per account

POST /v1/dmca/counter-notices


Endpoint index

Method

Path

Purpose

GET

/v1/runtimes

Runtimes accepting uploads, with limits

GET

/v1/tags

The tags a creator can put on a game

POST

/v1/games

Create a game (draft)

POST

/v1/games/{id}

Update game metadata

DELETE

/v1/games/{id}

Unpublish / delete a game

POST

/v1/games/{id}/restore

Undo your own delete, within 30 days

GET

/v1/me/deleted-games

Your deleted games and whether each is restorable

GET

/v1/me/games

List your games

GET

/v1/me/games/{id}

Manage view: versions, moderation results, analytics

GET

/v1/me/games/{id}/versions/{versionId}

One version's state — the cheap thing to poll

POST

/v1/me/games/{id}/versions/{versionId}/appeal

Appeal a rejected version

GET

/v1/games/{slug}

Public game detail (owners can see unpublished)

GET

/v1/games

Public catalog

POST

/v1/upload/init

Reserve a version and get an upload URL

PUT

{uploadUrl}

Send the zip bytes to storage

POST

/v1/upload/complete

Validate the bundle and start moderation

POST

/v1/games/{id}/versions

Reserve a new version (with a changelog and/or achievements)

GET

/v1/me/games/{id}/preview

Private play link for any build

POST

/v1/me/games/{id}/versions/{versionId}/rollback

Make an approved earlier version current

POST

/v1/games/{id}/media

Upload a cover, screenshot or loading screen

DELETE

/v1/games/{id}/media

Remove an image

POST

/v1/games/{id}/media/reorder

Reorder screenshots

GET

/v1/me/dmca-removals

Your DMCA-removed games and their notices

POST

/v1/dmca/counter-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

slug

string

Pass it as runtime when you upload: html5, pico8, twine, bitsy

acceptedExtensions

string[]

Upload file types (always .zip today)

maxUploadBytes

integer

Maximum zip size, in bytes. Enforced at upload/complete (upload_too_large).

status

"active" | "beta"

needsEval

boolean

Whether the runtime's sandbox allows eval

currentShellVersion

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

title

string

Yes

1–120 chars

description

string

No

≤ 4,000 chars

instructions

string

No

≤ 4,000 chars

categorySlug

string

No

action | puzzle | arcade | racing | shooter | strategy | sports | io. An unknown slug is silently ignored.

tagline

string

No

≤ 160 chars

longDescription

string

No

≤ 8,000 chars

faq

{ question, answer }[]

No

≤ 20 items; question 1–300 chars, answer 1–2,000 chars

websites

string[]

No

≤ 5 items; each ≤ 500 chars and matching ^https?://.+. Trimmed, de-duplicated, blanks dropped.

websiteUrl

string

No

Legacy single website, used only if websites is absent. Same URL rule.

tags

string[]

No

≤ 5 slugs from GET /v1/tags. Lowercase kebab-case, ≤ 24 chars each, de-duplicated.

aiGenerated

boolean

No

Default false

aiDisclosure

string

No

≤ 2,000 chars

turnstileToken

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

embeddable

boolean

Allow other sites to embed the game (default true)

thumbnailKey

string

Storage key of the cover. Only a key from this game's own uploaded images (media/{id}/…, as returned by the media endpoint) is accepted. Anything else is ignored and the current cover is kept. To set a new cover, upload it with POST /v1/games/{id}/media?kind=cover.

Behaviour notes:

  • An unknown categorySlug keeps the current category.

  • Sending websites or websiteUrl replaces the whole website list. "websites": [] clears it.

  • Sending tags replaces 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

code

Meaning

409

not_deleted

The game isn't deleted

409

restore_window_expired

More than 30 days since the delete

409

game_removed

The game was delisted or DMCA-removed before it was deleted. Restore never lifts a takedown.

409

restore_unavailable

Deleted before self-serve restore existed, so there's no prior status to return to. Contact support.

404

not_found

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

game.visibility

full (discoverable) | limited (direct link only)

game.tags

The game's tag slugs, sorted

versions[].status

uploaded | scanning | passed (awaiting human review) | failed | live

versions[].phase

The creator-facing reading of status: live | approved | rejected | in_human_review | superseded | scanning | awaiting_upload. Branch on this.

versions[].changelog

The release notes sent with this upload, or null

versions[].connectHosts

External hosts declared for this version ([] if none)

versions[].sizeBytes

Unpacked size, or null if validation never succeeded

versions[].moderation

null until moderation is queued. verdict: auto_approve | auto_reject | needs_review | null. status: queued | running | complete | error.

versions[].review

null, or the latest recorded decision: { decision: "approve" | "reject", reasonCodes: string[], notes: string | null, automated: boolean, at }. Human and automated approvals and rejections are both recorded here; automated is true when the pipeline decided. Automated ones have notes of "Approved by automated moderation." or "Rejected by automated moderation.". A version held for a human has no entry until the reviewer decides, and an appeal outcome replaces the earlier decision.

versions[].appeal

null, or { id, status: "open" | "approved" | "upheld", note, resolutionNotes, createdAt, resolvedAt }

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

note

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

code

Meaning

400

validation_error

note missing, too short or too long

409

not_rejected

That version wasn't rejected by moderation (a build that failed validation has nothing to overturn)

409

appeal_exists

This version has already been appealed. Upload a new version instead.

409

appeal_superseded

A newer version has been uploaded since

409

game_removed

The game has been taken down, so its versions can't be appealed

429

rate_limited

More than 5 appeals today. A refused appeal doesn't spend the allowance.

403 / 404

Not your game (or the key lacks publish) / no such game or version


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

category

string

Category slug

runtime

string

Runtime slug

tag

string

≤ 40 chars. A tag slug from GET /v1/tags; matches games carrying that tag. Case-insensitive.

q

string

≤ 120 chars; matches the title

sort

new | trending | top

new

multiplayer

boolean

Only games whose current version has realtime enabled

minRating

number

0–5

limit

integer

24

1–48

cursor

string

nextCursor from the previous page

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

gameId

string

Yes

A game you own

runtime

string

Yes

A slug from GET /v1/runtimes

filename

string

Yes

≤ 255 chars. Informational only; the upload is always treated as a zip.

realtime

boolean

No

Default false. Enables multiplayer rooms for this version (see Multiplayer).

connectHosts

string[]

No

≤ 50 entries submitted, ≤ 10 after normalising and de-duplicating. Exact public HTTPS hosts.

gamification

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

uploadUrl

PUT the zip here within 15 minutes

uploadId

The new version's id. Pass it to complete.

expiresAt

Unix time in milliseconds when uploadUrl expires

Errors

HTTP

code

Meaning

400

unknown_runtime

No such runtime

400

runtime_disabled

Runtime isn't accepting uploads

400

bad_connect_hosts

A host isn't an exact public HTTPS host, or is a coolgptgames.com origin

400

too_many_connect_hosts

More than 10 hosts

400

validation_error

Body invalid (including a malformed gamification block)

404

not_found

Game not found, or deleted

403

forbidden

Not your game, or the key lacks publish

409

game_removed

The game was delisted or DMCA-removed and can't take new builds

429

rate_limited

"Upload limit reached". Hourly allowance used up.


PUT {uploadUrl}

Send the raw zip bytes straight to storage.

Requirement

Value

Method

PUT

Header

Content-Type: application/zip. Exactly this. The URL is signed for it, so another type fails.

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.zip
const 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

uploadId

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. Poll GET /v1/me/games/{id} (every few seconds is plenty) until it's published, rejected or pending_review.

  • Update to a live game: the game stays published and 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 the uploadId from init: wait for version.phase to leave scanning.

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: livepublished, passedpending_review, failedrejected, anything else → scanning.

Errors. Once ownership is confirmed, any error marks this version failed. Start again from init.

HTTP

code

Meaning

400

cover_required

The game has no cover image yet. Only an image uploaded with POST /v1/games/{id}/media?kind=cover counts.

400

no_upload

No bytes were received for this upload

400

upload_too_large

Zip over the runtime's maxUploadBytes

400

empty_upload

Zero-byte upload, or an archive with no files

400

invalid_archive

Not a readable zip

400

disallowed_extension

A file type isn't allowed (the message names the file)

400

path_traversal / absolute_path / symlink

Unsafe entry in the archive

400

too_many_files

More than 2,000 files

400

uncompressed_too_large

More than 300 MB unpacked

400

compression_ratio_exceeded

A file compresses more than 100:1

400

extraction_timeout

Archive took more than 20 s to read

400

missing_entry

No index.html / index.htm

400

invalid_cart_format

PICO-8 / Twine / Bitsy upload doesn't look like that tool's export (the message includes a re-export hint)

404

not_found

Unknown uploadId, or its game is gone (deleted)

403

forbidden

Not your game, or the key lacks publish

409

game_removed

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

runtime

string

Yes

Runtime slug

realtime

boolean

No

Default false

changelog

string

No

≤ 2,000 chars. Shown to players as the game's latest update.

connectHosts

string[]

No

Same rules as upload/init

gamification

object

No

Declared achievements and XP intent, same as upload/init. Leave it out to keep the game's current achievements unchanged.

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

versionId

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

code

Meaning

400

no_build

That version has no stored build (it failed at complete, or nothing was uploaded)

400

runtime

The runtime is unavailable for preview

403

forbidden

Not your game, or the key lacks publish

404

not_found

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

code

Meaning

400

not_approved

That version was never approved and published (status isn't live)

403

forbidden

Not your game, or the key lacks publish

404

not_found

No such game, the game was deleted, or the version doesn't belong to it

409

game_removed

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

kind

cover | screenshot | loading

screenshot (any other value also means screenshot)

Header

Value

Content-Type

image/png, image/jpeg, image/gif or image/webp. Other types get 415. The real format is detected from the bytes.

  • cover and loading replace any existing image of that kind.

  • screenshot appends 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

code

Meaning

400

no_image

Empty body, or the body wasn't sent as raw image bytes

400

too_large

Over 5 MB

400

bad_image

Not a PNG, JPEG, GIF or WebP

400

too_many

Already 8 screenshots

413

payload_too_large

Body far over the limit

415

unsupported_media_type

Content-Type isn't one of the four image types

403 / 404

Not your game (or the key lacks publish) / no such game, or the game was deleted

429

rate_limited

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.png
import { 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

key

string

Yes

The key from the upload response. It's also the path of the image URL after the CDN host (it starts with media/).

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

keys

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

gameId

string

A DMCA-removed game you own

fullName

string

2–200 chars

address

string

10–1,000 chars — your postal address

phone

string

5–40 chars; digits, spaces and + ( ) - . only

email

string

A valid email, ≤ 320 chars

materialDescription

string

10–4,000 chars — what was removed and where it appeared

goodFaithStatement

true

Under penalty of perjury: removed by mistake or misidentification

consentToJurisdiction

true

Consent to federal district court jurisdiction and to accept service of process

signature

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

code

Meaning

400

validation_error

A required field is missing or malformed (the two sworn statements must both be true)

403

session_required

Called with an API key

409

not_dmca_removed

That game wasn't removed after a DMCA notice

409

counter_notice_exists

A counter-notice for this game is already being reviewed

429

rate_limited

More than 5 counter-notices today

404

not_found

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));
}
Was this page helpful?