Publisher SDK
@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/sdkRequirements: 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 |
|---|---|---|---|---|
|
| Yes | n/a | The constructor throws |
|
| No |
| API origin. Trailing |
|
| No | global | 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:
The
baseUrloption, if set.The
ARCADEY_API_URLenvironment variable, if set and non-empty.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 |
|---|---|
| Your game list, a game's manage view, summary, earnings, payouts, player reports, and |
| Creating, updating and unpublishing games, media uploads and deletes, and bundle uploads. A |
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|---|---|---|---|
API returned a non- | HTTP status | The API's | The API's |
API returned a non- | HTTP status | none |
|
API returned | HTTP status |
|
|
The bundle | HTTP status | none |
|
Checks the SDK makes before any request |
| See below | A description of the problem |
SDK-side codes (always status: 0):
| Thrown by | When |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
| The image is empty, not a |
| same | The image is over 5 MB ( |
Things to know:
Network failures (DNS, connection refused, TLS) reject with the underlying
fetcherror, notArcadeyError. The exception ispublish(), which wraps any failure after the game was created in anArcadeyErrorcarryinggameId(withstatus: 0if the original wasn't anArcadeyError).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 |
|---|---|---|
|
| The production API origin |
|
| The per-image limit for covers, screenshots and loading screens. Matches the API's limit. |
|
| Sniffs the type from the file's first bytes, with the same rules as the API. Returns |
|
| See |
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:
Checks before any request. Throws
ArcadeyError(status0) iffilesis empty (no_files),coveris missing (cover_required), or the cover or any screenshot isn't a valid image (bad_image,too_large). Nothing is created when these fail.Create the game:
POST /v1/gameswith anIdempotency-Keyheader (youridempotencyKey, or a fresh UUID).Cover:
POST /v1/games/:id/media?kind=cover.Screenshots:
POST /v1/games/:id/media?kind=screenshotfor each, in order.Bundle: reserve a version,
PUTthe zip to the returned upload URL, thenPOST /v1/upload/complete(seeuploadVersion).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.
Throws
no_game,no_files,bad_imageortoo_large(status0) before any request.If
coveris given, uploads it (replacing the current cover). Otherwise the existing cover stays.Uploads each of
screenshots, if any.Uploads the bundle with
uploadVersion()and waits withtimeoutMs(default 30,000 ms).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:
| Cause |
|---|---|
| No such game, or the game was deleted |
| Not your game, or the key lacks |
| The game was delisted or removed after a copyright notice and can't take new uploads |
| Your hourly upload limit is used up |
| 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():
| Meaning |
|---|---|
| The uploaded version was approved and is live. |
| The uploaded version was rejected. Read the reasons with |
| 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.
Throws
no_files(status0) iffilesis empty.Reserves a version:
without
changelog:POST /v1/upload/initwith{ gameId, filename: "bundle.zip", runtime, realtime, connectHosts?, gamification? }with
changelog:POST /v1/games/:id/versionswith{ runtime, realtime, connectHosts?, changelog, gamification? }
Zips
fileswithbuildZip()andPUTs the zip to the returneduploadUrlwithContent-Type: application/zip.POST /v1/upload/completewith theuploadId.With
waitForReview: false, returns{ status: "pending_review", playUrl: null, versionId }(noslug). Otherwise callswaitForReview(gameId, timeoutMs ?? 30000, versionId)and returns its result plusversionId.
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 statuslivereturnspublished,failedreturnsrejected, and anything else (uploaded,scanning,passed) keeps waiting. Use this for updates.Without
versionId: watches the game's own status and returns when it'spublishedorrejected. For a game that's already live this returnspublishedimmediately, whatever is happening to a newer version.
Results:
published: also fetchesGET /v1/games/:slugto returnplayUrl. 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 ishttps://coolgptgames.com/game/<slug>.rejected:playUrlisnull. Callgame(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 thegame.approved/game.rejectedwebhooks.
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.
| Effect | Returns |
|---|---|---|
| Sets the game's cover, replacing and deleting any previous one. Required before a build can be published. |
|
| Appends to the game's screenshot gallery. At most 8 per game. |
|
| Sets the loading-screen art, replacing any previous one |
|
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 |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|---|
|
| Plays, game counts, open reports, lifetime/30-day/available earnings, pending payouts, payout status and minimum |
|
| Per-day earnings series with totals (the endpoint's |
|
| Payout history and unpaid balance |
|
|
|
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 |
|---|---|---|
|
|
|
|
|
|
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
item is { sku, name, description?, priceCoins, active?, kind? }:
skuis 1–64 characters: letters, digits and_ . - :.nameis up to 80 characters anddescriptionup to 300.priceCoinsis an integer from 0 to 1,000,000. Players pay in coins, which aren't real money.activedefaults totrue.kindis"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 |
|---|---|---|
|
|
|
|
|
|
|
| none for published games; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| Media uploads + |
|
|
|
|
|
|
|
|
|
|
|
| Public |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
read is also satisfied by publish, and * satisfies every scope. See API keys & scopes.
See also
REST API overview: response shapes and the endpoint index - API keys & scopes, Errors & limits
CLI and MCP server: built on this SDK - Packaging & publishing your game: what goes in a bundle - Webhooks: learn about review decisions without polling