---
title: platform
description: The v1 reference for projects, keys, OAuth clients, and outbound email.
---

# platform

Base path `/api/platform/v1`. See [API reference](/api) for
authentication and the `api.shebang.pro` rewrite.

Most routes here take an optional `project` query parameter or body field
— a project **slug** (not id) — naming which of the account's projects the
call targets. Omitted, it defaults to the calling key's home project. A
write (create/update/delete/provision) against a named project requires
either the key's home project or a `full` grant on it — a `read` grant
only covers list/fetch. Naming a project the key can't reach at all
(wrong account, or no grant) is `403 insufficient_scope`, never `404` —
existence is never leaked to a caller who can't reach it.

The **Project object** every project route returns:

```json
{
  "id": "uuid",
  "name": "string",
  "tag": "string",
  "color": "string",
  "slug": "string",
  "oauthClientId": "string | null",
  "status": "active | archived",
  "createdAt": "ISO 8601",
  "updatedAt": "ISO 8601"
}
```

## Projects

### GET /api/platform/v1/projects

Lists every project on the caller's account.

- **Auth** — master key or bearer only (`403 insufficient_scope` for an
  app key).
- **Response** — `200 { "projects": [Project, ...] }`.

### POST /api/platform/v1/projects

Creates a project.

- **Auth** — master only.
- **Request body** — `name` (string, required), `tag` (string,
  required), `color` (string, required — one of the curated palette
  values), `slug` (string, required), `oauth_client_id` (string or
  `null`, optional).
- **Response** — `201 { "project": Project }`.
- **Errors** — `400 invalid_project` (missing/invalid field, with
  `message`), `400 invalid_slug`, `409 slug_taken`.

### GET /api/platform/v1/projects/{id}

Fetches one project.

- **Auth** — master (any of the account's projects), or an app key
  reading its own home project (`id` must equal the key's `projectId`).
- **Path params** — `id` (uuid).
- **Response** — `200 { "project": Project }`.
- **Errors** — `404 not_found` (malformed id, unowned project, or an app
  key naming a project other than its own).

### PATCH /api/platform/v1/projects/{id}

Updates a project.

- **Auth** — master only.
- **Path params** — `id` (uuid).
- **Request body** — any of `name`, `tag`, `color`, `slug` (strings),
  `oauth_client_id` (string or `null`), `status` (`"active"` or
  `"archived"`) — all optional, only supplied fields change.
- **Response** — `200 { "project": Project }`.
- **Errors** — `400 invalid_project`, `400 invalid_slug`, `409
  slug_taken`, `409 client_already_attached` (the named
  `oauth_client_id` is already attached to a different project), `404
  not_found`.

### DELETE /api/platform/v1/projects/{id}

Deletes a project row. Confirm-guarded — the project itself must be
empty (no keys, pages, objects, links, or sherbase databases assigned to
it) unless `move_to` names another of the account's projects to move its
contents into first.

- **Auth** — master only.
- **Path params** — `id` (uuid).
- **Request body** — `confirm` (string, required, must equal `id`),
  `move_to` (string, optional — another of the account's project
  **slugs**).
- **Response** — `204` (empty body).
- **Errors** — `400` (`confirm` doesn't match `id`), `400 invalid_body`
  (`move_to` doesn't name an owned, different project), `409
  project_not_empty`, `409 last_project` (the account's only project),
  `404 not_found`.

### GET /api/platform/v1/projects/{id}/resources

Lists everything assigned to a project — pages, objects, links,
databases, keys.

- **Auth** — same read access as `GET /api/platform/v1/projects/{id}`.
- **Path params** — `id` (uuid).
- **Response** — `200 { "resources": {...} }` (per-resource-type arrays;
  each sherbase database entry includes its own `createdAt`).
- **Errors** — `404 not_found`.

### POST /api/platform/v1/projects/{id}/assign

Assigns an existing resource to project `{id}`.

- **Auth** — master only.
- **Path params** — `id` (uuid, the destination project).
- **Request body** — `resource_type` (one of `page`, `object`, `link`,
  `database`, `key`, required), `resource_id` (uuid, required).
- **Response** — `204` (empty body).
- **Errors** — `400 invalid_resource_type`, `404 not_found` (malformed
  `id`/`resource_id`, or either doesn't belong to the caller's account).

### POST /api/platform/v1/projects/{id}/unassign

Moves a resource to the account's **default** project — `{id}` in the
path is validated as a well-formed id but not otherwise consulted; the
destination is always the default project, not `{id}`.

- **Auth** — master only.
- **Path params** — `id` (uuid — validated only, not the destination).
- **Request body** — same shape as `assign`: `resource_type`,
  `resource_id`.
- **Response** — `204` (empty body).
- **Errors** — `400 invalid_resource_type`, `404 not_found`.

### POST /api/platform/v1/projects/{id}/grants

Grants a key access to project `{id}` beyond its own home project.

- **Auth** — master only (an OAuth-client bearer principal gets `403
  insufficient_scope` — it can never mint a grant).
- **Path params** — `id` (uuid, the project being granted).
- **Request body** — `key_id` (string, required), `level` (`"read"` or
  `"full"`, required).
- **Response** — `201 { "grant": { "keyId", "projectId", "level",
  "createdAt" } }`. Calling again with a different `level` updates the
  existing grant (one grant per key/project pair).
- **Errors** — `400 invalid_body`, `404 not_found` (key or project not
  owned by the caller), `403 insufficient_scope` (bearer principal).

### DELETE /api/platform/v1/projects/{id}/grants

Revokes a key's grant on project `{id}`.

- **Auth** — master only (same bearer-principal restriction as `POST`
  above).
- **Path params** — `id` (uuid).
- **Request body** — `key_id` (string, required). A `level` field, if
  present, is accepted and ignored.
- **Response** — `204` (empty body).
- **Errors** — `400 invalid_body`, `404 not_found`, `403
  insufficient_scope` (bearer principal).

## Keys

### GET /api/platform/v1/keys

Lists every key on the caller's account.

- **Auth** — master only.
- **Response** — `200 { "keys": [{ ..., "project": "slug | null" }, ...] }`
  — every stored key field plus the home project's slug.

### POST /api/platform/v1/keys

Mints a new **app** key (never a master — the only paths to a master key
are the device-login approve flow and the dashboard's own "Create key"
button).

- **Auth** — master only (an OAuth-client bearer principal gets `403
  insufficient_scope` — it can never mint a key).
- **Request body** — `name` (string, 1–60 chars, required), `apps`
  (non-empty array of `"page" | "serve" | "link" | "base"`, required),
  `project` (string, optional — a project slug; defaults to the
  account's default project if omitted). `app_id` is no longer accepted.
- **Response** — `201 { "key", "keyId", "keyPrefix", "tier": "app",
  "apps", "name", "projectId", "project" }`. `key` is the plaintext
  `shb_...` credential, returned exactly once — nothing server-side keeps
  it past this response.
- **Errors** — `400` (invalid `name` or `apps`), `400 invalid_body`
  (malformed or unsupported `project`/`app_id`), `403 insufficient_scope`
  (`project` names a slug the caller doesn't own, or a bearer principal).

### DELETE /api/platform/v1/keys/{id}

Revokes a key.

- **Auth** — master only. A master may revoke any key on the account,
  including another master, except the key currently authenticating this
  very call.
- **Path params** — `id` (uuid).
- **Response** — `204` (empty body).
- **Errors** — `400 cannot_revoke_current_key`, `404 not_found`.

## OAuth clients

Gated to one operator account (`PLATFORM_ADMIN_USER_ID`) — every other
master key gets the same `403` a non-master key would.

### GET /api/platform/v1/oauth-clients

Lists every registered OAuth client.

- **Auth** — master, and the key's account must be the platform admin
  account.
- **Response** — `200 { "clients": [...] }`.
- **Errors** — `403 insufficient_scope` (non-master), `403 forbidden`
  (master, but not the admin account).

### POST /api/platform/v1/oauth-clients

Registers a new OAuth client.

- **Auth** — same admin-only gate as `GET` above.
- **Request body** — `name` (string, 1–60 chars after trimming,
  required), `redirect_uris` (array of strings, required), `client_type`
  (`"confidential"` or `"public"`, required).
- **Response** — `201 { "client": {...} }`.
- **Errors** — `400` (invalid `name`, `redirect_uris`, or `client_type`,
  or a redirect URI that fails validation), `403 insufficient_scope`,
  `403 forbidden`.

## Email

### POST /api/platform/v1/email

Sends one plain-text email on the caller's behalf (fixed from-address,
reply-to the caller, a 10-per-user rolling-24h quota).

- **Auth** — master only (there is no `email` app scope to grant an app
  key).
- **Request body** — `to` (string, required), `subject` (string,
  required), `text` (string, required — never echoed back or logged).
- **Response** — `200 { "sent": true, "resend_id", "remaining_today" }`.
- **Errors** — `400 invalid_email_input`, `429 email_quota_exceeded`
  (with `retry_after_hours`), `502 send_failed`.

## Next

[sherpage](/api/sherpage) — pages.
