Publisher SDK

Embed
Preview of the card
<a href="https://coolgptgames.com/docs/publisher-sdk"><img src="https://coolgptgames.com/docs/_api/public/orgs/062b11d9-2b86-4627-9e86-96816a67d7a1/spaces/07d97e67-936e-4223-9eaf-6998a46fee24/pages/5dda2afd-49ad-454f-8c6c-316ae1dcb171/social-card.svg?v=3e7ae33eec8b" alt="Publisher SDK" width="600" height="315"></a>

@coolgptgames/sdk is a typed TypeScript client for the Cool GPT Games management API. It wraps the REST endpoints you need to create games, upload covers, screenshots and builds, ship updates, wait for review, read your stats, and manage tournaments, remote config, webhooks and store items. The CLI and the MCP server are built on it.

It's a server-side library for Node.js, CI and scripts. Don't use it in a browser, and never ship an API key inside a game. For code that runs inside your game, use the GameSDK (see GameSDK core reference).

Installation

npm i @coolgptgames/sdk

Requirements: Node.js 18 or later. The SDK uses the built-in fetch, Buffer and crypto.randomUUID. The package ships compiled JavaScript with TypeScript declarations, so it works from both TypeScript and plain JavaScript (ESM).

Everything the SDK does is a plain HTTPS call, so you can also work straight against the API — see REST API overview and Games & uploads API reference, which document the same operations with curl.

Quick start

import { ArcadeyClient } from "@coolgptgames/sdk";

const arc = new ArcadeyClient({ apiKey: process.env.ARCADEY_API_KEY! });

const games = await arc.listGames();
console.log(games.map((g) => `${g.id}  ${g.slug}  ${g.status}`).join("\n"));

The client talks to https://api.coolgptgames.com unless you tell it otherwise (see Configuration).

Configuration

new ArcadeyClient(options)

Option

Type

Required

Default

Notes

apiKey

string

Yes

n/a

The constructor throws Error("apiKey is required") if this is empty

baseUrl

string

No

$ARCADEY_API_URL, else https://api.coolgptgames.com

API origin. Trailing / characters are removed.

fetchImpl

typeof fetch

No

global fetch

Inject your own fetch, for example for proxies, retries or tests. It's used for API calls and for the bundle upload.

The API origin is chosen in this order:

  1. The baseUrl option, if set.

  2. The ARCADEY_API_URL environment variable, if set and non-empty.

  3. DEFAULT_API_URL (https://api.coolgptgames.com).

The origin in use is available afterwards as the read-only arc.baseUrl property.

Authentication and scopes

Pass an API key (ak_…, or an older arc_live_…) as apiKey. The client sends it as Authorization: Bearer <key> on every API request. Create keys in Dashboard → API keys. See API keys & scopes for choosing scopes.

Every method needs a key that holds the scope listed for it in the method index, or * (full access):

Scope

Covers

read

Your game list, a game's manage view, summary, earnings, payouts, player reports, and publicGame() for your own unpublished games

publish

Creating, updating and unpublishing games, media uploads and deletes, and bundle uploads. A publish key also satisfies read, so it can poll the review result of what it uploaded.

tournaments

createTournament, listTournaments, cancelTournament

analytics

analytics

config

getConfig, setConfig

webhooks

createWebhook, listWebhooks, deleteWebhook, testWebhook

economy

upsertItem, listItems, deleteItem

A key without the needed scope gets ArcadeyError with status: 403, code: "forbidden" and the message This API key lacks the "<scope>" scope.

For a CI or publishing script, a key with just publish is enough for everything in Publishing, Media and Managing games.

A few actions spend your coins or change your account, and no API key can do them, whatever its scopes. They need a signed-in dashboard session and return 403 with code: "session_required". The one the SDK can hit is creating a tournament with a prize pool (see createTournament).

Errors

Every failure the SDK detects is thrown as an ArcadeyError:

class ArcadeyError extends Error {
  // name === "ArcadeyError"
  readonly status: number;   // HTTP status, or 0 for a check the SDK made before calling the API
  readonly code?: string;    // the API's error.code (e.g. "cover_required"), or an SDK code (below)
  readonly gameId?: string;  // set when publish() failed AFTER creating the game
}

Where the message and code come from:

Situation

status

code

message

API returned a non-2xx JSON error

HTTP status

The API's error.code

The API's error.message

API returned a non-2xx response that isn't JSON (for example an HTML error page from a proxy)

HTTP status

none

HTTP <status>: <first 200 characters of the body>

API returned 2xx but the body isn't JSON

HTTP status

bad_response

Expected JSON from <METHOD> <path> but got: <snippet>

The bundle PUT to the upload URL failed

HTTP status

none

bundle upload failed (HTTP <status>)

Checks the SDK makes before any request

0

See below

A description of the problem

SDK-side codes (always status: 0):

code

Thrown by

When

no_files

publish, publishVersion, uploadVersion

files is empty

no_game

publishVersion

gameId is empty

cover_required

publish

cover is missing

bad_image

publish, publishVersion, uploadMedia and its shortcuts

The image is empty, not a Uint8Array, or not PNG, JPEG, GIF or WebP

too_large

same

The image is over 5 MB (MAX_IMAGE_BYTES)

Things to know:

  • Network failures (DNS, connection refused, TLS) reject with the underlying fetch error, not ArcadeyError. The exception is publish(), which wraps any failure after the game was created in an ArcadeyError carrying gameId (with status: 0 if the original wasn't an ArcadeyError).

  • The client doesn't retry anything itself. Add retries in your code or through fetchImpl.

import { ArcadeyClient, ArcadeyError } from "@coolgptgames/sdk";

try {
  await arc.setConfig(gameId, { difficulty: "hard" });
} catch (e) {
  if (e instanceof ArcadeyError && e.code === "forbidden") {
    console.error("Key is missing a scope:", e.message);
  } else if (e instanceof ArcadeyError && e.code === "rate_limited") {
    // back off and retry later
  } else {
    throw e;
  }
}

For every code the API can return, see Errors & limits.

Constants and helpers

import { DEFAULT_API_URL, MAX_IMAGE_BYTES, detectImageType, buildZip } from "@coolgptgames/sdk";

Export

Value / signature

Notes

DEFAULT_API_URL

"https://api.coolgptgames.com"

The production API origin

MAX_IMAGE_BYTES

5242880 (5 MB)

The per-image limit for covers, screenshots and loading screens. Matches the API's limit.

detectImageType(buf)

(buf: Uint8Array) => "image/png" | "image/jpeg" | "image/gif" | "image/webp" | null

Sniffs the type from the file's first bytes, with the same rules as the API. Returns null for anything else, or for fewer than 12 bytes.

buildZip(entries)

(entries: ZipEntry[]) => Buffer

See buildZip

import { readFile } from "node:fs/promises";
const bytes = await readFile("cover.webp");
if (!detectImageType(bytes) || bytes.length > MAX_IMAGE_BYTES) throw new Error("unusable cover");

Types

interface ArcadeyClientOptions {
  apiKey: string;
  baseUrl?: string;
  fetchImpl?: typeof fetch;
}

interface ZipEntry {
  path: string;                          // path inside the bundle, "/"-separated, e.g. "assets/ship.png"
  content: Uint8Array | Buffer | string; // strings are encoded as UTF-8
}

/** Image bytes: PNG, JPEG, GIF or WebP, max 5 MB. The type is sniffed from the bytes. */
type ImageInput = Uint8Array;            // a Node Buffer works too
type MediaKind = "cover" | "screenshot" | "loading";

interface MediaUploadResult {
  kind: MediaKind;
  key: string;             // storage key; pass it to deleteMedia()
  url: string;             // public URL of the image
  screenshots?: string[];  // screenshots only: the full gallery (URLs) after this upload
}

/** The `gamification` block of game.json: declared achievements + XP intent. */
interface GamificationManifest {
  achievements?: { key: string; name: string; description?: string; tier?: string; icon?: string; [k: string]: unknown }[];
  xp?: { note?: string; leaderboard?: boolean };
  [k: string]: unknown;
}

interface UploadOptions {
  runtime?: string;        // default "html5"
  realtime?: boolean;      // opt into multiplayer (default false)
  connectHosts?: string[]; // external https hosts the game may fetch from (max 10)
  gamification?: GamificationManifest;
  changelog?: string;      // release notes, up to 2,000 characters
  waitForReview?: boolean; // default true
  timeoutMs?: number;      // how long to wait for review, default 30000
}

interface PublishVersionInput extends Omit<UploadOptions, "waitForReview"> {
  files: ZipEntry[];
  cover?: ImageInput;         // replace the game's cover first (optional)
  screenshots?: ImageInput[]; // add to the gallery (max 8 per game)
}

interface PublishInput extends PublishVersionInput {
  title: string;
  description?: string;
  category?: string;          // category slug, e.g. "arcade"
  instructions?: string;
  aiGenerated?: boolean;
  aiDisclosure?: string;
  cover: ImageInput;          // REQUIRED for a new game
  idempotencyKey?: string;    // for the create step; default: a fresh UUID per call
}

interface PublishResult {
  gameId: string;
  slug: string;
  status: string;          // "published" | "rejected" | "pending_review" (see "What status means")
  playUrl: string | null;  // only when published
  versionId?: string;      // the version this call uploaded
}

interface GameSummary {
  id: string;
  slug: string;
  title: string;
  status: string;          // "draft" | "scanning" | "pending_review" | "published" | "rejected" | "delisted" | "dmca_removed"
  playsTotal: number;
  ratingAvg: number | null;
}

interface PlayerReport {
  id: string;
  gameId: string;
  gameTitle: string;
  gameSlug: string;
  reasonCode: string;
  details: string | null;
  status: string;          // e.g. "open"
  createdAt: string;
}

interface Tournament {
  id: string;
  name: string;
  description: string | null;
  metric: "high_score" | "total_score";
  status: "scheduled" | "live" | "ended" | "settled" | "canceled";
  prizePoolCoins: number;
  maxWinners: number;
  startsAt: string;
  endsAt: string;
  entries?: number;
  game?: { slug: string; title: string };
}

interface TournamentStanding {
  rank: number;            // shared by exact ties; null for entrants with no score yet
  handle: string;
  displayName: string | null;
  avatarUrl: string | null;
  score: number;
  prizeCoins: number;
}

interface GameAnalytics {
  game: { slug: string; title: string };
  totalEvents: number;
  uniquePlayers: number;
  events: { name: string; count: number }[];
  errors: { message: string; count: number }[];
  daily: { date: string; plays: number; players: number }[];
  returningPlayers: number;
}

interface GameItem {
  id: string;
  gameId: string;
  sku: string;
  name: string;
  description: string | null;
  priceCoins: number;
  active: boolean;
  kind?: "durable" | "consumable";
}

interface ItemPurchase {
  id: string;
  sku: string;
  kind: "durable" | "consumable";
  quantity: number;
  priceCoins: number;
  status: string;                 // "purchased" until refunded
  createdAt: string;
  refundedAt: string | null;
  refundReason: string | null;
  unitsRevoked: number | null;
  playerHandle: string | null;
  refundable: boolean;            // false once refunded, or past the refund window
}

interface Webhook {
  id: string;
  url: string;
  events: string[];
  active: boolean;
  at?: string;
  disabledAt?: string | null;
  disabledReason?: "manual" | "failing" | null;
  consecutiveFailures?: number;
  failingSince?: string | null;
}

interface WebhookDelivery {
  id: string;                     // also the x-arcadey-delivery header; stable across retries
  webhookId: string;
  event: string;
  status: "pending" | "succeeded" | "failed";
  ok: boolean;
  statusCode: number | null;
  error: string | null;
  attempts: number;
  maxAttempts: number;
  nextAttemptAt: string | null;
  lastAttemptAt: string | null;
  deliveredAt: string | null;
  createdAt: string;
  attemptLog: {
    attempt: number;
    statusCode: number | null;
    ok: boolean;
    error: string | null;
    durationMs: number | null;
    manual: boolean;
    nextRetryAt: string | null;
  }[];
}

TournamentStanding.rank is typed number, but the API sends null for an entrant who joined and hasn't scored.

Coin amounts (prizePoolCoins, prizeCoins, priceCoins) are the platform's virtual currency, not real money.

Publishing

Every game needs a cover image before a build can be published. publish() uploads it for you. For an existing game, publishVersion() keeps the current cover unless you pass a new one.

publish(input)

Creates a new game, uploads its cover and screenshots, uploads the bundle, and waits for the review verdict.

publish(input: PublishInput): Promise<PublishResult>

Scope: publish.

Steps, in order:

  1. Checks before any request. Throws ArcadeyError (status 0) if files is empty (no_files), cover is missing (cover_required), or the cover or any screenshot isn't a valid image (bad_image, too_large). Nothing is created when these fail.

  2. Create the game: POST /v1/games with an Idempotency-Key header (your idempotencyKey, or a fresh UUID).

  3. Cover: POST /v1/games/:id/media?kind=cover.

  4. Screenshots: POST /v1/games/:id/media?kind=screenshot for each, in order.

  5. Bundle: reserve a version, PUT the zip to the returned upload URL, then POST /v1/upload/complete (see uploadVersion).

  6. Wait for the verdict with waitForReview(gameId, timeoutMs ?? 30000).

If any step after 2 fails, the thrown ArcadeyError has gameId set, and its message ends with (game <id> was created — retry with publishVersion("<id>", …) / `arcadey publish --game <id>`). Retry with publishVersion(e.gameId, …), not publish(), or you'll create a second game.

import { ArcadeyClient, ArcadeyError, type ZipEntry } from "@coolgptgames/sdk";
import { readFile, readdir } from "node:fs/promises";
import { join, relative, sep } from "node:path";

const arc = new ArcadeyClient({ apiKey: process.env.ARCADEY_API_KEY! });

async function readBundle(dir: string): Promise<ZipEntry[]> {
  const files: ZipEntry[] = [];
  for (const entry of await readdir(dir, { recursive: true, withFileTypes: true })) {
    if (!entry.isFile()) continue;
    const full = join(entry.parentPath, entry.name);
    files.push({ path: relative(dir, full).split(sep).join("/"), content: await readFile(full) });
  }
  return files;
}

try {
  const res = await arc.publish({
    title: "Space Miner",
    category: "arcade",
    description: "Mine asteroids, dodge pirates.",
    files: await readBundle("./dist"),
    cover: await readFile("./art/cover.png"),
    screenshots: [await readFile("./art/shot1.png"), await readFile("./art/shot2.png")],
    timeoutMs: 120_000,
  });
  console.log(res.gameId, res.status, res.playUrl ?? "not live yet");
  // Save res.gameId: you need it to ship updates.
} catch (e) {
  if (e instanceof ArcadeyError && e.gameId) {
    console.error(`Game ${e.gameId} was created but publishing failed: ${e.message}`);
    // later: await arc.publishVersion(e.gameId, { files, ... })
  }
  throw e;
}

idempotencyKey only protects the create step. If you retry publish() yourself after a timeout, pass the same idempotencyKey and the API returns the game it already created instead of a duplicate draft.

publishVersion(gameId, input)

Ships a new version of an existing game: optionally replaces its cover and adds screenshots, uploads the bundle, and waits for the verdict.

publishVersion(gameId: string, input: PublishVersionInput): Promise<PublishResult>

Scope: publish.

  1. Throws no_game, no_files, bad_image or too_large (status 0) before any request.

  2. If cover is given, uploads it (replacing the current cover). Otherwise the existing cover stays.

  3. Uploads each of screenshots, if any.

  4. Uploads the bundle with uploadVersion() and waits with timeoutMs (default 30,000 ms).

  5. Returns { gameId, slug, status, playUrl, versionId }.

const files = await readBundle("./dist");
const res = await arc.publishVersion(gameId, { files, changelog: "Faster levels, new boss" });
console.log(res.versionId, res.status);

The game must already have a cover, either from an earlier upload or from cover in this call. If it doesn't, upload/complete fails and the SDK throws ArcadeyError with code: "cover_required" and the message This game has no cover image, and one is required to publish. Upload one first (uploadCover / `arcadey publish --cover <path>`), then retry.

Other errors from the API include:

status / code

Cause

404 not_found

No such game, or the game was deleted

403 forbidden

Not your game, or the key lacks publish

409 game_removed

The game was delisted or removed after a copyright notice and can't take new uploads

429 rate_limited

Your hourly upload limit is used up

400 bundle codes (upload_too_large, missing_entry and so on)

The bundle failed validation. See Errors & limits.

What status means

publish(), publishVersion() and uploadVersion() wait for the verdict on the version they uploaded, by passing its id to waitForReview():

status

Meaning

published

The uploaded version was approved and is live. playUrl is set.

rejected

The uploaded version was rejected. Read the reasons with game(gameId) (the version whose id equals versionId, under review).

pending_review

No verdict yet: the wait ran out, or you didn't wait. This includes a version held for a human moderator. Check later.

This works the same for a first publish and for an update. When you update a game that's already live, the game itself stays published and keeps serving the previous version while the new one is reviewed; a rejected update only fails that version, and players keep the old one. The SDK reports the new version's verdict, not the game's status, so published really means your update went live.

To check a version yourself later:

const res = await arc.publishVersion(gameId, { files });
if (res.status === "pending_review") {
  const later = await arc.waitForReview(gameId, 120_000, res.versionId);
  console.log(later.status);
}

Or subscribe to the game.approved / game.rejected webhooks, which carry the gameVersionId (see Webhooks).

uploadVersion(gameId, files, options?)

The low-level upload that publishVersion() uses. It doesn't touch media.

uploadVersion(gameId: string, files: ZipEntry[], opts?: UploadOptions):
  Promise<{ status: string; playUrl: string | null; slug?: string; versionId: string }>

Scope: publish.

  1. Throws no_files (status 0) if files is empty.

  2. Reserves a version:

    • without changelog: POST /v1/upload/init with { gameId, filename: "bundle.zip", runtime, realtime, connectHosts?, gamification? }

    • with changelog: POST /v1/games/:id/versions with { runtime, realtime, connectHosts?, changelog, gamification? }

  3. Zips files with buildZip() and PUTs the zip to the returned uploadUrl with Content-Type: application/zip.

  4. POST /v1/upload/complete with the uploadId.

  5. With waitForReview: false, returns { status: "pending_review", playUrl: null, versionId } (no slug). Otherwise calls waitForReview(gameId, timeoutMs ?? 30000, versionId) and returns its result plus versionId.

versionId is the id of the new version (the upload id). runtime defaults to html5. realtime is sent as true only when you pass true. connectHosts and gamification are sent only when given (connectHosts only when non-empty).

changelog and gamification can be combined in one upload. A version uploaded without a gamification block keeps the game's current achievements, so you only need to send the block when you add, change or remove achievements. See XP, achievements & social for the manifest format. The API requires each achievement's description, even though the SDK type marks it optional.

Each call uses one of your hourly uploads (see Errors & limits). The reserve step counts, whichever endpoint it uses.

waitForReview(gameId, timeoutMs = 30000, versionId?)

Polls GET /v1/me/games/:id every 0.8 seconds until review finishes or the timeout passes. It always reads the status at least once, so timeoutMs: 0 is a single check.

waitForReview(gameId: string, timeoutMs?: number, versionId?: string):
  Promise<{ status: string; playUrl: string | null; slug: string }>

Scope: read (or publish).

  • With versionId (what the upload methods pass): watches that version. Its status live returns published, failed returns rejected, and anything else (uploaded, scanning, passed) keeps waiting. Use this for updates.

  • Without versionId: watches the game's own status and returns when it's published or rejected. For a game that's already live this returns published immediately, whatever is happening to a newer version.

Results:

  • published: also fetches GET /v1/games/:slug to return playUrl. The link carries a ticket that expires after 15 minutes, so use it to open the game promptly, not as a permanent link. The permanent public page is https://coolgptgames.com/game/<slug>.

  • rejected: playUrl is null. Call game(gameId) to read the moderation reasons.

  • Timeout: returns { status: "pending_review", playUrl: null, slug }. Review can take longer than 30 seconds, especially when a human moderator is involved. Poll again later, or listen for the game.approved / game.rejected webhooks.

Media: covers, screenshots, loading screens

All media methods take raw image bytes (PNG, JPEG, GIF or WebP, at most 5 MB) and check them before sending. The type is detected from the bytes, not the file name. Scope: publish.

uploadMedia(gameId, kind, image)

uploadMedia(gameId: string, kind: MediaKind, image: ImageInput): Promise<MediaUploadResult>

POST /v1/games/:id/media?kind=<kind> with the image as the request body.

kind

Effect

Returns

cover

Sets the game's cover, replacing and deleting any previous one. Required before a build can be published.

{ kind, key, url }

screenshot

Appends to the game's screenshot gallery. At most 8 per game.

{ kind, key, url, screenshots } (the full gallery, as URLs)

loading

Sets the loading-screen art, replacing any previous one

{ kind, key, url }

Errors: bad_image / too_large (status 0, from the SDK's own check, or 400 from the API), 400 too_many (already 8 screenshots), 404 not_found (no such game, or deleted), 403 forbidden (not your game or no publish scope), 429 rate_limited (media uploads are limited to 60 an hour per IP address).

uploadCover(gameId, image) and uploadScreenshot(gameId, image)

Shortcuts for uploadMedia(gameId, "cover", image) and uploadMedia(gameId, "screenshot", image).

const { url } = await arc.uploadCover(gameId, await readFile("cover-v2.png"));

deleteMedia(gameId, key)

deleteMedia(gameId: string, key: string): Promise<{ ok: boolean; screenshots: string[] }>

DELETE /v1/games/:id/media with { key }. Removes the cover, loading screen or screenshot whose storage key is key (the key from uploadMedia's result). Returns the remaining screenshot gallery as URLs. A key that matches nothing leaves the game unchanged.

Removing the cover means the next build upload fails with cover_required until you upload a new one.

Managing games

Method

Endpoint

Scope

Returns

listGames()

GET /v1/me/games

read

Promise<GameSummary[]>: your games, drafts included (up to 200, most recently updated first)

game(gameId)

GET /v1/me/games/:id

read

Promise<unknown>: the full manage view { game, versions, analytics }: every version with its status, moderation result and review reasons/notes, plus per-day revenue. Shape in REST API overview.

publicGame(slug)

GET /v1/games/:slug

read for your own unpublished games; none for published ones

Promise<unknown>: the public game record, including a ticketed playUrl

createGame(input, opts?)

POST /v1/games

publish

Promise<{ id, slug, status }>

updateGame(gameId, patch)

POST /v1/games/:id

publish

Promise<unknown> ({ ok: true })

unpublishGame(gameId)

DELETE /v1/games/:id

publish

Promise<unknown> ({ ok: true })

createGame(input, opts?)

Creates a draft game with no cover or build. Use it when you want to set up media before the first upload. Otherwise publish() does it for you.

createGame(
  input: {
    title: string;          // 1–120 chars
    description?: string;   // up to 4,000 chars
    category?: string;      // category slug
    instructions?: string;  // up to 4,000 chars
    aiGenerated?: boolean;
    aiDisclosure?: string;  // up to 2,000 chars
  },
  opts?: { idempotencyKey?: string },
): Promise<{ id: string; slug: string; status: string }>

It always sends an Idempotency-Key header: opts.idempotencyKey, or a fresh UUID. When you retry a create yourself, reuse the same key and the API returns the existing game instead of a duplicate. An unknown category slug is ignored (the game gets no category).

updateGame(gameId, patch)

Accepts { title?, description?, instructions?, category? }. Fields you leave out are unchanged, and an unknown category slug keeps the current category. To edit other metadata (tagline, FAQ, websites, embedding), call POST /v1/games/:id directly (see Games & uploads API reference). To change the cover, use uploadCover().

await arc.updateGame(gameId, { description: "Now with co-op!", category: "puzzle" });

unpublishGame(gameId)

Delists and deletes the game. After this the game no longer appears in listGames(), and further calls about it (uploads, media, game()) return 404.

The SDK has no method for rolling back to an earlier version. Use the dashboard or the REST API (see Games & uploads API reference).

Account data

All of these need read (or publish) and return the API response as-is. Response shapes are in REST API overview. Amounts are in US dollars.

Method

Endpoint

What you get

summary()

GET /v1/me/summary

Plays, game counts, open reports, lifetime/30-day/available earnings, pending payouts, payout status and minimum

earnings()

GET /v1/me/earnings

Per-day earnings series with totals (the endpoint's from/to filters aren't exposed)

payouts()

GET /v1/me/payouts

Payout history and unpaid balance

reports(opts?)

GET /v1/me/reports

Promise<{ reports: PlayerReport[]; openCount: number }>

reports() returns the 200 most recent reports across your games, in every status. reports({ status: "open" }) filters that list in the client (the API has no status filter); openCount always counts the open reports among the 200.

Tournaments

Requires the tournaments scope, except getTournament, which is public. See Tournaments for rules, ranking and payouts.

createTournament(input)

POST /v1/dev/tournaments. Creates a tournament on one of your published games.

createTournament(input: {
  gameId?: string;            // one of gameId / gameSlug is required
  gameSlug?: string;
  name: string;               // 1–120 chars
  description?: string;       // up to 1,000 chars
  metric?: "high_score" | "total_score"; // default "high_score"
  prizePoolCoins?: number;    // 0–1,000,000, default 0
  maxWinners?: number;        // 1–50, default 3
  startsAt?: string | Date;   // default: now
  endsAt: string | Date;
}): Promise<Tournament>

Date values are converted to ISO strings for you.

With an API key you can only create free tournaments (prizePoolCoins left out or 0). A prize pool is paid from your coin balance, and that needs a signed-in dashboard session: with a key, a non-zero prizePoolCoins fails with 403 session_required. Create funded tournaments in the dashboard.

Other errors: no_game (neither id nor slug), not_found, forbidden (not your game), not_published, bad_window (endsAt not after startsAt), validation_error (a field out of range), rate_limited (20 per hour).

const t = await arc.createTournament({
  gameSlug: "space-miner-3fa2c1",
  name: "Weekend Sprint",
  endsAt: new Date(Date.now() + 2 * 24 * 3600 * 1000),
});

listTournaments(opts?)

GET /v1/dev/tournaments. Your tournaments (up to 100, newest first) with entries counts, optionally filtered by { gameId } or { gameSlug }. Returns Promise<Tournament[]>. Leave out keys you don't use rather than passing undefined.

getTournament(id)

GET /v1/tournaments/:id (public). Returns Promise<{ tournament: Tournament; standings: TournamentStanding[] }>, with the top 100 standings.

Tournaments start and settle on their own: a background job moves them from scheduled to live and settles them after endsAt. Reading a tournament also settles it if it's due.

cancelTournament(id)

POST /v1/dev/tournaments/:id/cancel. Cancels a scheduled or live tournament and refunds any prize pool to you. Returns Promise<{ ok: boolean; tournament: Tournament }>. Errors: already_final (already settled or canceled), cannot_cancel (it's being settled right now), forbidden (not yours).

Analytics

analytics(gameId)

GET /v1/dev/games/:id/analytics. Requires the analytics scope. Returns Promise<GameAnalytics> covering the last 30 days:

  • events: your top 50 custom event names with counts.

  • errors: the top 20 crash/error messages.

  • daily: valid plays and players per day.

  • returningPlayers: players who played on two or more different days.

See Remote config & analytics.

const a = await arc.analytics(gameId);
console.log(`${a.uniquePlayers} players, ${a.returningPlayers} returning`);
for (const e of a.errors) console.log(`${e.count}× ${e.message}`);

Remote config

Requires the config scope. See Remote config & analytics.

Method

Endpoint

Returns

getConfig(gameId)

GET /v1/dev/games/:id/config

Promise<Record<string, unknown>>: the current config ({} if none)

setConfig(gameId, config)

PUT /v1/dev/games/:id/config

Promise<Record<string, unknown>>: the saved config

setConfig replaces the whole config. It doesn't merge. Read, modify, then write:

const cfg = await arc.getConfig(gameId);
await arc.setConfig(gameId, { ...cfg, dropRate: 0.15 });

The config must be a JSON object under 16 KB (too_large otherwise). Games read it with GameSDK.getConfig(), with no new build or review needed.

Webhooks

Requires the webhooks scope. See Webhooks for event types, payloads and signature verification.

Method

Endpoint

Returns

createWebhook({ url, events? })

POST /v1/dev/webhooks

Promise<Webhook & { secret: string }>. The signing secret is returned only here.

listWebhooks()

GET /v1/dev/webhooks

Promise<Webhook[]>

updateWebhook(id, patch)

PATCH /v1/dev/webhooks/:id

Promise<Webhook>. patch is any of { active?, url?, events? }.

deleteWebhook(id)

DELETE /v1/dev/webhooks/:id

Promise<{ ok: boolean }>

testWebhook(id)

POST /v1/dev/webhooks/:id/test

Promise<{ ok, delivery? }>: sends a webhook.test event to that webhook only, one attempt

listWebhookDeliveries(id)

GET /v1/dev/webhooks/:id/deliveries

Promise<WebhookDelivery[]>: the 25 most recent, each with its attemptLog

redeliverWebhook(id, deliveryId)

POST …/deliveries/:deliveryId/redeliver

Promise<{ ok, delivery }>: same delivery id, so your endpoint can dedupe

url must be https://. Leaving out events, or passing [], subscribes to everything (["*"]). An account can have up to 10 webhooks.

A webhook that fails for three days straight (and at least five deliveries in a row) is switched off automatically; Webhook then carries disabledAt, disabledReason: "failing", consecutiveFailures and failingSince. Turn it back on with updateWebhook(id, { active: true }), which also clears the streak and resumes anything queued.

const hook = await arc.createWebhook({ url: "https://example.com/hooks/coolgptgames", events: ["game.approved", "game.rejected"] });
saveSecretSomewhereSafe(hook.secret);
await arc.testWebhook(hook.id);

// Later: what happened to recent deliveries, and retry one by hand.
const deliveries = await arc.listWebhookDeliveries(hook.id);
const failed = deliveries.find((d) => d.status === "failed");
if (failed) await arc.redeliverWebhook(hook.id, failed.id);

In-game store items

Requires the economy scope. See In-game economy.

Method

Endpoint

Returns

upsertItem(gameId, item)

POST /v1/dev/games/:id/items

Promise<{ ok: boolean; sku: string; kind?: string }>

listItems(gameId)

GET /v1/dev/games/:id/items

Promise<GameItem[]>

deleteItem(gameId, sku)

DELETE /v1/dev/games/:id/items/:sku

Promise<{ ok: boolean }>

listPurchases(gameId, opts?)

GET /v1/dev/games/:id/purchases

Promise<{ purchases: ItemPurchase[]; refundWindowDays: number; nextCursor: string | null }>. opts is { limit?, cursor?, sku? }.

refundPurchase(gameId, purchaseId, reason?)

POST …/purchases/:purchaseId/refund

Promise<{ ok, purchaseId, refundedCoins, unitsRevoked }>

item is { sku, name, description?, priceCoins, active?, kind? }:

  • sku is 1–64 characters: letters, digits and _ . - :.

  • name is up to 80 characters and description up to 300.

  • priceCoins is an integer from 0 to 1,000,000. Players pay in coins, which aren't real money.

  • active defaults to true.

  • kind is "durable" (owned once — skins, unlocks) or "consumable" (bought in quantity and spent — extra lives, hints). It defaults to "durable" and is fixed once any player owns the SKU (kind_locked).

Creating an item whose sku already exists updates it. A game can have up to 200 items (too_many_items); editing an existing item always works.

await arc.upsertItem(gameId, { sku: "skin_gold", name: "Gold Ship", priceCoins: 250 });
await arc.upsertItem(gameId, { sku: "extra_lives", name: "3 Extra Lives", priceCoins: 40, kind: "consumable" });

// Support: find a player's purchase and give their coins back.
const { purchases } = await arc.listPurchases(gameId, { sku: "extra_lives", limit: 25 });
const p = purchases.find((x) => x.playerHandle === "someone" && x.refundable);
if (p) await arc.refundPurchase(gameId, p.id, "bought by mistake");

Refunds return the coins and revoke the item (or the units). Creators can refund within 30 days of the purchase; after that only staff can.

buildZip(entries)

The zip builder used by uploadVersion, exported in case you want the bytes yourself:

import { buildZip } from "@coolgptgames/sdk";
const zip: Buffer = buildZip([{ path: "index.html", content: "<!doctype html><title>Hi</title>" }]);

It stores files uncompressed, which is fine for the platform (bundle limits apply to the zip size). Paths must use /, must be relative, and must not contain ... The upload validator rejects anything else (see Errors & limits).

Method index

Method

HTTP

Scope

listGames()

GET /v1/me/games

read

game(gameId)

GET /v1/me/games/:id

read

publicGame(slug)

GET /v1/games/:slug

none for published games; read for your unpublished ones

summary()

GET /v1/me/summary

read

earnings()

GET /v1/me/earnings

read

payouts()

GET /v1/me/payouts

read

reports(opts?)

GET /v1/me/reports

read

createGame(input, opts?)

POST /v1/games

publish

updateGame(gameId, patch)

POST /v1/games/:id

publish

unpublishGame(gameId)

DELETE /v1/games/:id

publish

uploadMedia(gameId, kind, image)

POST /v1/games/:id/media?kind=…

publish

uploadCover(gameId, image)

POST /v1/games/:id/media?kind=cover

publish

uploadScreenshot(gameId, image)

POST /v1/games/:id/media?kind=screenshot

publish

deleteMedia(gameId, key)

DELETE /v1/games/:id/media

publish

uploadVersion(gameId, files, opts?)

POST /v1/upload/init or POST /v1/games/:id/versions, PUT <uploadUrl>, POST /v1/upload/complete, then polling

publish

waitForReview(gameId, timeoutMs?)

GET /v1/me/games/:id, GET /v1/games/:slug

read

publishVersion(gameId, input)

Media uploads + uploadVersion

publish

publish(input)

createGame + publishVersion

publish

createTournament(input)

POST /v1/dev/tournaments

tournaments (plus a signed-in session for a prize pool)

listTournaments(opts?)

GET /v1/dev/tournaments

tournaments

getTournament(id)

GET /v1/tournaments/:id

Public

cancelTournament(id)

POST /v1/dev/tournaments/:id/cancel

tournaments

analytics(gameId)

GET /v1/dev/games/:id/analytics

analytics

getConfig(gameId)

GET /v1/dev/games/:id/config

config

setConfig(gameId, config)

PUT /v1/dev/games/:id/config

config

createWebhook(input)

POST /v1/dev/webhooks

webhooks

listWebhooks()

GET /v1/dev/webhooks

webhooks

deleteWebhook(id)

DELETE /v1/dev/webhooks/:id

webhooks

testWebhook(id)

POST /v1/dev/webhooks/:id/test

webhooks

upsertItem(gameId, item)

POST /v1/dev/games/:id/items

economy

listPurchases(gameId, opts?)

GET /v1/dev/games/:id/purchases

economy

refundPurchase(gameId, purchaseId, reason?)

POST /v1/dev/games/:id/purchases/:id/refund

economy

updateWebhook(id, patch)

PATCH /v1/dev/webhooks/:id

webhooks

listWebhookDeliveries(id)

GET /v1/dev/webhooks/:id/deliveries

webhooks

redeliverWebhook(id, deliveryId)

POST /v1/dev/webhooks/:id/deliveries/:id/redeliver

webhooks

listItems(gameId)

GET /v1/dev/games/:id/items

economy

deleteItem(gameId, sku)

DELETE /v1/dev/games/:id/items/:sku

economy

read is also satisfied by publish, and * satisfies every scope. See API keys & scopes.

See also

Updated