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:
{ "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:
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.
