---
title: "REST API reference"
description: "The SDK and CLI are thin clients over this HTTP API. Use it directly from engines without a JavaScript SDK, from your own server, or from tools."
url: "/docs/reference/rest-api/"
lang: en
index: "/llms.txt"
---
# REST API reference

The SDK and CLI are thin clients over this HTTP API. Use it directly from engines without a JavaScript SDK, from
your own server, or from tools. Base URL: your API (`https://api.kumodeck.com`). All bodies are JSON.

## Authentication

| Caller | Headers |
|---|---|
| Player (a signed-in user of your app or game) | `X-Kumo-Key: pk_…` + `Authorization: Bearer <access token>` |
| Public app data | `X-Kumo-Key: pk_…` only |
| Server / CLI / CI | `X-Kumo-Key: sk_…` |
| Developer (dashboard) | `Authorization: Bearer kds_…` |

Errors always look like:

```json
{ "error": { "code": "version_conflict", "message": "…", "details": { "currentVersion": 7 } } }
```

In the API, the people who use your app are called *players* (`/v1/players/…`, `playerId`); this page keeps that name.

**Any origin.** The SDK works from an app or game hosted anywhere (KUMODeck hosting, your own server, itch.io, localhost): CORS is open
and auth is header-based. To stop other sites from using your publishable key, list your origins in
`kumo.config.json` → `web.allowedOrigins` (see [config](/docs/reference/config/index.md)); browser requests from other origins then get
403 `origin_not_allowed`. Every module works on its own: use only saves, only multiplayer, only hosting, and so on.

**Features are off until you turn them on** (`features` in [config](/docs/reference/config/index.md#features)). Calling a feature that is off
from your app or a secret key returns 403 `feature_disabled` with `details: { feature, configPath }` (e.g. `features.saves`), before
anything is read or written. Guest sign-in, token refresh, your own player profile and reading the config always work.
A feature that is not offered right now answers 403 `feature_unavailable`, and it cannot be turned on.

Resources you do not own return **404**, not 403, so their existence is not revealed. An expired access token returns
401 `token_expired` — refresh and retry. Endpoints that change balances accept an `Idempotency-Key` header
(1–128 chars of `A-Z a-z 0-9 _ . : -`); a retry with the same key returns the first result without applying twice.

## Players & auth

| Method | Path | Body → Response |
|---|---|---|
| POST | `/v1/auth/guest` | `{ displayName? }` → session |
| POST | `/v1/auth/email/signup` | `{ email, password (≥ 8), displayName? }` → session |
| POST | `/v1/auth/email/login` | `{ email, password }` → session |
| POST | `/v1/auth/refresh` | `{ refreshToken }` → session (rotated). 401 = chain revoked, 409 `refresh_race` = another tab just rotated it |
| POST | `/v1/auth/logout` | `{ refreshToken }` |
| POST | `/v1/auth/link/email` | `{ email, password }` → `{ player }` (guest keeps all data) |
| GET / PATCH | `/v1/players/me` | → `{ player }` / `{ displayName }` |

A **session** is `{ player: { id, displayName, isGuest, createdAt, identities }, accessToken, expiresIn, refreshToken }`.
Access tokens last 15 minutes, refresh tokens 90 days (rotating).

Email verification, password reset, external sign-in and privacy:

| Method | Path | Body → Response |
|---|---|---|
| POST | `/v1/auth/email/verify` | `{ token }` — from the verification link |
| POST | `/v1/auth/email/resend` | `{ redirectUrl? }` (player) |
| POST | `/v1/auth/password/forgot` | `{ email, redirectUrl? }` — always 200 |
| POST | `/v1/auth/password/reset` | `{ token, password }` — revokes every refresh token of the player |
| POST / GET | `/v1/auth/oauth/:provider/start` | `:provider` = `google` \| `discord` \| `apple` \| `x`. `{ redirectUrl, challenge, mode? }` → `{ url }`. With a player `Authorization` header the provider is **linked** to that player. GET (with `key=pk_…` in the query) redirects, for plain links |
| GET / POST | `/v1/auth/oauth/:provider/callback` | the provider returns here; then redirects to `<redirectUrl>#kumo_code=…` (or `#kumo_error=…`) |
| POST | `/v1/auth/oauth/exchange` | `{ code, verifier }` → session (the code lasts 60 s, once) |
| GET | `/v1/players/me/identities` | → `{ identities }` (sign-in methods; guest not included) |
| DELETE | `/v1/players/me/identities/:provider` | `{ refreshToken }` (this device's) → `{ player }`. Signs out every other device. 409 `last_login_method` = it is the last way to sign in (add another first) |
| GET | `/v1/players/me/export` | everything stored about the player (JSON) |
| DELETE | `/v1/players/me` | `{ password }` or `{ refreshToken }` — irreversible |

`redirectUrl` must be allowed by `auth.redirectUrls` in your config (localhost in development and your KUMODeck-hosted URLs
always are). A provider answers 404 `provider_disabled` until enabled in `auth.providers`, and 409
`provider_not_configured` while its credentials are missing (Google, Discord, Apple). See [Authentication](/docs/guides/auth/index.md).

## Game data (player)

| Method | Path | Notes |
|---|---|---|
| GET | `/v1/gamedata/definitions` | the public part of your config. `pk_` only |
| GET | `/v1/saves` | `{ saves: [{ key, version, size, updatedAt }] }` |
| GET | `/v1/saves/:key` | `{ key, version, size, updatedAt, data }` · 404 if missing |
| PUT | `/v1/saves/:key` | `{ data, ifVersion? }` · 409 `version_conflict`, 413 `save_too_large`, 409 `too_many_saves` |
| DELETE | `/v1/saves/:key?ifVersion=` | |
| GET | `/v1/stats` | `{ stats: { key: value } }` (all-time) |
| POST | `/v1/stats` | `{ stats: { key: value } }` (≤ 64 keys) → `{ stats: { key: { period: value } }, rejected, unlocked }` |

## Multiplayer

| Method | Path | Notes |
|---|---|---|
| GET (WebSocket) | `/v1/realtime` | first frame `{ "t": "auth", "key": "pk_…", "token": "<access token>" }` within 5 s |
| GET | `/v1/rooms?mode=` | open public rooms `{ rooms: [{ id, code, mode, players, maxPlayers, metadata, createdAt }] }` |

WebSocket requests carry an optional `rid` and get `{ t: "reply", rid, ok, data | error }`:

| `t` | Fields |
|---|---|
| `create` | `mode, private?, maxPlayers?, metadata?, code?, hostOnlyState?` |
| `join` | `roomId` or `code` |
| `quickMatch` / `cancelMatch` | `mode` / — |
| `leave`, `resume` | —, `roomId` |
| `send` | `type (≤ 64), data? (≤ 16 KB), to?: playerId[]` |
| `setState` / `setMyState` | `patch` (null deletes a key) |
| `lock` | `locked` (host only) |
| `signal` | peer-to-peer rooms only: `to: playerId, kind: offer \| answer \| candidate \| restart, data? (≤ 8 KB), gen?` — relayed to that one player |
| `iceServers` | peer-to-peer rooms only → `{ iceServers, iceTransportPolicy: 'relay' \| 'all', expiresAt }` · `turn_unavailable` |

Server events: `welcome`, `room_joined` (snapshot, always before the reply), `player_joined`, `player_left`,
`player_disconnected`, `player_reconnected`, `host_changed`, `state`, `player_state`, `message`, `locked`,
`signal` (peer-to-peer rooms), `room_closed`, `warn`. Close codes: 4001 unauthorized, 4002 token expired (refresh and reconnect), 4003 banned /
forbidden, 4004 replaced by a newer connection, 4008 idle, 4029 rate-limit abuse.

## Realtime channels

Off until `features.realtimeChannels` is on ([guide](/docs/guides/realtime/index.md)). Over the same `/v1/realtime` WebSocket
(one connection: one multiplayer room plus up to 8 channels). Nothing is stored: messages are delivered and dropped.
A connection without a player id sends `{ "t": "auth", "key": "pk_…", "anon": true }` and may join only channels your
server made `open`.

| `t` | Fields | Errors |
|---|---|---|
| `channelJoin` | `channel` | `channel_not_found`, `channel_forbidden`, `channel_banned`, `channel_full`, `player_required`, `too_many_channels` |
| `channelCreate` | `channel?` (`@…`, made for you when left out), `join?`, `send?`, `maxMembers?`, `voice?` (`true` or `{ mode: 'sfu' }`) | `channel_exists`, `player_required`, `voice_too_large`, `feature_disabled` (`features.voice`) |
| `channelLeave` | `channel` | — |
| `channelSend` | `channel, type (≤ 64), data?` | `not_in_channel`, `muted`, `send_forbidden`, `payload_too_large`, `rate_limited` |
| `channelModerate` | `channel, op, playerId, seconds?` — `op`: `invite`, `uninvite`, `allowSend`, `disallowSend`, `mute`, `unmute`, `kick`, `ban`, `unban` | `owner_only` |
| `voiceJoin` / `voiceLeave` | `channel` — [voice calls](/docs/guides/voice/index.md); the SDK sends these and the call's other messages (`voiceSignal`; `voicePublish`, `voiceAnswer`, `voiceSpeaking` in a relay-server call) for you | `voice_disabled`, `voice_suspended`, `voice_unavailable`, `feature_disabled`, `player_required`, `not_in_channel`, `rate_limited` |

Server events: `channel_joined` (settings and members, always before the reply), `channel_message`
(`from` = player id or `'server'`, `at` = server time), `channel_player_joined`, `channel_player_left`,
`channel_self` (`canSend`, `mutedUntil`), `channel_closed` (`reason`). Voice: `voice_state` (`members: [{ id, canSpeak }]`), `voice_signal`, `voice_sfu_offer` (relay-server calls), `voice_closed` (`time_limit`, `suspended`, `unavailable`). `channel_joined` carries `voice: { mode: 'p2p' | 'sfu', relayOnly } | null`.

Your server (secret key only; not available to delegated tokens):

| Method | Path | Notes |
|---|---|---|
| PUT | `/v1/realtime/channels/:name` | `@` names only: `{ join?, send?, open?, maxMembers?, allow?: playerId[], speakers?: playerId[], voice?: true \| { mode?: 'p2p' \| 'sfu', relayOnly? } }` → settings, lists, members · 409 `voice_in_use` |
| GET | `/v1/realtime/channels/:name` | the same, never message text · 404 `channel_not_found` |
| DELETE | `/v1/realtime/channels/:name` | `{ deleted: true }` (members get `channel_closed` `deleted`) |
| POST | `/v1/realtime/channels/:name/messages` | `{ type, data? }` → `{ delivered }` (arrives with `from: 'server'`) · 600 / minute per key |
| POST | `/v1/realtime/channels/:name/moderate` | `{ op, playerId, seconds? }` → `{}` · 300 / minute per key (with PUT / DELETE) |

## Your own iOS / Android app (player)

| Method | Path | Notes |
|---|---|---|
| POST | `/v1/auth/oauth/:provider/native` | `apple` / `google`: `{ idToken, nonce, platform, link?, name? }` → session + `{ provider, linked }` |
| POST | `/v1/players/me/age-signal` | OS age signal (`{ platform, status, lowerBound?, upperBound?, source?, supervised? }`); only ever makes the age group stricter |

## Age group (player)

| Method | Path | Notes |
|---|---|---|
| GET | `/v1/social/profile` | `{ ageBand, declaredAgeBand, capabilities }` |
| PUT | `/v1/social/age` | `{ band: child \| teen \| adult }` (players can only make it stricter: 403 `age_change_not_allowed`) |

## Ban appeals (player)

A player you banned can see the ban and appeal it once. You read and answer appeals as the developer (below); accepting
an appeal lifts the ban. Appeals are part of banning, so they are always available.

| Method | Path | Notes |
|---|---|---|
| GET | `/v1/players/me/sanctions` | the player's own bans (public note only) |
| POST | `/v1/players/me/sanctions/:sanctionId/appeal` | `{ message }` → 201 · one appeal per ban |
| POST | `/v1/auth/appeal` | `{ refreshToken, sanctionId?, message }` — for banned players (the refresh token revoked by the ban proves identity; without sanctionId, the ban that revoked the token) |

`POST /v1/stats` also runs your integrity rules (`integrity.stats` in config: value ranges, submissions per minute, an
optional signed webhook to your server). Rejected values come back in `rejected` with a reason.

## Sharing on X (player / public)

Every tool here is **off until you turn it on** in `kumo.config.json` → `share` (see [config](/docs/reference/config/index.md#share)
and the [guide](/docs/guides/sharing/index.md)). A tool that is off answers 404 `share_<tool>_disabled`; the SDK treats that as "not used".

| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | `/v1/share/links` | player | `{ kind: plain \| score \| challenge, score?, showName? }` → 201 `{ link, imageUrl, post: { text, hashtags, via } }`. |
| GET | `/v1/share/links/:shareId` | `pk_` | `{ link: { id, kind, score, displayName, createdAt } }` — restore a challenge on the receiving side |
| POST | `/v1/share/visits` | `pk_` (+ player if signed in) | `{ shareId, landed }` — counted once per player per day (`share.tracking`) |
| GET | `/v1/share/images/:label/card.png` · `…/:shareId.png` | none | 1200×600 PNG card (`share.images`). `:label` = your slug, or `<slug>--dev` for development. Cached; unknown ids are 404 |
| GET | `/v1/share/tags?ks=` | `pk_` or `sk_` | `{ html, tags, image, defaultImage, shareImageTemplate }` — `<meta>` tags for your page's `<head>` (read it on each request for a card per share) |
| GET | `/v1/players/me/x-profile` · PUT `{ visible }` | player | the player's X handle / name / avatar and whether they allow showing it (default: not shown) |
| GET | `/v1/players/x-profiles?ids=a,b` | `pk_` | `{ profiles: { [playerId]: { username, name, avatarUrl } } }` — only players who opted in (max 100 ids) |
| POST | `/v1/auth/transfer` | player | → 201 `{ code, expiresIn: 600 }` — one-time code to continue as the same player in another browser (`share.inAppBrowser`) |
| POST | `/v1/auth/transfer/redeem` | `pk_` | `{ code }` → session |

KUMODeck-hosted apps also serve the same card images from the app's own domain: `/.card.png` and `/.card/<shareId>.png`.
Sign in with X uses the normal OAuth routes with `:provider` = `x` (see [Authentication](/docs/guides/auth/index.md)). It needs your own X app's
credentials in the dashboard; without them `POST /v1/auth/oauth/x/start` answers 409 `provider_not_configured`. Sign-in is free
(X does not charge for it), so today it is never refused for billing. If X ever starts charging, apps using KUMODeck's
shared X app would get 402 `x_signin_unavailable` (`details.continueAsGuest: true`) while the balance is overdue — keep the
player on their guest account in that case.

## Server (secret key)

| Method | Path | Notes |
|---|---|---|
| GET / PUT | `/v1/config` | read / push master data for the key's environment |
| GET | `/v1/admin/features` | every feature with `{ key, title, configPath, source, enabled }` (also at `/v1/projects/:projectId/environments/:env/features`) |
| POST | `/v1/deployments` | `{ files: [{ path, sha256, size }], message? }` → `{ deploymentId, version, missing, missingBytes }` |
| PUT | `/v1/blobs/:sha256` | raw bytes of one missing file (≤ 50 MB) |
| POST | `/v1/deployments/:ref/finalize` | `{ activate?: true }` · 409 `blobs_missing` |
| POST | `/v1/deployments/:ref/activate` | switch the live version (rollback / promote) |
| GET | `/v1/deployments?limit=` · `/v1/deployments/:ref` · `/v1/hosting` | history, one version (with manifest), live URLs |

`:ref` is a deployment id or a version number.

## Functions (secret key or dashboard)

Your own server code and database ([guide](/docs/guides/functions/index.md)). **Off by default.** Each path is available at
`/v1/admin/<path>` with `X-Kumo-Key: sk_…` and at `/v1/projects/:projectId/environments/:env/<path>` with a developer session.

| Method | `<path>` | Notes |
|---|---|---|
| GET | `functions` | `{ enabled, suspended, limits, deployed, version, url, crons, durableObjects, resources, secrets }` (secret names only) |
| POST | `functions/enable` · `functions/disable` | enable: `{ cpuMs?, subRequests? }` · 402 `prepaid_required` without prepaid credit · disable keeps code and data |
| PUT | `functions/limits` | `{ cpuMs? (1–30000), subRequests? (0–1000) }` |
| POST | `functions/deployments` | `{ mainModule, modules: [{ name, type, content (base64) }], bindings, vars, crons, compatibilityDate?, message? }` → 201 `{ version, … }`. Usually sent by `kumodeck functions deploy` |
| GET | `functions/deployments?limit=` | history, newest first |
| DELETE | `functions?purge=true` | remove the code; `purge` also deletes databases, KV, files and queues |
| GET / PUT / DELETE | `functions/secrets` · `functions/secrets/:name` | values go straight to the runtime and are never stored or returned |
| POST | `functions/db/:binding/query` · `functions/db/:binding/migrate` | `{ sql, params? }` · `{ migrations: [{ name, sql }] }` → `{ applied, failed, skipped }` |

From your Functions code (secret key only): `POST /v1/admin/players/verify-token` `{ token }` → `{ player: { id, displayName, banned } }`
(401 for a token from another environment). To ban from your code, `POST /v1/admin/players/:playerId/ban` · `/unban`
(see [Operating your game](#operating-your-game-secret-key-or-dashboard)).

## Developer (dashboard session)

| Method | Path | |
|---|---|---|
| POST | `/v1/developers/signup` · `/login` · `/logout` · GET `/v1/developers/me` | account (`me` includes `hasPassword`). `signup` takes an optional `inviteCode` (an invalid code creates no account: 400 `invalid_invite_code` / 409 `invite_code_used` / `invite_limit_reached`) |
| POST | `/v1/developers/me/invite-code` | `{ code }` → `{ credited: 500, currency: "usd", pending }` — invite credit (usage fees only; it cannot be refunded or withdrawn), added once the email is confirmed (`pending: true` until then). Developer session only (not MCP). 409 `already_redeemed` (one per account) |
| POST | `/v1/developers/oauth/:provider/start` · `/v1/developers/oauth/exchange` | `exchange` takes an optional `inviteCode` for a new account (an existing account gets `invite: { ignored: true }`). Google / GitHub sign-in, adding a method (`intent: "link"`) and re-confirming (`intent: "reauth"`), with PKCE. Providers the server has turned on: `oauthProviders` in `GET /v1/platform/info` |
| GET / DELETE / POST | `/v1/developers/me/identities` · `/me/identities/:provider` · `/me/password` | sign-in methods: list · remove (needs `password` or `reauthToken`; the last one cannot be removed) · set or change the password |
| GET / POST | `/v1/projects` | list / create (returns every key in plain text **once**) |
| GET / DELETE | `/v1/projects/:projectId` | |
| PATCH | `/v1/projects/:projectId` | `{ name }` (1–80 characters) → `{ project, previousName, changed }`. Renames the project (its name is in the subject and sender of emails to users); the slug does not change. 400 `name_reserved` for names that look like KUMODeck or its staff (also on create); 429 after 20 renames an hour |
| POST | `/v1/projects/:projectId/environments/:env/keys` | new key · DELETE `/v1/projects/:projectId/keys/:keyId` revokes |
| GET / PUT | `/v1/projects/:projectId/environments/:env/config` | master data |
| GET | `…/environments/:env/overview` | players, daily actives (14 days), config version, keys |
| GET | `…/environments/:env/players?q=&cursor=&limit=` · `…/players/:playerId` | search / detail |
| POST | `…/environments/:env/players/:playerId/ban` · `/unban` | ban closes live connections immediately |
| GET | `…/environments/:env/realtime` | read-only view of live rooms |
| GET | `/v1/projects/:projectId/audit?limit=&before=&env=` | audit log |

Hosting endpoints are also available to developers under `/v1/projects/:projectId/environments/:env/…`.

## Money (developer session)

Your prepaid balance and usage. Developer session only (`Authorization: Bearer kds_…`); secret keys cannot read or
move money. Amounts are integers in minor units (cents).

| Method | Path | Notes |
|---|---|---|
| GET | `/v1/money/prepaid` · `/v1/money/prepaid/ledger` | `{ balance, owed, invite }` · entries. `invite`: `{ granted, remaining, pending }` or `null` (`remaining` is part of `balance`) |
| POST | `/v1/money/prepaid/checkout` | `{ amount ($5–$10,000), successUrl, cancelUrl }` + `Idempotency-Key` → Stripe Checkout (3D Secure); GET `/v1/money/prepaid/checkouts/:id` to poll |
| GET | `/v1/money/usage?period=YYYY-MM&refresh=` | usage cost by component (micro-USD) and what was charged (cents) |

## Operating your game (secret key or dashboard)

Each of these is available at `/v1/admin/<path>` with `X-Kumo-Key: sk_…` (your server, CLI, MCP) and at
`/v1/projects/:projectId/environments/:env/<path>` with a developer session. Changes are written to the audit log.

| Method | `<path>` | Notes |
|---|---|---|
| PUT | `players/:playerId/age` | `{ band }` — correct a player's age group (e.g. after a parent's confirmation) |
| POST | `players/:playerId/ban` · `players/:playerId/unban` | `{ reason? (≤ 500), durationHours? (> 0, ≤ 8784; secret key only, leave out = until unban) }` → `{ player: { id, banned, bannedAt, banReason, revokedSessions, banExpiresAt } }`. Closes live connections at once; 404 `player_not_found`. Always available (no feature switch); a ban set anywhere can be lifted from anywhere |
| GET | `appeals?status=` · POST `appeals/:appealId/resolve` | ban appeals from your players · `{ accept, response? }` — accepting lifts the ban. Always available |
| GET | `integrity/webhook-secret` | the key your stats webhook verifies |
| GET | `share/stats?from=&to=&shareId=&limit=` | visits, new players, plays per share link (UTC days, default last 30 days, max 366) |
| POST | `share/links` | `{ label }` → 201 `{ link, url, query }` — a tracked link for your own post |
| GET | `share/tags?ks=` | same as `/v1/share/tags` (used by `kumodeck share tags`) |
