Moud

Studios, keys and webhooks#

A game can belong to one person or to a studio. Creator keys let a script or CI act for you without your password, and webhooks tell another service when something happens to your game.

Studios#

Open Studios in the account menu and make one. You are its first owner. Invite people by their forum handle; they accept the invite on their own Studios page. Every member has one role:

Role Can
Owner everything, including inviting, changing roles and moving games in and out
Developer publish versions, change settings, badges, saved data, analytics, webhooks and the server console
Analytics read analytics and crash reports, nothing else
Moderator bans and player reports

A studio always keeps at least one owner. To move a game in, open the studio as an owner and pick it under Games; only games you hold on your own show up. Take it out to me hands a game back to you as its only owner. Website reviewers can still look after any game, as before.

A studio game's page credits the studio and links to its page, where anyone sees its listed games. When a member deletes their account the studio keeps its games. If they were the last owner, the member who joined first becomes owner; a studio left with nobody takes its games down.

Studio Publish in the editor works for owners and developers of a studio game, the same as for a game of your own.

Creator keys#

Open your game, Settings, then Keys. A key has:

Field
Name for you, like "CI publish"
Games only this game, or every game you work on
Scopes what it may do: publish, analytics:read, store:read, store:write, bans, console
Runs out after 7, 30, 90 or 365 days, or never

The key is shown once. Moud keeps only its hash, so a lost key is revoked and made again. The list shows when each key was last used. A key never does more than your role on the game allows, so a key of an analytics member can not publish even with the publish scope, and a key never carries website reviewer rights.

Send it as a bearer header:

Authorization: Bearer moud_ck_...

What a key reaches, all under /v1 (the older /api/catalog prefix still works). The api reference has every route:

Call Scope
GET /creator/me any
GET /creator/games any, the games the key reaches
GET /creator/games/<slug>/versions any
GET /creator/games/<slug>/analytics?days=30 analytics:read
POST /creator/games/<slug>/store with op get, keys or set store:read, store:write for set
blob uploads and POST /games/<slug>/versions publish
GET /creator/games/<slug>/console, POST /creator/games/<slug>/console with op logs, commands or command console, see Server console

Store calls take the same store, key, value and version fields as the server's store library. A key makes at most 300 calls a minute, a person has at most 20 keys at once.

Publishing from CI#

moud publish sends a build the editor made. Publish once from the editor so the folder .moud/publish holds manifest.json, client.zip, server.zip and content/, commit or cache that folder, then in CI:

MOUD_KEY=moud_ck_... moud publish .moud/publish --game my-game

--game defaults to the slug in .moud/catalog.json, --catalog or MOUD_CATALOG points at another catalog, and --key-file reads the key from a file. The key is never taken as an argument, since arguments end up in logs.

Webhooks#

Open your game, Settings, then Webhooks, and add an https url. Pick the events:

Event When
version_approved a reviewer accepted a version
version_refused a reviewer refused one, with the reason
crash_spike one version crashed N times in M minutes, you set both
fleet_down a fleet sent no heartbeat for X minutes
player_report a player reported someone in the game
badge_milestone a badge was earned 10, 100, 1000 and so on times

A Discord webhook url gets a message Discord can show. Any other url gets json:

json
{ "id": "...", "event": "crash_spike", "at": "2026-11-06T12:00:00.000Z",
  "game": { "id": "...", "slug": "my-game", "name": "My game" },
  "text": "My game version 3 crashed 12 times in 10 minutes.",
  "data": { "version": "3", "count": 12, "minutes": 10 } }

Every post carries x-moud-event, x-moud-delivery, x-moud-timestamp and x-moud-signature: sha256=<hex>, the HMAC SHA-256 of <timestamp>.<body> with the hook's secret. Check it before trusting a post, and drop posts whose timestamp is more than a few minutes old:

js
const wanted = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")
const ok = wanted.length === signature.length && timingSafeEqual(Buffer.from(wanted), Buffer.from(signature))

A post that fails with a network error, a timeout, 408, 429 or a 5xx is tried again after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. Other answers are not retried, and redirects are not followed. The page lists the last 50 deliveries of each hook with their status. Send a test queues a test event. Urls that point at private or local addresses are refused, checked again on every send. A game has at most 10 webhooks, and a hook gets at most 60 events a minute.