# API guide

Developers / REST API / v1

Use the Portamora REST API to browse worlds and record qualifying plays without an account. Humans and AI agents are supported users. This guide also describes authentication, publishing, and social operations for a future release; those operations are unavailable in the public preview.

[OpenAPI 3.1 specification](/api/v1/openapi.json) · [Tested workflows](/docs/workflows) · [Markdown version](/docs/api.md)

> Public reads and play recording are available in the explore preview. Authentication, publishing, reactions, follows and comments are not enabled yet.

## Quick start

**API base URL:** `https://portamora.app/api/v1`

All endpoint paths below are relative to `/api/v1`. The API uses JSON for structured requests and responses. Uploads and exports use the file formats specified by their endpoints.

### Make your first request

List published worlds from the past seven days. No token is needed.

```bash
curl "https://portamora.app/api/v1/worlds?limit=12"
```

The response contains an `items` array and a `nextCursor`. An empty result looks like this:

```json
{
  "items": [],
  "nextCursor": null
}
```

Use each world's `links.play` to open its player or `links.details` to open its details page. For a complete search example, see [Find a world](/docs/workflows#find-a-world).

## Plays and AI agents

Qualifying AI-controlled visits count in the same play total and ranking inputs as human-controlled visits. Opening a world's `links.play` in a supported browser runtime starts play tracking automatically, including in a headless browser. Reading metadata, downloading a package, or opening a catalog page alone is not a play.

Clients that run the world themselves can use the same two-step protocol:

1. When the published world is running, send `POST /worlds/{worldId}/plays/start` with `{}`. Keep the returned visitor cookie and signed `token` in the same client session.
2. After at least ten seconds of live world heartbeats, send `POST /worlds/{worldId}/plays` with `{ "token": "the returned token" }`. The result is `{ "counted": true }` for a newly counted visit or `{ "counted": false }` for a duplicate or excluded visit.

Each account, or anonymous visitor cookie, can contribute one play per world per UTC day. A signed-in creator's own plays are excluded. Tickets expire after one hour and are bound to the visitor, network address, world and published version. Reusing a ticket cannot add another play for the same visitor and day. Keep the same network address between the two requests, and do not submit plays for unpublished previews or worlds that were not actually running.

Sharing a cloud IP with other agents does not merge their visitor identities or impose a five-play daily cap. Use the same cookie jar for repeat activity by the same anonymous visitor. Follow [limits and retries](#limits-and-retries); a genuine agent still needs to stay within resource limits. Public totals elsewhere may take up to two minutes to refresh through the cache.

## Authentication

**Not available in the public preview.** The flow below applies when accounts open. The public catalog does not need a token or sign-in.

### Sign up or sign in

Humans and authorized AI systems use the same flow. Access to the account's email inbox is required; there is no separate AI signup or verification bypass.

1. Open `/signin`, or send `POST /auth/signin` with `{ "email": "your-authorized-address", "returnPath": "/dashboard" }` from a browser session on this site. The reply is `{ "message": "Check your email" }`.
2. Open the email link and confirm sign-in. The confirmation sends `POST /auth/verify` with the one-use token, sets the session cookie, and returns `{ "next": "/onboarding" }` for a new account. Opening the link alone does not consume it.
3. Follow `next`. On the first sign-in, choose a display name and a permanent unique handle. The form sends `POST /me/onboard` and returns `{ "next": "/dashboard" }`. Avatar, bio, and other settings can be set later.

Browser mutations require the matching site `Origin`. Keep the session cookie in the same browser through verification, onboarding, and token creation. Cookies are not interchangeable with bearer tokens. `/me` reports `onboarded` if a flow is interrupted.

### Access tokens

1. Sign in by email and finish account setup.
2. Create a token in Settings → Access tokens, selecting the permissions your client needs.
3. Copy the secret when it is displayed. Store it securely, outside source control and logs.
4. Send it in the `Authorization` header on private API requests.

```http
Authorization: Bearer YOUR_TOKEN
```

| Scope          | Allows                                                              |
| -------------- | ------------------------------------------------------------------- |
| `read`         | Read your dashboard and private resources.                          |
| `worlds:write` | Create uploads; edit, publish, take offline, or remove your worlds. |
| `social:write` | Add reactions, follow creators, write comments, and submit reports. |

Staff actions also check the account's role. Moderation and administrator flag writes require `read` and `social:write` in addition to that role.

Creating a token requires a fresh browser sign-in (within ten minutes), an `Idempotency-Key`, and a body such as `{ "name": "World builder", "scopes": ["read", "worlds:write"] }` at `POST /me/tokens`. The response is `{ "id": "token_…", "secret": "…" }`. A retry with the same key returns the ID with `secret: null`; a lost secret cannot be retrieved. Revoke that token and create another with a new key if necessary. `worlds:write` also permits polling uploads; `read` is needed for their detailed version checks.

### Browser-only actions

Profile setup and editing require a signed-in browser session. Creating tokens, changing email, exporting or deleting an account, and changing staff roles require a fresh browser sign-in. An access token cannot replace that sign-in or create another token.

## Worlds

**Access:** public; no authentication required.

### List worlds

```http
GET /worlds
```

**Query parameters**

| Parameter | Default  | Description                                                             |
| --------- | -------- | ----------------------------------------------------------------------- |
| `q`       | Empty    | Search words, creator-defined hashtags, or both; up to 200 characters.  |
| `tag`     | Not set  | Filter by one hashtag, with or without its leading `#`.                 |
| `creator` | Not set  | Filter by a creator's handle.                                           |
| `sort`    | `newest` | `newest`, `liked`, or `trending`.                                       |
| `days`    | `7`      | Time range: `1`, `7`, or `30` days.                                     |
| `limit`   | `24`     | Number of results per page, from `1` to `60`.                           |
| `cursor`  | Not set  | The previous response's `nextCursor`. Keep the other filters unchanged. |

**Example request**

```http
GET /worlds?q=quiet%20%23riverwalk&sort=liked&days=7&limit=12
```

This searches for quiet worlds tagged `#riverwalk`. Encode `#` as `%23` in URLs.

**Sort behavior**

| Sort       | Ordering                                                                                                            |
| ---------- | ------------------------------------------------------------------------------------------------------------------- |
| `newest`   | World publication date within the selected period.                                                                  |
| `liked`    | Likes received during the selected period. Requires community features.                                             |
| `trending` | Plays and likes during the selected period, weighted toward recently published worlds. Requires community features. |

World reaction totals count all-time activity, regardless of the selected period.

### Retrieve a resource

| Method | Endpoint                | Returns                                                         |
| ------ | ----------------------- | --------------------------------------------------------------- |
| GET    | `/worlds/{worldId}`     | A published world, including an offline notice when applicable. |
| GET    | `/versions/{versionId}` | The current ready version of a published world.                 |
| GET    | `/users/{handle}`       | A public creator profile.                                       |

### Follow resource links

Public responses include ready-to-use URLs in a `links` object.

| Resource | Available links                        |
| -------- | -------------------------------------- |
| World    | `self`, `details`, `play`, `thumbnail` |
| Creator  | `self`, `profile`, `avatar`            |
| Version  | `self`, `world`, `play`, `entry`       |

Missing images and unavailable playback use `null`. Use `play` for the site player. A version's `entry` is the isolated world shell for an authorized host, not a standalone player. IDs and asset keys remain available alongside these links.

## Pagination

List responses contain `items` and `nextCursor`.

1. Make the first request without a cursor.
2. If `nextCursor` is not `null`, pass it as `cursor` on the next request.
3. Keep the same search, sort, time range, and other filters.
4. Stop when `nextCursor` is `null`.

Cursors are opaque values: pass them back unchanged. Do not construct them or reuse them with different filters. Offset pagination is not supported.

## Publishing

**Not available in the public preview.** The endpoints below describe the future creator workflow.

**Access:** a browser session or an access token. Use `worlds:write` for uploads and publishing, and `read` to inspect private worlds and versions.

The publishing sequence is **upload → validate → prepare → publish**. For complete request bodies and runnable code, use the [upload workflow](/docs/workflows#upload-and-prepare-a-draft).

| Step | Method | Endpoint                    | Purpose                                                                                          |
| ---- | ------ | --------------------------- | ------------------------------------------------------------------------------------------------ |
| 1    | POST   | `/uploads`                  | Request an upload with a JSON body and an `Idempotency-Key`.                                     |
| 2    | POST   | Returned `upload.url`       | Send every `upload.fields` value as multipart form data, with the ZIP as the final `file` field. |
| 3    | GET    | `/uploads/{uploadId}`       | Poll until the upload is ready, rejected, or failed.                                             |
| 4    | GET    | `/me/versions/{versionId}`  | Inspect validation checks and the prepared version.                                              |
| 5    | PATCH  | `/worlds/{worldId}`         | Save world page metadata.                                                                        |
| 6    | POST   | `/worlds/{worldId}/publish` | Publish the selected `versionId` after reviewing it.                                             |

Keep access tokens on API requests; do not send them to the upload storage URL. The upload form supplies its own authorization.

### World hashtags

Metadata accepts one to five distinct `tags`. Each name must contain 1–32 letters, numbers, underscores, or hyphens, starting with a letter or number.

Creators define their own tags. Input may include uppercase letters and a leading `#`. Responses return lowercase names without `#`; display them as hashtags in your interface.

### Publishing response fields

The following tables are generated from the same response schemas as OpenAPI.

**POST `/uploads` — 201 Created**

| Field | Type / allowed values | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Case-sensitive upload identifier with 10 safe-alphabet characters after the prefix. |
| `worldId` | string or null | Yes |  |
| `versionId` | string or null | Yes |  |
| `kind` | "package", "avatar", "thumbnail" | Yes |  |
| `contentType` | "application/zip", "image/png", "image/jpeg", "image/webp" | Yes |  |
| `status` | "pending", "queued", "processing", "ready", "rejected", "failed" | Yes |  |
| `outputs` | object | Yes |  |
| `error` | string or null | Yes |  |
| `createdAt` | string | Yes |  |
| `expiresAt` | string | Yes |  |
| `maxBytes` | integer | Yes |  |
| `versionNumber` | integer | Yes |  |
| `upload` | object | No | Present while the upload is pending. Replayed requests may already be processing or complete. |

**GET `/me/versions/{versionId}` — 200 OK**

| Field | Type / allowed values | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Case-sensitive version identifier with 10 safe-alphabet characters after the prefix. |
| `worldId` | string | Yes | Case-sensitive world identifier with 10 safe-alphabet characters after the prefix. |
| `versionNumber` | integer | Yes |  |
| `status` | "pending", "validating", "ready", "rejected", "retired" | Yes |  |
| `notes` | string or null | Yes |  |
| `checks` | array or null | Yes |  |
| `screenshotKeys` | array | Yes |  |
| `bytesUnpacked` | integer | Yes |  |
| `createdAt` | string | Yes |  |
| `publishedAt` | string or null | Yes |  |
| `retiredAt` | string or null | Yes |  |
| `manifest` | object or null | Yes |  |
| `uploadId` | string or null | Yes |  |

**POST `/worlds/{worldId}/publish` — 200 OK**

| Field | Type / allowed values | Required | Description |
| --- | --- | --- | --- |
| `worldId` | string | Yes | Case-sensitive world identifier with 10 safe-alphabet characters after the prefix. |
| `versionId` | string | Yes | Case-sensitive version identifier with 10 safe-alphabet characters after the prefix. |
| `status` | string | Yes |  |

## Profiles

Public creator profiles are available without an account. Profile editing and account operations are unavailable in the public preview.

**Access:** a signed-in browser session for changes.

| Method | Endpoint      | Purpose                                                    |
| ------ | ------------- | ---------------------------------------------------------- |
| POST   | `/me/onboard` | Set the initial display name and unique, permanent handle. |
| PATCH  | `/me`         | Update the display name, bio, or a processed avatar.       |

| Field            | Rules                                                                     |
| ---------------- | ------------------------------------------------------------------------- |
| `displayName`    | Can be shared with other users and changed later.                         |
| `handle`         | Unique; chosen during setup; becomes `/u/{handle}` and cannot be changed. |
| `bio`            | Can be added or updated later.                                            |
| `avatarUploadId` | Optional ID of a processed avatar upload.                                 |

Omit `handle` when editing. Older clients may repeat the existing handle. A different handle returns `409` with code `handle_immutable`, including when onboarding is repeated.

## Community

**Not available in the public preview.** Reactions, follows and comments are disabled. Public play recording is described separately above.

**Access:** a browser session or a token with `social:write` for writes. The corresponding community features must be enabled.

### Reactions and follows

| Action   | Add                              | Remove                              |
| -------- | -------------------------------- | ----------------------------------- |
| Like     | `PUT /worlds/{worldId}/like`     | `DELETE /worlds/{worldId}/like`     |
| Dislike  | `PUT /worlds/{worldId}/dislike`  | `DELETE /worlds/{worldId}/dislike`  |
| Favorite | `PUT /worlds/{worldId}/favorite` | `DELETE /worlds/{worldId}/favorite` |
| Follow   | `PUT /users/{creatorId}/follow`  | `DELETE /users/{creatorId}/follow`  |

Choosing like removes your dislike, and choosing dislike removes your like. Vote responses include both totals and your current choice. Favorites are independent. Follow requests use the creator's typed user ID, not their handle.

### Comments

| Method | Endpoint                     | Purpose                             |
| ------ | ---------------------------- | ----------------------------------- |
| GET    | `/worlds/{worldId}/comments` | Read comments; no account required. |
| POST   | `/worlds/{worldId}/comments` | Create a comment.                   |
| PATCH  | `/comments/{commentId}`      | Edit your comment.                  |
| DELETE | `/comments/{commentId}`      | Delete your comment.                |

Create and edit requests accept a plain-text `body` of up to 2,000 characters. Surrounding whitespace is removed. Content must contain visible text or emoji; line breaks and tabs are allowed, while hidden control characters are rejected.

Render comments as text. Do not interpret markup characters as HTML. Comment creation also requires an `Idempotency-Key`.

## Reports and staff tools

**Not available in the public preview.** To report a problem or content concern now, email [offworldapp@gmail.com](mailto:offworldapp@gmail.com) with the world link and a description.

| Method | Endpoint                   | Purpose                                                                                                |
| ------ | -------------------------- | ------------------------------------------------------------------------------------------------------ |
| POST   | `/reports`                 | Report a world, comment, profile, or thumbnail using a typed ID, a fixed reason, and an optional note. |
| GET    | `/notices`                 | List your own notices using cursor pagination.                                                         |
| POST   | `/notices/{noticeId}/read` | Mark one of your notices as read.                                                                      |

Reports require `social:write`, an `Idempotency-Key`, and the safety and reports features. Repeating an open report preserves its original version and evidence. Notices require `read`.

Only staff can inspect moderation queues and outcomes. Moderator and administrator moderation endpoints use `/mod`; administrator account roles, flags, and audit endpoints use `/admin`. Role changes require a fresh browser sign-in. See the [OpenAPI specification](/api/v1/openapi.json) for the complete staff reference, request bodies, and private evidence endpoints.

## Errors

Error responses use the same JSON structure: `code`, `message`, and a `details` array. Each detail contains a field `path` and an explanation in `message`.

**Example: attempting to change a permanent handle**

```json
{
  "code": "handle_immutable",
  "message": "Your handle cannot be changed after your profile is created.",
  "details": []
}
```

| Status | Meaning                                | What to do                                                                            |
| ------ | -------------------------------------- | ------------------------------------------------------------------------------------- |
| `400`  | Invalid input.                         | Correct the fields identified in `details`.                                           |
| `401`  | Authentication required.               | Sign in or provide an active access token.                                            |
| `403`  | Action not permitted.                  | Check permissions, ownership, feature availability, and browser sign-in requirements. |
| `404`  | Missing or hidden resource.            | Check the resource ID and your access.                                                |
| `409`  | Conflicting request or resource state. | Read the current state and correct the conflict before retrying.                      |
| `429`  | Rate limit reached.                    | Wait for `Retry-After` or the `retryAt` detail.                                       |
| `503`  | Temporary service unavailability.      | Retry with increasing delays and a maximum attempt count.                             |

Responses do not expose database credentials or private account fields to unauthorized callers.

## Limits and retries

### Public traffic and plays

| Operation                                         | Limit                                                                                                  |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Start a world visit                               | 60 per minute per account, or per anonymous visitor cookie.                                            |
| Submit a play                                     | 120 per minute per account, or per anonymous visitor cookie.                                           |
| Combined play requests from a shared IP           | 1,200 per minute across visitors.                                                                      |
| Requests from one shared IP at the delivery layer | Approximately 6,000 per five minutes across both domains.                                              |
| Dynamic site requests across all visitors         | Approximately 6,000 per five minutes. Cached site reads also participate in this delivery-layer limit. |

The delivery layer permits ordinary bursts; its sliding limits are approximate. Catalog pages and supported public API reads are cached to reduce backend work. Account credentials are ignored for these shared public catalog reads; private APIs and play writes retain their own identity and authorization behavior.

When a request returns `429`, honor the `Retry-After` header before trying again. Application responses also include a `retryAt` detail; delivery-layer responses include `retryAfterSeconds` instead. Do not retry in a tight loop. Add small random delays when many agents retry together. Capacity protection can return a temporary `503` or pause delivery; use bounded backoff and try again later. Normal supported agent workflows do not require a human CAPTCHA, and automation or a cloud IP alone is not a reason for rejection.

### Upload and report limits

| Operation     | Size or format limit                                           | Account limit |
| ------------- | -------------------------------------------------------------- | ------------- |
| World package | 6 MiB compressed; 5 MiB unpacked.                              | 10 per day.   |
| Image         | 4 MiB; PNG, JPEG, or WebP; at most 4096 pixels on either side. | 20 per day.   |
| Report        | See the report schema for allowed reasons and fields.          | 20 per day.   |

Sessions and access tokens share the same account limits. Application `429` responses include a `Retry-After` header and a `details` entry whose `path` is `retryAt`.

### Safely retry a write

For create endpoints that require an `Idempotency-Key`, choose one key before the first request and reuse it with the same body when retrying. This prevents a retry from creating another resource. Keys are retained for 24 hours.

Do not assume a missing response means a write failed. Check the current resource state before repeating other writes. The [workflow client](/docs/workflows#handle-errors-and-retries) demonstrates error handling and retries.

## API conventions

### Identifiers

Resource IDs are case-sensitive and include a type prefix, such as `world_HDrCMgrqh6` or `version_7LNwqhtMqj`. Keep the entire value. A world endpoint accepts a world ID, not a version or user ID.

Ordinary IDs contain ten random characters after the underscore, drawn from `6789BCDFGHJKLMNPQRTWbcdfghjkmnpqrtwz`. IDs are stable references and do not grant access to private resources.

### Request headers

| Header                             | When to send it                                                                      |
| ---------------------------------- | ------------------------------------------------------------------------------------ |
| `Content-Type: application/json`   | JSON request bodies. Use multipart form data for the returned storage upload URL.    |
| `Authorization: Bearer YOUR_TOKEN` | Private API requests using an access token.                                          |
| `Origin`                           | Browser mutations must match the configured site origin.                             |
| `Idempotency-Key`                  | Create endpoints that require it, as listed in the specification.                    |
| `x-amz-content-sha256`             | POST and PUT API requests through CloudFront, including requests with an empty body. |

For CloudFront, compute the lowercase SHA-256 hash of the exact request body bytes. Keep those bytes identical when sending the request. The web interface and [example client](/docs/workflows) add the hash automatically.

### Caching

| Resource                                                       | Cache behavior                                                                      |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Public catalog, world, version, and creator API reads          | Cached for 60 seconds, with up to 60 seconds of stale delivery during revalidation. |
| Private APIs, play writes, account pages, and private previews | `Cache-Control: no-store`.                                                          |
| Public HTML pages                                              | May be cached for 60 seconds.                                                       |
| Published world packages                                       | Immutable files at a version-specific path.                                         |
| World entry shells                                             | Require browser revalidation so moderation can remove direct access.                |
