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:
{ "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); 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). 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.
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). 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; 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
and the guide). 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). 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). 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).
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) |