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/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, and all three media endpoints

read

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

none

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

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

404

not_found

No such game, version or upload (also returned for a deleted game).

409

game_removed

The game was delisted or removed after a DMCA notice, so it can't take new builds or be rolled back.

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


Endpoint index

Method

Path

Purpose

GET

/v1/runtimes

Runtimes accepting uploads, with limits

POST

/v1/games

Create a game (draft)

POST

/v1/games/{id}

Update game metadata

DELETE

/v1/games/{id}

Unpublish / delete a game

GET

/v1/me/games

List your games

GET

/v1/me/games/{id}

Manage view: versions, moderation results, analytics

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


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


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.

aiGenerated

boolean

No

Default false

aiDisclosure

string

No

≤ 2,000 chars

turnstileToken

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

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.

  • "" 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

game.visibility

full (discoverable) | limited (direct link only)

versions[].status

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

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 recorded decision: { decision: "approve" | "reject", reasonCodes: string[], notes: string | null, at }. Human and automated approvals and rejections are both recorded here. 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.

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

category

string

Category slug

runtime

string

Runtime slug

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 }


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. Poll the new version's status in the same response instead (live, failed or passed). 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: 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=…",
  "version": { "id": "v_…", "versionNumber": 3, "status": "passed" }
}

playUrl expires about 15 minutes after it's issued.

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.


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.


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));
}
Was this page helpful?
Games & uploads API reference