# Mayavoo creator API

Base URL: `https://mayavoo.com/api/v1`. Use the same site's origin in other environments.
Machine-readable contract: `/openapi.json`. Human guide: `/developers`.

Use this API to list your games, create a draft, edit its listing, upload files, and submit a new build for review.
You need a website account with a creator profile. Uploading and editing require a read-and-write key.

- [Quick start](#quick-start)
- [Endpoints](#endpoints)
- [Request and response examples](#request-and-response-examples)
- [Listing fields](#listing-fields-and-partial-edits)
- [File uploads](#direct-uploads)
- [Versions and publication](#versions-and-publication)
- [Retries and errors](#retries-and-errors)
- [Rate limits](#rate-limits)
- [What responses contain](#what-responses-contain)

## Authentication

Sign in to the website, complete your creator profile, and open `/account/api-keys`.
Create a read-only or read-and-write key. Copy the secret once into your tool's secret store or `MAYAVOO_API_KEY` environment variable.
You can have five active keys. Keys expire after 90 days. To rotate, create a replacement, update your tool, then revoke the old key.

Send `Authorization: Bearer mayavoo_...` on API requests over HTTPS. Read-and-write includes read access.
Keys can access only their owner's games, even if the owner is an administrator. Website cookies do not authenticate these endpoints.
Never include keys in URLs, logs, frontend/game code, uploaded files, or requests to signed upload URLs.

## Quick start

This shell example uses curl and jq. Load the key from your secret store first. Do not paste it into source control.

```sh
export MAYAVOO_ORIGIN=https://mayavoo.com
curl --fail-with-body "$MAYAVOO_ORIGIN/api/v1/games?limit=20" \
  -H "Authorization: Bearer $MAYAVOO_API_KEY"

# Persist this UUID before the request. Reuse it on retries of this creation.
node -e 'console.log(crypto.randomUUID())' > create-operation.txt
curl --fail-with-body "$MAYAVOO_ORIGIN/api/v1/games" \
  -H "Authorization: Bearer $MAYAVOO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(cat create-operation.txt)" \
  --data '{"title":"Orbit Garden","category":"puzzle"}' > game-response.json
GAME_ID=$(jq -r '.game.id' game-response.json)
```

For the complete workflow, download `/developers/upload-game.mjs` and `/developers/game.example.json`.
Place your ZIP and images beside a copy of the manifest, fill in the listing, and run `node upload-game.mjs game.json` with Node.js 22 or later.
The example stores operation IDs, file keys, and resource IDs in `game.json.mayavoo-state.json`; it does not store your API key or signed URLs.
Keep that state file to resume the same run within seven days. Use one process per state file. Inputs, files, account, and origin must stay the same for a resumed run.
For a new version, use a new manifest filename with `gameId` and `build`. Omit `listing`, `cover`, and `screenshots` to keep current details. If supplied, they edit the existing listing before submission.

## Endpoints

| Method | Path | Result |
| --- | --- | --- |
| GET | `/games` | `games`, `quota: {used, limit}`, `nextCursor` |
| POST | `/games` | 201: `game: {id, slug}`, `listing`, `missingFields`, `quota` |
| GET | `/games/{gameId}` | `game`, `listing`, `latestVersion`, `publishedVersion`, `missingFields`, `refreshPending` |
| PATCH | `/games/{gameId}` | `listing`, `live`, `missingFields`, `refreshPending` |
| POST | `/games/{gameId}/uploads` | 201: `key`, `url`, `method`, `headers`, `expiresIn`, `expiresAt` |
| GET | `/games/{gameId}/versions` | `versions`, `nextCursor` |
| POST | `/games/{gameId}/versions` | 202: `versionId`, `versionNumber`, `status`, `statusUrl` |
| GET | `/games/{gameId}/versions/{versionId}` | `version` |

All writes use JSON and require an `Idempotency-Key` UUID. Collection `limit` defaults to 20, maximum 100.
Pass the returned `nextCursor` as `cursor`; stop when it is null. Games sort newest first by creation time and ID; versions sort newest first by version number.
JSON bodies are limited to 64 KiB. Upload file bytes directly to storage, never as JSON or multipart to these endpoints.

## Request and response examples

### List your games — GET `/games`

Read permission. This returns only games owned by the key's account. A draft has null version fields.

```json
{
  "games": [{
    "id": "11111111-1111-4111-8111-111111111111",
    "slug": "orbit-garden-a1b2c3",
    "title": "Orbit Garden",
    "shortDescription": "A puzzle among the stars.",
    "disabled": false,
    "publishedVersionId": null,
    "latestVersion": null,
    "status": null,
    "createdAt": "2026-09-29T10:00:00.000000Z"
  }],
  "quota": { "used": 1, "limit": 3 },
  "nextCursor": null
}
```

IDs and slugs here are examples; always use the values returned for your account.
For the next page, URL-encode `nextCursor` and pass `?limit=20&cursor=VALUE`. A null cursor means there are no more results.

### Create a draft — POST `/games`

Write permission + a new persisted `Idempotency-Key`. Minimal body: `{"title":"Orbit Garden"}`.
You can include all text fields and devices from the listing table below; attach images in a later PATCH.

The `201` response contains `game: {id, slug}`, the full `listing` with defaults, `missingFields`, and `quota`.
Keep `game.id` for all subsequent requests. The `Location` header points to its details endpoint.
`missingFields` lists the JSON properties still required before you can submit a build.

### Read details — GET `/games/{gameId}`

Read permission. Response fields:

| Property | Contents |
| --- | --- |
| `game` | `id`, `slug`, `disabled`, `publishedVersionId` |
| `listing` | Every field in the listing table; image entries contain `key`, `alt`, `width`, `height` |
| `latestVersion` | Most recent submitted version, or null; same fields as the version example below |
| `publishedVersion` | Currently selected published version, or null; check its `isLive` flag |
| `missingFields` | Listing fields needed before build submission; empty means complete |
| `refreshPending` | Whether the saved listing still needs a public-page cache refresh |

An unknown game and a game owned by someone else both return `404`.

### Edit details — PATCH `/games/{gameId}`

Write permission + a new persisted `Idempotency-Key`. For example:

```sh
node -e 'console.log(crypto.randomUUID())' > edit-operation.txt
curl --fail-with-body "$MAYAVOO_ORIGIN/api/v1/games/$GAME_ID" \
  -X PATCH \
  -H "Authorization: Bearer $MAYAVOO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(cat edit-operation.txt)" \
  --data '{"shortDescription":"A puzzle among the stars.","devices":["desktop","mobile"]}'
```

The `200` response contains the full saved `listing`, `live`, `missingFields`, and `refreshPending`.
`live:true` means this game currently has a published build and is not disabled.
Omitted properties stay unchanged. This example replaces the supported devices, while keeping the title and images.

### Reserve a file upload — POST `/games/{gameId}/uploads`

Write permission + a new persisted `Idempotency-Key`. For a ZIP containing exactly 12345 bytes:

```json
{ "kind": "build", "size": 12345, "contentType": "application/zip" }
```

Example `201` response (the URL is illustrative):

```json
{
  "key": "incoming/GAME_ID/UPLOAD_ID",
  "url": "https://ACCOUNT.r2.cloudflarestorage.com/BUCKET/OBJECT?SIGNATURE",
  "method": "PUT",
  "headers": { "Content-Type": "application/zip", "Content-Length": "12345" },
  "expiresIn": 300,
  "expiresAt": "2026-09-29T10:05:00.000Z"
}
```

Save the response as `upload.json`, then send the raw bytes:

```sh
curl --fail-with-body -X PUT "$(jq -r '.url' upload.json)" \
  -H "Content-Type: $(jq -r '.headers["Content-Type"]' upload.json)" \
  -H "Content-Length: $(jq -r '.headers["Content-Length"]' upload.json)" \
  --data-binary @game.zip
```

Do not add an Authorization header to this PUT. Treat `upload.json` as temporary sensitive data because the URL grants upload access until it expires. Delete it after the upload. The Node.js example keeps signed URLs only in memory.
For an image, change the kind and content type, upload it, then PATCH `cover:{"key":"RETURNED_KEY","alt":"Game cover"}` or `screenshots:[{"key":"RETURNED_KEY","alt":"Gameplay"}]`.

### Submit a new version — POST `/games/{gameId}/versions`

Write permission + a new persisted `Idempotency-Key`. Body: `{"buildKey":"KEY_FROM_UPLOAD_RESPONSE"}`.
Use the same endpoint for the first build and every update. Do not create a new game for an update.

Example `202` response:

```json
{
  "versionId": "22222222-2222-4222-8222-222222222222",
  "versionNumber": 1,
  "status": "queued",
  "statusUrl": "/api/v1/games/11111111-1111-4111-8111-111111111111/versions/22222222-2222-4222-8222-222222222222"
}
```

`statusUrl` is relative to your API origin. `Retry-After: 30` gives the minimum polling interval.
Submission never automatically approves a game or replaces its live build.

### Check a version — GET `/games/{gameId}/versions/{versionId}`

Read permission. Example `200` response:

```json
{
  "version": {
    "id": "22222222-2222-4222-8222-222222222222",
    "versionNumber": 1,
    "status": "in_review",
    "submittedAt": "2026-09-29T10:00:00.000Z",
    "failureCode": null,
    "rejectionReason": null,
    "isLive": false
  }
}
```

### List versions — GET `/games/{gameId}/versions`

Read permission. Returns `{"versions":[...],"nextCursor":null}`. Each entry has the same fields as `version` above.
Use `limit` and `cursor` as for the games list. Unsubmitted drafts are excluded.

## Listing fields and partial edits

Creation requires `title`. It creates a draft, consumes a game slot, and does not publish anything.
The response's `missingFields` identifies what you must finish before submitting a build.

| Field | Limit / meaning |
| --- | --- |
| `title` | 120 characters; nonempty on creation and submission |
| `shortDescription` | 240 characters; required for submission |
| `description` | 10,000 characters; required for submission |
| `instructions` | 5,000 characters; required for submission |
| `category` | 50 characters; required for submission |
| `devices` | Unique values from `desktop`, `mobile`, `tablet`; at least one for submission. New games start with `["desktop"]` |
| `orientation` | `any`, `landscape`, or `portrait`; defaults to `any` on creation |
| `contentNotes` | 2,000 characters; optional, defaults to empty |
| `cover` | PATCH only: `{key, alt}` or null; required for submission |
| `screenshots` | PATCH only: array of `{key, alt}`, maximum six; at least one for submission |

`alt` defaults to empty and can contain 300 characters. Image dimensions come from the uploaded bytes.
PATCH keeps omitted fields. Supplied arrays replace the entire array. `cover:null` and `screenshots:[]` clear images only while the listing can remain incomplete.
Once any build has been submitted, edits must keep the listing complete. Unknown properties are rejected; you cannot set ownership, slug, publication, or moderation fields.
Read the current listing before an edit. Concurrent independent edits to the same field use the last committed value.
Live metadata changes take effect immediately. `refreshPending:true` means public page cache refresh will be retried; the edit is already saved.

## Direct uploads

1. POST `{ "kind":"cover", "size":12345, "contentType":"image/png" }` to `/games/{gameId}/uploads`.
2. PUT the exact raw file bytes to the returned `url` with the returned `headers`. Do not send the API key. Use the declared content type and exact Content-Length. Do not use multipart form data.
3. For images, PATCH the listing with the returned `key`. For a ZIP, POST `{ "buildKey":"returned-key" }` to `/games/{gameId}/versions`.

Kinds are `build`, `cover`, and `screenshot`. Images must be JPEG, PNG, or WebP, at most 5 MiB and 20 million decoded pixels. Builds must use `application/zip`, at most 50 MiB.
Signed URLs expire after five minutes. Repeat the reservation with the same UUID and input for a fresh URL to the same object, then PUT again. Only new reservations count toward the upload quota.
Reservations expire after seven days. Objects are checked and copied to immutable retained keys during PATCH/submission. Use the image keys returned by PATCH or GET for future edits, and never overwrite a reserved object with different bytes after attaching it.
File ownership, kind, declared size/type, actual image format, dimensions, and storage version are checked. References to another game's files are rejected.

## Versions and publication

Submission requires a complete listing and an uploaded build for the same game. A game may have one pending build; an account may have five.
New versions reuse the existing game's listing. A `202` response acknowledges queuing. It does not mean the game is live.
Poll `statusUrl` no more frequently than every 30 seconds. Version fields include `status`, `failureCode`, `rejectionReason`, and `isLive`.
The current processor schedule is hourly, so a build can wait roughly an hour before processing starts. Human review takes additional time. Back off to a few minutes with jitter for longer waits.
Normal states are `queued` → `processing` → `in_review` → `approved` or `rejected`; processing can also end in `failed`.
`isLive` is the authority for whether this version is currently published. An older approved version may no longer be live.
If failed, fix the build according to `failureCode` and submit a new version. If rejected, address the review reason before resubmitting.

The ZIP needs a nonempty `index.html` at its root or under one `dist/` wrapper. Use relative asset paths. Include only the production HTML, CSS, JavaScript, and game assets.
Exclude source maps, dependencies, configs, archives, source files, and unused assets. Extracted output is limited to 200 MiB, 2,000 files, and 50 MiB per file.
The existing processing and human review checks apply to API builds.

## Retries and errors

Generate and persist a fresh UUID for each logical write operation before sending the request. Retry with the same UUID and JSON, even after a timeout or 5xx response.
Successful mutations and their replay receipts commit in one database transaction. The receipt lasts seven days and is scoped to the account, operation, and resource.
`Idempotency-Replayed:true` means the original committed response was returned. Changes to JSON property order do not matter; changed input returns `409 idempotency_conflict`.
Replaying an old PATCH does not overwrite subsequent edits. Replaying a submitted version returns its original queued acceptance; GET the version to see its current status.
After seven days, inspect the game and its versions before taking another action. A new UUID means a new operation and can create a duplicate if used carelessly.

Errors use `{ "error": { "code":"listing_incomplete", "message":"...", "fields":["cover"], "retryable":false }, "requestId":"..." }`.
Use codes and fields in your automation; messages are for people. Proxies and request-size/parser failures may return non-JSON, so clients must handle those too.

| Status / code | Action |
| --- | --- |
| 401 `invalid_api_key` | Replace an invalid, expired, or revoked key |
| 403 `insufficient_permission` | Use a read-and-write key for mutations |
| 404 `not_found` | Check IDs and ownership |
| 409 `idempotency_conflict` | Restore original input for a retry, or use a new UUID for an intentional new operation |
| 409 `game_limit`, `profile_required` | Resolve the account quota or complete your profile on the website |
| 409 `build_pending`, `conflict` | Read current state; wait for the pending build or resolve the conflict |
| 409 `idempotency_expired` | Inspect the previously created game; do not recreate it blindly |
| 410 `upload_expired` | Reserve and upload again as a new operation |
| 413 `payload_too_large` | Reduce the JSON or file size |
| 415 `unsupported_media_type` | Use a supported content type |
| 422 `validation_failed`, `listing_incomplete` | Correct the named fields |
| 429 `rate_limited`, `upload_rate_limit` | Wait at least Retry-After seconds, then retry the same operation |
| 429 `pending_build_limit` | Wait for a review/processing decision; retryable is false |
| 503 `service_unavailable` or network failure | Retry the same operation with exponential backoff and jitter |

## Rate limits

| Scope | Limit per minute | Includes |
| --- | --- | --- |
| One API key | 60 requests | Reads, writes, and retries |
| One API key | 10 writes | POST and PATCH requests |
| One account, across all its keys | 120 requests | Reads, writes, and retries |
| One account, across all its keys | 20 writes | POST and PATCH requests |
| One IP address | 300 requests | API traffic, including invalid credentials |

These are approximate limits per Cloudflare location, not exact global counters. The IP guard also applies when multiple callers share one network.
Exceeding any applicable limit returns `429 rate_limited` with `Retry-After: 60`. Wait at least that long, add random jitter, and retry with the same operation UUID. Creating more keys does not raise your account limits.
Write throttling alone does not stop status reads, although the total request limit still applies. Direct PUTs to signed storage URLs use their own URL authorization and do not consume API request counters.
Database quotas remain authoritative: 30 new upload reservations/account/hour, one pending version/game, five pending/account, and the game quota returned by GET `/games`.
Keep polling bounded and report the status URL when a tool stops waiting. Never retry indefinitely or make a new operation ID to escape a limit.

## What responses contain

GET responses expose only your game's listing, public identifiers, submission status, creator-facing review reason, and relevant quotas. They never include API key secrets or hashes, account emails, sign-in tokens, reviewer identities, processing leases, build archive locations, or internal artifact manifests.
Game image keys are intentional: use them when editing the listing. Uploaded game images are publicly accessible, including draft images; do not upload confidential images.
Only upload reservation responses include a temporary signed PUT URL. Keep those URLs private. The API key itself is shown once when you create it in account settings and cannot be retrieved later.
Authenticated responses use `Cache-Control: private, no-store`. There is no general `data` wrapper; each endpoint returns the named fields shown above. Consult `/openapi.json` for the complete request and response schemas.
