# REST API

The REST API publishes, lists and manages spaces on a creator's behalf. Every endpoint answers JSON. An error answers with a code in `error`, except the hosting endpoints, whose `error` is a sentence and `code` the code. The calls the Velven page makes for a space, such as sign-in and saves, are not part of it.

## Authentication

Send your API token in the `authorization` header as `Bearer <token>`. The examples on these pages keep it in `$VELVEN_TOKEN`. An agent gets one through the [device-code sign-in](https://velven.ai/docs/agent#login), which the creator approves in their browser; the token lasts 90 days. A 401 `unauthorized` means it is missing, expired or revoked: sign in again.

```bash
curl -s https://velven.ai/api/agent/me \
  -H "authorization: Bearer $VELVEN_TOKEN"
```

A token works only on its own creator's spaces. For another creator's space, the board endpoints answer 404.

## Endpoints

Every endpoint, with the credential it takes and a link to its details:

| Endpoint | Auth | What it does |
| --- | --- | --- |
| `POST /api/agent/login` | None | Starts a login; answers a `device_code` and a link for the creator. [Details](https://velven.ai/docs/agent#login) |
| `POST /api/agent/token` | None | Exchanges the `device_code` for a token once the creator approves. [Details](https://velven.ai/docs/agent#approve) |
| `GET /api/agent/me` | Token | The creator the token belongs to. |
| `POST /api/spaces` | Token | Lists a space. [Details](https://velven.ai/docs/agent#submit) |
| `GET /api/spaces?mine=1` | Token | The creator's listed spaces. [Details](https://velven.ai/docs/agent#reply) |
| `POST /api/spaces/verify` | Token | Claims a space Velven listed. [Details](https://velven.ai/docs/agent#claim) |
| `GET`, `PATCH /api/spaces/{slug}` | Token | Reads or changes a space's fields and its page. [Details](https://velven.ai/docs/agent#page) |
| `POST /api/spaces/{slug}/move` | Token | Moves a space to a new URL. [Details](https://velven.ai/docs/api#move) |
| `POST /api/spaces/recapture` | Token | Asks for a new clip. [Details](https://velven.ai/docs/agent#picture) |
| `POST /api/v1/hosting/versions` | Token or none | Starts a version of a hosted space: its files and `velven.json`. [Details](https://velven.ai/docs/api#hosting) |
| `POST /api/v1/hosting/versions/{id}/finish` | Token or claim | Makes the uploaded version a preview, or sends it to the safety check. [Details](https://velven.ai/docs/api#hosting) |
| `GET /api/v1/hosting/versions/{id}` | Token or claim | A version's status. [Details](https://velven.ai/docs/api#hosting) |
| `GET /api/v1/hosting/claim` | Claim | The unlisted page a claim token opens, and when it is deleted: `{ pageUrl, expiresAt }`. [Details](https://velven.ai/docs/api#hosting) |
| `GET /api/v1/hosting/spaces/{slug}/versions` | Token | A hosted space's versions. [Details](https://velven.ai/docs/api#hosting) |
| `POST /api/v1/hosting/spaces/{slug}/rollback` | Token | Puts an earlier version live again. [Details](https://velven.ai/docs/api#hosting) |
| `POST /api/v1/hosting/versions/{id}/preview` | Token | A new 24-hour preview link. [Details](https://velven.ai/docs/api#hosting) |
| `POST /api/v1/hosting/versions/{id}/review` | Token | Asks a person at Velven to review a refused or unjudged version. [Details](https://velven.ai/docs/api#hosting) |
| `POST /api/v1/hosting/spaces/{slug}/reset` | Token | Deletes the space's sandbox data. [Details](https://velven.ai/docs/api#hosting) |
| `GET`, `PUT /api/spaces/{slug}/boards` | Token | Reads or writes the space's boards. [Details](https://velven.ai/docs/api#boards) |
| `GET`, `POST /api/spaces/{slug}/secret` | Token | The board secret's status, or a new secret. [Details](https://velven.ai/docs/api#secret) |
| `GET`, `PUT /api/spaces/{slug}/achievements` | Token | Reads or writes the space's stats and achievements. [Details](https://velven.ai/docs/api#achievements) |
| `DELETE /api/spaces/{slug}/scores/{id}` | Token | Deletes one submission. [Details](https://velven.ai/docs/api#moderation) |
| `GET`, `POST`, `DELETE /api/spaces/{slug}/bans` | Token | Lists, adds or lifts bans. [Details](https://velven.ai/docs/api#moderation) |
| `POST /api/v1/scores` | Secret | A space's own server posts a score. [Details](https://velven.ai/docs/sdk/server#server-body) |
| `POST /api/v1/achievements`, `POST /api/v1/stats` | Secret | A space's own server unlocks an achievement or sets a stat. [Details](https://velven.ai/docs/sdk/server#server-achievements) |
| `GET /.well-known/jwks.json` | None | The public keys player tokens are signed with. |

## Move a space

Points the space at a new URL and keeps everything else. The new page passes the checks a listing does and, on a claimed space, carries the proof tag naming its owner. The owner may move a space, and an admin any space; each space gets 6 tries in 10 minutes. [Move to another host](https://velven.ai/docs/agent#move) says what to change on the new host.

```bash
curl -s -X POST https://velven.ai/api/spaces/<slug>/move \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{"url":"https://orbit-dodger.vercel.app"}'
```

| Status | Body | Meaning |
| --- | --- | --- |
| 200 | The space's fields, `velven_url` and `previous_url` | Moved. The fields are those `GET /api/spaces/{slug}` answers, with the new `url`. |
| 401 | `{ "error": "unauthorized" }` | The API token is missing, expired or revoked. Sign in again. |
| 403 | `{ "error": "blocked", "message" }` | The new URL cannot be listed on Velven. |
| 404 | `{ "error": "not_found", "message" }` | No space with that slug on this account. |
| 409 | `{ "error": "unverified", "message", "instruction", "snippet" }` | The new page does not carry the proof tag naming the space's owner. Add `snippet` to its head, deploy, and call again. |
| 409 | `{ "error": "duplicate", "url", "message" }` | Another space on Velven already uses that URL, before or after its redirects; `url` is its page. |
| 409 | `{ "error": "same_url", "message" }` | The new URL is the space's own. Nothing to move. |
| 409 | `{ "error": "removed", "message" }` | The space is off Velven. Put it back first. |
| 409 | `{ "error": "held", "message" }` | The space is under review. It can move once the review is done. |
| 409 | `{ "error": "conflict", "message" }` | The space changed while it was being moved. Read it again, then retry. |
| 422 | `{ "error": "invalid", "issues" }` | The body was not JSON or not `{ url }`, the URL is not a web address, or Velven does not list its host. |
| 422 | `{ "error": "unreachable", "message", "instruction" }` | The new page did not answer with a page, or asks visitors to sign in (a locked preview answers 401): make it public, or use the production URL. `instruction` comes only with a locked page. |
| 422 | `{ "error": "unframeable", "message", "instruction", "snippet" }` | The new page's headers refuse frames. Make the change that `instruction` describes (the [steps for each host](https://velven.ai/docs/hosting#framing)), deploy, and call again. |
| 429 | `{ "error": "rate_limited", "message" }` | The space has had its 6 tries in 10 minutes. Wait a few minutes; do not retry in a loop. |

## Boards

`GET` answers `{ "boards": [...] }`, every board as it stands. `PUT` takes the same shape as the page's [boards block](https://velven.ai/docs/sdk/leaderboards#board-fields) and writes only the boards named; the page's next sync overwrites a key the page also names.

```bash
curl -s https://velven.ai/api/spaces/<slug>/boards \
  -H "authorization: Bearer $VELVEN_TOKEN"

curl -s -X PUT https://velven.ai/api/spaces/<slug>/boards \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{"boards":[{"key":"main","trust":"client","metric":"points","sort":"desc"}]}'
```

| Status | Body | Meaning |
| --- | --- | --- |
| 200 | `{ "boards": [...] }` | The boards after the write. |
| 401 | `{ "error": "unauthorized" }` | The API token is missing, expired or revoked. Sign in again. |
| 404 | `{ "error": "not_found", "message" }` | No space with that slug on this account. |
| 409 | `{ "error": "too_many", "message" }` | The write would take the space past its board limit. |
| 422 | `{ "error": "invalid", "issues" }` | A board did not validate; `issues` names the field. |
| 503 | `{ "error": "failed", "message" }` | The boards could not be saved just now. Try again. |

## Hosting

The calls the [Velven CLI](https://velven.ai/docs/cli) makes, for a tool of your own that publishes. A version is made in 3 calls: create it with the file list, upload the files Velven lacks, then finish it. The files are named by their SHA-256, so a file Velven already has is never sent again. [Hosting](https://velven.ai/docs/hosting) has the rules.

```bash
curl -s -X POST https://velven.ai/api/v1/hosting/versions \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{"space":"star-hop","fields":{"title":"Star Hop","space_type":"game","devices":["desktop"]},
       "files":[{"path":"index.html","size":1843,"sha256":"<hex>"}]}'
```

It answers 201 `{ version: { id, number }, space: { slug, url }, uploads: [{ sha256, url }], limits }`. `PUT` each file's bytes to its upload `url`, with no authorization header; a link lasts 2 hours. Then finish:

```bash
curl -s -X POST https://velven.ai/api/v1/hosting/versions/<id>/finish \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{"preview":false}'
```

- The create body: `space` (a slug of yours, left out for a new space), `fields` (the listing, named as `spaceFieldsSchema` names them: `title`, `space_type`, `devices`, `description`, `engine`, `ai_tools`, `models`, `how_made`, `source_url`), `files` (1 to 2,000 of `{ path, size, sha256 }`; a `velven.json`, a dotfile, a dot-folder or `node_modules`, in any folder and any case, is refused, naming the path, since none is ever published), and `velven.json`'s `entry`, `spa`, `sdk`, `start`, `toasts`, `boards`, `achievements` and `stats` as they are.
- Finish takes `{ preview }`, `true` by default: a preview answers `{ status: "preview", previewUrl, watchUrl }`; `false` answers `{ status: "checking", watchUrl }` and the version goes live when the check passes. Finishing twice answers as the first did; a file not yet uploaded answers 409 `missing_uploads`; a version made before the space moved to a live URL answers 409 `not_hosted` for `false`, since it can only be previewed.
- `GET /api/v1/hosting/versions/{id}` answers `{ id, number, status, reason?, shownOnTake?, pageUrl, previewUrl?, createdAt, liveAt?, reviewRequested, stopped? }`. Poll it every few seconds for the verdict: `live`, `refused` or `unjudged`. `stopped: true` on a `checking` version means its check ended without a verdict (the space was hidden, held or moved, or its checks are over the hourly limit) and none is queued: stop polling and publish again.
- `GET .../spaces/{slug}/versions` answers the last 100 as `{ versions: [{ id, number, status, createdAt, live, files, size, reason?, liveAt?, rollback, leftBehind }] }` (`leftBehind`: made before the space moved to a live URL, so it can only be previewed). `POST .../rollback { version }` answers `{ version: { number }, status: "live", pageUrl }`, or `not_allowed` for a version that was never live, and `held` or `removed` while the space is off Velven.
- `POST .../versions/{id}/review` answers `{ requestedAt }`, once per refused or unjudged version, and never for one older than the live version (`superseded`). `POST .../spaces/{slug}/reset { categories?, player? }` answers `{ deleted }`, the categories being `scores`, `saves`, `achievements`, `content` and `rooms`.
- Without a token, create makes an [unlisted page](https://velven.ai/docs/hosting#no-account) and adds `pageUrl`, `expiresAt` and, the first time, `claimToken`. Send the token as `claim` in later create and finish bodies, and as the `x-velven-claim` header on reads. Finish with `preview: false` answers 403 `sign_in_required`.

| Code | Meaning |
| --- | --- |
| `missing_fields`, `invalid_fields` | The listing lacks a required field or has a bad one; `fields` names them. |
| `invalid_request`, `invalid_files`, `invalid_declaration` | The body, a file path, a file's `size` (it must be the file's real bytes) or a board, stat or achievement did not validate. |
| `no_entry` | No file at `entry`, `index.html` by default. |
| `too_large`, `too_many_files` | Over the version limits; the largest files are named. |
| `too_many` | `velven.json` and what the [achievements](https://velven.ai/docs/api#achievements) or [boards](https://velven.ai/docs/api#boards) API declared would take the space past 50 stats, 50 achievements or 10 boards. Declare fewer in `velven.json`. |
| `not_owner`, `not_found`, `removed`, `held` | Not your space, no such space or version, or the space is off Velven. |
| `rate_limited` | Too many publishes; `Retry-After` says when to try again. |
| `checks_busy` | 429 on finish: too many of your versions are already waiting for the safety check. Nothing is lost: finish again after the seconds `Retry-After` gives. |
| `sign_in_required`, `claim_not_found`, `claim_expired`, `claim_no_profile`, `claim_banned` | Production needs an account; the claim token is wrong, used or past its 7 days; the account claiming has no handle yet, or cannot claim. |
| `claim_spent` | 409 without an account, on a create, a finish, a version read or `GET /api/v1/hosting/claim`: the claim token's page was claimed (during the check, say). Its versions go on under that account; sign in as it and publish again. |
| `unauthorized`, `no_handle`, `forbidden` | The token does not resolve (run velven login again); the account has no handle yet; the account cannot publish. |
| `cross_site` | A publish without an account that another site's page sent. Publish from Velven itself or the CLI. |
| `not_allowed`, `not_hosted`, `not_previewable`, `not_refused`, `superseded`, `space_unavailable`, `no_player` | A rollback to a version never live, or on a space now at a live URL, or to a version from before the space moved to a live URL; a review, or a finish for production, of a version uploaded before the space moved to a live URL; a preview of a version not uploaded or refused; a review of a version neither refused nor unjudged; a review of a version older than the one live, which a review could not take live (publish a new version instead); a preview while the space is held, removed or hidden; a reset for a handle with no player. |
| `missing_uploads` | Finish before every file was uploaded; the paths are named. |
| `hosting_unavailable`, `server_error` | Velven's file store did not answer, or Velven had a problem on its side. Try again in a moment. |

## Achievements

`GET` answers the space's stats and achievements. `PUT` takes `{ "stats": [...], "achievements": [...] }` in the shape of the page's [block](https://velven.ai/docs/sdk/achievements), writes only the keys named, stats first since a trigger names one, and answers the declarations after the write. An `icon` here is an https URL. 422 `invalid` with `issues` for a field that does not validate, 422 `unknown_stat` for a trigger on a stat the space does not declare, 409 `too_many` when the write would take the space past 50 stats or 50 achievements, 503 `failed` when they could not be saved just now (try again).

```bash
curl -s -X PUT https://velven.ai/api/spaces/<slug>/achievements \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" \
  -d '{"stats":[{"key":"kills","label":"Kills"}],"achievements":[{"key":"centurion","title":"Centurion","trigger":{"stat":"kills","atLeast":100}}]}'
```

## Board secret

A [server board](https://velven.ai/docs/sdk/server) takes posts only with the space's secret. `POST` issues one and answers 201 with `{ "secret", "message" }`; the secret is shown this once, only its hash is kept, and issuing again replaces it. `GET` answers `{ "secret": { "created_at", "rotated_at" } }`, or `{ "secret": null }` before the first, never the secret itself.

```bash
curl -s -X POST https://velven.ai/api/spaces/<slug>/secret \
  -H "authorization: Bearer $VELVEN_TOKEN"

curl -s https://velven.ai/api/spaces/<slug>/secret \
  -H "authorization: Bearer $VELVEN_TOKEN"
```

## Moderation

Delete one submission; the player is re-ranked from what is left. Or ban a player, by handle, from every board of the space, and lift the ban later.

No endpoint lists submissions yet. The latest are on the Leaderboards tab of the space's edit page, each with Remove.

```bash
# delete one submission
curl -s -X DELETE https://velven.ai/api/spaces/<slug>/scores/<id> \
  -H "authorization: Bearer $VELVEN_TOKEN"

# list, add and lift bans
curl -s https://velven.ai/api/spaces/<slug>/bans \
  -H "authorization: Bearer $VELVEN_TOKEN"
curl -s -X POST https://velven.ai/api/spaces/<slug>/bans \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" -d '{"handle":"@mara"}'
curl -s -X DELETE https://velven.ai/api/spaces/<slug>/bans \
  -H "authorization: Bearer $VELVEN_TOKEN" \
  -H "content-type: application/json" -d '{"handle":"@mara"}'
```

| Status | Body | Meaning |
| --- | --- | --- |
| 200 | `{ "deleted": true }` or `{ "banned", "handle" }` | Done. |
| 400 | `{ "error": "invalid" }` | The submission id is not a positive integer. |
| 404 | `not_found` or `no_such_player` | No such submission on this space's boards, or no account has that handle. |
| 422 | `{ "error": "invalid", "issues" }` | The body was not `{ handle }`, or it named you. |
