KUMODeck
日本語

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#

CallerHeaders
Player (a signed-in user of your app or game)X-Kumo-Key: pk_… + Authorization: Bearer <access token>
Public app dataX-Kumo-Key: pk_… only
Server / CLI / CIX-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#

MethodPathBody → 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:

MethodPathBody → 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/callbackthe 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/exporteverything 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)#

MethodPathNotes
GET/v1/gamedata/definitionsthe 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#

MethodPathNotes
GET (WebSocket)/v1/realtimefirst 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 }:

tFields
createmode, private?, maxPlayers?, metadata?, code?, hostOnlyState?
joinroomId or code
quickMatch / cancelMatchmode / —
leave, resume—, roomId
sendtype (≤ 64), data? (≤ 16 KB), to?: playerId[]
setState / setMyStatepatch (null deletes a key)
locklocked (host only)
signalpeer-to-peer rooms only: to: playerId, kind: offer | answer | candidate | restart, data? (≤ 8 KB), gen? — relayed to that one player
iceServerspeer-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.

tFieldsErrors
channelJoinchannelchannel_not_found, channel_forbidden, channel_banned, channel_full, player_required, too_many_channels
channelCreatechannel? (@…, 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)
channelLeavechannel—
channelSendchannel, type (≤ 64), data?not_in_channel, muted, send_forbidden, payload_too_large, rate_limited
channelModeratechannel, op, playerId, seconds? — op: invite, uninvite, allowSend, disallowSend, mute, unmute, kick, ban, unbanowner_only
voiceJoin / voiceLeavechannel — voice calls; the SDK sends these and the call's other messages (voiceSignal; voicePublish, voiceAnswer, voiceSpeaking in a relay-server call) for youvoice_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):

MethodPathNotes
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/:namethe 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)#

MethodPathNotes
POST/v1/auth/oauth/:provider/nativeapple / google: { idToken, nonce, platform, link?, name? } → session + { provider, linked }
POST/v1/players/me/age-signalOS age signal ({ platform, status, lowerBound?, upperBound?, source?, supervised? }); only ever makes the age group stricter

Age group (player)#

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

MethodPathNotes
GET/v1/players/me/sanctionsthe 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".

MethodPathAuthNotes
POST/v1/share/linksplayer{ kind: plain | score | challenge, score?, showName? } → 201 { link, imageUrl, post: { text, hashtags, via } }.
GET/v1/share/links/:shareIdpk_{ link: { id, kind, score, displayName, createdAt } } — restore a challenge on the receiving side
POST/v1/share/visitspk_ (+ player if signed in){ shareId, landed } — counted once per player per day (share.tracking)
GET/v1/share/images/:label/card.png · …/:shareId.pngnone1200×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 }playerthe player's X handle / name / avatar and whether they allow showing it (default: not shown)
GET/v1/players/x-profiles?ids=a,bpk_{ profiles: { [playerId]: { username, name, avatarUrl } } } — only players who opted in (max 100 ids)
POST/v1/auth/transferplayer→ 201 { code, expiresIn: 600 } — one-time code to continue as the same player in another browser (share.inAppBrowser)
POST/v1/auth/transfer/redeempk_{ 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)#

MethodPathNotes
GET / PUT/v1/configread / push master data for the key's environment
GET/v1/admin/featuresevery 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/:sha256raw bytes of one missing file (≤ 50 MB)
POST/v1/deployments/:ref/finalize{ activate?: true } · 409 blobs_missing
POST/v1/deployments/:ref/activateswitch the live version (rollback / promote)
GET/v1/deployments?limit= · /v1/deployments/:ref · /v1/hostinghistory, 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
GETfunctions{ enabled, suspended, limits, deployed, version, url, crons, durableObjects, resources, secrets } (secret names only)
POSTfunctions/enable · functions/disableenable: { cpuMs?, subRequests? } · 402 prepaid_required without prepaid credit · disable keeps code and data
PUTfunctions/limits{ cpuMs? (1–30000), subRequests? (0–1000) }
POSTfunctions/deployments{ mainModule, modules: [{ name, type, content (base64) }], bindings, vars, crons, compatibilityDate?, message? } → 201 { version, … }. Usually sent by kumodeck functions deploy
GETfunctions/deployments?limit=history, newest first
DELETEfunctions?purge=trueremove the code; purge also deletes databases, KV, files and queues
GET / PUT / DELETEfunctions/secrets · functions/secrets/:namevalues go straight to the runtime and are never stored or returned
POSTfunctions/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)#

MethodPath
POST/v1/developers/signup · /login · /logout · GET /v1/developers/meaccount (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/exchangeexchange 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/passwordsign-in methods: list · remove (needs password or reauthToken; the last one cannot be removed) · set or change the password
GET / POST/v1/projectslist / 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/keysnew key · DELETE /v1/projects/:projectId/keys/:keyId revokes
GET / PUT/v1/projects/:projectId/environments/:env/configmaster data
GET…/environments/:env/overviewplayers, daily actives (14 days), config version, keys
GET…/environments/:env/players?q=&cursor=&limit= · …/players/:playerIdsearch / detail
POST…/environments/:env/players/:playerId/ban · /unbanban closes live connections immediately
GET…/environments/:env/realtimeread-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).

MethodPathNotes
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
PUTplayers/:playerId/age{ band } — correct a player's age group (e.g. after a parent's confirmation)
POSTplayers/: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
GETappeals?status= · POST appeals/:appealId/resolveban appeals from your players · { accept, response? } — accepting lifts the ban. Always available
GETintegrity/webhook-secretthe key your stats webhook verifies
GETshare/stats?from=&to=&shareId=&limit=visits, new players, plays per share link (UTC days, default last 30 days, max 366)
POSTshare/links{ label } → 201 { link, url, query } — a tracked link for your own post
GETshare/tags?ks=same as /v1/share/tags (used by kumodeck share tags)