# REST API reference

Every endpoint an API key can call, generated from the [OpenAPI 3.1 spec](https://ollav.app/openapi.json) (version 1.3.0), which CI checks against the running API. Prefer MCP from an agent? See [the MCP docs](https://ollav.app/docs/mcp). New here? [Publish your first prototype with curl](https://ollav.app/docs/quickstart/curl).

## Basics {#basics}

- **Base URL:** `https://app.ollav.app`. Shared documents are served from `https://content.ollav.app/p/{slug}`.
- **Auth:** `Authorization: Bearer $OLLAV_API_KEY`. The user creates the key at https://app.ollav.app/keys; it belongs to one workspace and has the scopes `upload`, `read` and `comment`. `ol_test_...` keys work in the user's test sandbox: no plan limits, 60 requests a minute, documents deleted after 30 days, and responses say `"livemode": false`. `ol_live_...` keys (and older `sk_live_...` keys) work on live data.
- **`X-Ollav-Client: agent`** on every POST, PATCH and DELETE (a CSRF guard; any value works). Without it: `403 CSRF_REQUIRED`.
- **`Idempotency-Key`** (optional, any unique string) on POST, PATCH and DELETE makes a retry safe: it runs once and replays the first response for 24 hours.
- **`Ollav-Version`** (optional) picks a dated API version; otherwise the version your key was pinned to is used. See the [changelog](https://ollav.app/docs/changelog).
- **Errors** are JSON `{"error": {"type", "code", "message", "param", "hint", "doc_url", "request_id"}}`. Match on `code`; every code is on [the errors page](https://ollav.app/docs/errors). Every response has a `Request-Id` header.
- **Examples** use `$OLLAV_API_KEY`; set it to a test key while you build: `export OLLAV_API_KEY=ol_test_...`.

## Endpoints {#endpoints}

- **Documents**: [`GET /api/documents`](#listDocuments), [`POST /api/documents`](#shareHtml), [`GET /api/documents/{slug}`](#getDocument), [`PATCH /api/documents/{slug}`](#updateDocumentVisibility), [`DELETE /api/documents/{slug}`](#deleteDocument), [`POST /api/documents/{slug}/archive`](#archiveDocument), [`POST /api/documents/{slug}/unarchive`](#unarchiveDocument), [`POST /api/documents/{slug}/expiry`](#setDocumentExpiry), [`GET /api/dashboard`](#listDocumentsWithCounts)
- **Versions**: [`GET /api/documents/{slug}/versions`](#listVersions), [`POST /api/documents/{slug}/versions`](#publishNewVersion)
- **Comments**: [`GET /api/comments`](#getComments), [`POST /api/comments`](#addComment), [`DELETE /api/comments/{id}`](#deleteComment), [`POST /api/comments/{id}/resolve`](#resolveComment), [`POST /api/comments/{id}/unresolve`](#unresolveComment), [`POST /api/comments/{id}/attachments`](#attachImage)
- **Reactions**: [`POST /api/comments/{id}/reactions`](#reactToComment)
- **Review**: [`GET /api/review`](#getReview)
- **Exports**: [`GET /api/documents/{slug}/export`](#exportDocument), [`POST /api/exports`](#exportData), [`GET /api/exports/download`](#downloadExport)
- **Workspaces**: [`GET /api/workspaces`](#listWorkspaces), [`GET /api/workspaces/{id}/members`](#listWorkspaceMembers)
- **Billing**: [`GET /api/workspaces/{id}/billing`](#getWorkspaceBilling)
- **Account**: [`GET /api/me`](#getMe)
- **API keys**: [`GET /api/keys`](#listApiKeys), [`DELETE /api/keys/{id}`](#revokeApiKey)

## Documents {#tag-documents}

A document is one self-contained HTML file (up to 15 MB) at a stable URL on the content origin. Uploading a file with the same filename as one you already own adds a version instead of a new document.

### List documents {#listDocuments}

`GET /api/documents`

Lists your non-archived documents in the key's workspace, newest first, as a bare array. Use GET /api/dashboard instead when you also need version and comment counts. Guests can't list (403 ROLE_REQUIRED).

**Responses**

| Status | Meaning |
|---|---|
| 200 | Your documents (a bare array). |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/documents" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
[
  {
    "id": "9908f969-9774-4374-a5bf-d66abcfbe70b",
    "slug": "qkjgjQCQGz",
    "title": "Pricing page v2",
    "filename": "pricing.html",
    "visibility": "workspace",
    "createdAt": 1793107356443,
    "url": "https://content.ollav.app/p/qkjgjQCQGz"
  }
]
```

### Upload (share) an HTML document {#shareHtml}

`POST /api/documents`

MCP tool: [`share_html`](https://ollav.app/docs/mcp#share_html)

Publishes one self-contained HTML file and returns its share url. Give that url to the user. Send JSON `{html, title?, filename?, visibility?}`, multipart/form-data with a `file` field, or a raw text/html body (title, filename and visibility then come from the query string or X-Title/X-Filename/X-Visibility headers). If you already own a document with the same filename and the content changed, this adds a version to it (201, versioned: true); identical content returns the existing version (200, deduped: true). To update a known document prefer POST /api/documents/{slug}/versions. `warnings` lists relative asset paths that won't load; it never blocks the upload. New documents expire after 90 days unless extended or made permanent. Publishing (this and new versions together) is limited to 30 an hour per account (looser per IP): over it, 429 RATE_LIMITED with error.retry_after in seconds. A multipart upload takes one file (400 TOO_MANY_FILES).

**Request body** (`application/json`, `multipart/form-data`, `text/html`)

| Field | Type | Required | Description |
|---|---|---|---|
| `html` | string | yes | The whole self-contained HTML document, up to 15 MB. |
| `title` | string |  | Defaults to the <title> tag, then the filename. |
| `filename` | string |  | Re-uploading the same filename adds a version to your document. |
| `visibility` | `workspace` \| `link` \| `public` |  | workspace (default): members of the workspace only. link: anyone signed in to Ollav with the URL. public: PUBLIC, anyone with the URL, no sign-in, including people outside the company. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Deduplicated. You already uploaded exactly this content; nothing changed. |
| 201 | A new document, or a new version of the document with the same filename. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 409 | [Conflict](#response-conflict) |
| 413 | [PayloadTooLarge](#response-payload-too-large) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/documents" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent" \
  -H "Content-Type: application/json" \
  -d '{"html":"<!DOCTYPE html><html><head><title>Pricing page v2</title></head><body><h1>Pricing</h1></body></html>","filename":"pricing.html","visibility":"workspace"}'
```

Response (200):

```json
{
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "slug": "qkjgjQCQGz",
  "documentId": "9908f969-9774-4374-a5bf-d66abcfbe70b",
  "versionId": "b8cfa354-0c7c-4102-bf14-69b49de24ca3",
  "hash": "859d1148aaf2854ea2a59a25b89063395b0ded22230bdb4c7aad72d2a83b537a",
  "deduped": true,
  "versioned": false,
  "warnings": [],
  "livemode": true
}
```

### Get a document {#getDocument}

`GET /api/documents/{slug}`

MCP tool: [`get_document`](https://ollav.app/docs/mcp#get_document)

Returns one document's metadata with every stored version (newest first). For the document's owner or a workspace admin; anyone else gets the same 404 as for a missing slug. Archived documents are returned too (archivedAt is set). Versions older than the plan's retention window are hidden, not deleted.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | The document with its versions. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/documents/qkjgjQCQGz" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "id": "9908f969-9774-4374-a5bf-d66abcfbe70b",
  "workspaceId": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
  "slug": "qkjgjQCQGz",
  "title": "Pricing page v2",
  "filename": "pricing.html",
  "ownerId": "e970d2fd-66e4-4fa0-bac2-144f81995e32",
  "ownerEmail": "ada@example.com",
  "visibility": "workspace",
  "currentVersionId": "07120538-9e66-460f-9f26-22f3910a213a",
  "createdAt": 1793107356443,
  "updatedAt": 1793107356781,
  "archivedAt": null,
  "expiresAt": 1800883356443,
  "isPermanent": 0,
  "expiryWarnedAt": null,
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "versions": [
    {
      "id": "07120538-9e66-460f-9f26-22f3910a213a",
      "workspaceId": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
      "documentId": "9908f969-9774-4374-a5bf-d66abcfbe70b",
      "hash": "97602aa74d0b26c2fcc8494d28a82c13430712aeacc549f67b7ce3c1dcf74221",
      "r2Key": "docs/97602aa74d0b26c2fcc8494d28a82c13430712aeacc549f67b7ce3c1dcf74221",
      "size": 53,
      "createdAt": 1793107356781
    }
  ]
}
```

### Change who can view a document {#updateDocumentVisibility}

`PATCH /api/documents/{slug}`

Sets the document's visibility. Only use `public` when the user explicitly asked to share outside their company: anyone with the URL can then view it without signing in. Commenting stays members-only whatever the visibility. Owner or workspace admin; needs the upload scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `visibility` | `workspace` \| `link` \| `public` | yes | workspace (default): members of the workspace only. link: anyone signed in to Ollav with the URL. public: PUBLIC, anyone with the URL, no sign-in, including people outside the company. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Updated. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X PATCH "https://app.ollav.app/api/documents/qkjgjQCQGz" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent" \
  -H "Content-Type: application/json" \
  -d '{"visibility":"link"}'
```

Response (200):

```json
{
  "ok": true,
  "visibility": "link"
}
```

### Delete a document (session only) {#deleteDocument}

`DELETE /api/documents/{slug}`

Needs a signed-in browser session; an API key gets 401.

Permanently deletes the document, its versions, comments, reactions and images, and erases stored files nothing else references. Needs a signed-in browser session and the document's title or slug typed in `confirm`; an API key gets 403 FORBIDDEN. Agents: archive instead (POST /api/documents/{slug}/archive), or ask the user to delete it in the web UI.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `confirm` | string | yes | The document's title or slug. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Deleted. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X DELETE "https://app.ollav.app/api/documents/qkjgjQCQGz" \
  --cookie "ollav_session=$OLLAV_SESSION" \
  -H "X-Ollav-Client: agent" \
  -H "Content-Type: application/json" \
  -d '{"confirm":"qkjgjQCQGz"}'
```

Response (200):

```json
{
  "deleted": true,
  "r2": {
    "deleted": 3,
    "pending": 0
  }
}
```

### Archive a document {#archiveDocument}

`POST /api/documents/{slug}/archive`

Takes the document offline: its URL answers 410 and it leaves listings. Nothing is deleted; undo with POST /api/documents/{slug}/unarchive. Owner or workspace admin; needs the upload scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Archived. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/documents/qkjgjQCQGz/archive" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent"
```

Response (200):

```json
{
  "ok": true,
  "archived": true
}
```

### Unarchive a document {#unarchiveDocument}

`POST /api/documents/{slug}/unarchive`

Brings an archived document back online at the same URL. Counts against the plan's document limit again, so it can answer 402.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Restored. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/documents/qkjgjQCQGz/unarchive" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent"
```

Response (200):

```json
{
  "ok": true,
  "archived": false
}
```

### Extend, set or remove a document's expiry {#setDocumentExpiry}

`POST /api/documents/{slug}/expiry`

Documents are archived automatically when they expire (90 days after upload by default). `extend` sets expiry to 90 days from now, `permanent` removes it, `custom` sets `expiresAt` (Unix ms, in the future). Test-mode documents are deleted 30 days after upload: for them `extend` sets that date, and `permanent` or a later `custom` date is a 400. Owner or workspace admin; needs the upload scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `action` | `extend` \| `permanent` \| `custom` | yes |  |
| `expiresAt` | integer |  | Unix ms; required when action is custom. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | The new expiry. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/documents/qkjgjQCQGz/expiry" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent" \
  -H "Content-Type: application/json" \
  -d '{"action":"extend"}'
```

Response (200):

```json
{
  "ok": true,
  "isPermanent": false,
  "expiresAt": 1800883357891
}
```

### List documents with version and comment counts {#listDocumentsWithCounts}

`GET /api/dashboard`

MCP tool: [`list_documents`](https://ollav.app/docs/mcp#list_documents)

Your documents in the key's workspace, newest first, with version, comment and unresolved-comment counts: the quickest way to find which document has open feedback. Includes archived documents (archivedAt set). `sharedWithMe` is filled for browser sessions only.

**Responses**

| Status | Meaning |
|---|---|
| 200 | The key's workspace and its documents. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/dashboard" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "workspace": {
    "id": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
    "name": "Acme",
    "role": "owner"
  },
  "documents": [
    {
      "id": "9908f969-9774-4374-a5bf-d66abcfbe70b",
      "slug": "qkjgjQCQGz",
      "title": "Pricing page v2",
      "filename": "pricing.html",
      "visibility": "workspace",
      "createdAt": 1793107356443,
      "updatedAt": 1793107357969,
      "archivedAt": null,
      "expiresAt": 1800883356443,
      "isPermanent": 0,
      "versionCount": 3,
      "commentCount": 2,
      "unresolvedCount": 1,
      "url": "https://content.ollav.app/p/qkjgjQCQGz"
    }
  ],
  "sharedWithMe": []
}
```

## Versions {#tag-versions}

Every upload of new content is a version. The URL never changes; comments on earlier versions are kept and marked as such.

### List a document's versions {#listVersions}

`GET /api/documents/{slug}/versions`

Every visible version, newest first, numbered from 1 (the first upload), with which one is current. Versions older than the plan's retention window are hidden, not deleted. Owner or workspace admin; needs the read scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | The versions. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/documents/qkjgjQCQGz/versions" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "document": {
    "slug": "qkjgjQCQGz",
    "title": "Pricing page v2",
    "versionCount": 2
  },
  "versions": [
    {
      "id": "07120538-9e66-460f-9f26-22f3910a213a",
      "hash": "97602aa74d0b26c2fcc8494d28a82c13430712aeacc549f67b7ce3c1dcf74221",
      "size": 53,
      "createdAt": "2026-10-27T13:22:36.781Z",
      "isCurrent": true,
      "versionNumber": 2
    },
    {
      "id": "b8cfa354-0c7c-4102-bf14-69b49de24ca3",
      "hash": "859d1148aaf2854ea2a59a25b89063395b0ded22230bdb4c7aad72d2a83b537a",
      "size": 99,
      "createdAt": "2026-10-27T13:22:36.443Z",
      "isCurrent": false,
      "versionNumber": 1
    }
  ]
}
```

### Publish a new version {#publishNewVersion}

`POST /api/documents/{slug}/versions`

MCP tool: [`publish_new_version`](https://ollav.app/docs/mcp#publish_new_version)

Replaces what the document's URL shows with new HTML; the URL stays the same and earlier comments are kept, marked "earlier version". Use this after editing in response to feedback, then resolve the threads you fixed. Same body formats as POST /api/documents. Identical content to the current version returns 200 with deduped: true. Needs the upload scope; archived documents answer 404 ARCHIVED, documents taken down for breaking the Terms 410 DOCUMENT_REMOVED. Counts towards the 30 publishes an hour per account (429 RATE_LIMITED).

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Request body** (`application/json`, `multipart/form-data`, `text/html`)

| Field | Type | Required | Description |
|---|---|---|---|
| `html` | string | yes | The full new HTML. |
| `title` | string |  | Overrides the <title> tag. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Deduplicated. Same content as the current version; nothing changed. |
| 201 | New version published. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 410 | [Gone](#response-gone) |
| 413 | [PayloadTooLarge](#response-payload-too-large) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/documents/qkjgjQCQGz/versions" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent" \
  -H "Content-Type: application/json" \
  -d '{"html":"<!DOCTYPE html><html><head><title>Pricing page v3</title></head><body><h1 style='\''color:blue'\''>Pricing</h1></body></html>"}'
```

Response (200):

```json
{
  "deduped": true,
  "slug": "qkjgjQCQGz",
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "versionId": "f2d1c6e2-6960-488b-a198-6d4f33ab1841",
  "hash": "c0fb1a4309519139808296b940c600f23fa26a67fd1732f7610528fd95298909",
  "warnings": [],
  "livemode": true
}
```

## Comments {#tag-comments}

Element-anchored comments and single-level replies. Top-level comments carry the CSS selector and bounding rect of the element they pin to.

### List a document's comments {#getComments}

`GET /api/comments`

MCP tool: [`get_comments`](https://ollav.app/docs/mcp#get_comments)

Every comment thread on the document, resolved ones included, with replies, reactions and image attachments. For acting on feedback prefer GET /api/review, which returns open threads shaped for finding the element in your source. Needs the read scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | query | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Threads, oldest first. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/comments?slug=qkjgjQCQGz" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "slug": "qkjgjQCQGz",
  "currentVersionId": "f2d1c6e2-6960-488b-a198-6d4f33ab1841",
  "docOwnerId": "e970d2fd-66e4-4fa0-bac2-144f81995e32",
  "viewerCanManage": true,
  "count": 1,
  "unresolvedCount": 1,
  "comments": [
    {
      "id": "e7cb98b7-c5e3-4048-8590-e7c1b1dea787",
      "documentId": "9908f969-9774-4374-a5bf-d66abcfbe70b",
      "versionId": "f2d1c6e2-6960-488b-a198-6d4f33ab1841",
      "parentId": null,
      "selector": "h1",
      "fallbackRect": {
        "x": 24,
        "y": 80,
        "w": 640,
        "h": 48
      },
      "elementText": "Pricing",
      "authorId": "e970d2fd-66e4-4fa0-bac2-144f81995e32",
      "body": "Make the headline blue",
      "createdAt": "2026-10-27T13:22:38.297Z",
      "resolvedAt": null,
      "resolvedBy": null,
      "author": {
        "email": "ada@example.com",
        "name": "Ada"
      },
      "isCurrentVersion": true,
      "reproduceNote": null,
      "breadcrumbs": null,
      "markEmoji": null,
      "attachment": null,
      "snapshotUrl": null,
      "reactions": [
        {
          "reaction": "👍",
          "count": 1,
          "mine": false
        }
      ],
      "replies": []
    }
  ]
}
```

### Post a comment or reply {#addComment}

`POST /api/comments`

MCP tool: [`add_comment`](https://ollav.app/docs/mcp#add_comment)

A top-level comment pins to an element: send `selector` (CSS) and `rect` (its bounding box, page coordinates). A reply sends `parentId` (a top-level comment's id) and no selector or rect; replies are one level deep. `markEmoji` without `body` places an emoji mark as a reaction to the element. Members only, whatever the document's visibility; needs the comment scope. Limited to 120 comments and replies an hour per account (looser per IP): over it, 429 RATE_LIMITED with error.retry_after in seconds.

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `slug` | string | yes |  |
| `body` | string |  | Required, unless a top-level comment sets markEmoji. |
| `parentId` | string |  | Set for a reply (a top-level comment's id); omit for a top-level comment. |
| `selector` | string |  | CSS selector of the element. Required for top-level comments. |
| `rect` | Rect |  | Bounding box of the element, in page pixels. |
| `elementText` | string |  | The element's visible text; helps re-anchor and find it in source. |
| `markEmoji` | string or null |  | One emoji to show as the pin (top-level only); null for a numbered pin. |
| `reproduceNote` | string |  | How to reach the UI state, for reviewers (top-level only). |
| `breadcrumbs` | array of Breadcrumb |  | Clicks that lead to the UI state (top-level only). |

**Responses**

| Status | Meaning |
|---|---|
| 201 | Created. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/comments" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent" \
  -H "Content-Type: application/json" \
  -d '{"slug":"qkjgjQCQGz","body":"Make the headline blue","selector":"h1","rect":{"x":24,"y":80,"w":640,"h":48},"elementText":"Pricing"}'
```

Response (201):

```json
{
  "id": "e7cb98b7-c5e3-4048-8590-e7c1b1dea787",
  "workspaceId": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
  "documentId": "9908f969-9774-4374-a5bf-d66abcfbe70b",
  "versionId": "f2d1c6e2-6960-488b-a198-6d4f33ab1841",
  "parentId": null,
  "selector": "h1",
  "fallbackRect": {
    "x": 24,
    "y": 80,
    "w": 640,
    "h": 48
  },
  "elementText": "Pricing",
  "authorId": "e970d2fd-66e4-4fa0-bac2-144f81995e32",
  "body": "Make the headline blue",
  "createdAt": "2026-10-27T13:22:38.297Z",
  "resolvedAt": null,
  "resolvedBy": null,
  "reproduceNote": null,
  "breadcrumbs": null,
  "markEmoji": null,
  "author": {
    "email": "ada@example.com",
    "name": "Ada"
  }
}
```

### Delete a comment {#deleteComment}

`DELETE /api/comments/{id}`

MCP tool: [`delete_comment`](https://ollav.app/docs/mcp#delete_comment)

Deletes a comment; deleting a top-level comment deletes its replies. Only the comment's author or the document's owner. Anyone else, and unknown ids, get the same 404. Needs the comment scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | A comment id, from GET /api/review or GET /api/comments. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Deleted. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X DELETE "https://app.ollav.app/api/comments/e7cb98b7-c5e3-4048-8590-e7c1b1dea787" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent"
```

Response (200):

```json
{
  "deleted": true
}
```

### Resolve a comment thread {#resolveComment}

`POST /api/comments/{id}/resolve`

MCP tool: [`resolve_comment`](https://ollav.app/docs/mcp#resolve_comment)

Marks a top-level comment resolved. Do this after publishing a version that addresses it, and reply saying what changed. The comment's author or the document's owner; needs the comment scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | A comment id, from GET /api/review or GET /api/comments. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Resolved. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/comments/e7cb98b7-c5e3-4048-8590-e7c1b1dea787/resolve" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent"
```

Response (200):

```json
{
  "ok": true,
  "resolved": true
}
```

### Reopen a comment thread {#unresolveComment}

`POST /api/comments/{id}/unresolve`

MCP tool: [`resolve_comment`](https://ollav.app/docs/mcp#resolve_comment)

Reopens a resolved top-level comment. Same rules as resolving (MCP: resolve_comment with resolved: false).

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | A comment id, from GET /api/review or GET /api/comments. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Reopened. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/comments/e7cb98b7-c5e3-4048-8590-e7c1b1dea787/unresolve" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent"
```

Response (200):

```json
{
  "ok": true,
  "resolved": false
}
```

### Attach an image to a comment {#attachImage}

`POST /api/comments/{id}/attachments`

Attaches ONE image (a screenshot) to a top-level comment you wrote, or on a document you own. multipart/form-data with a `file` field, or the raw image as the body. PNG, JPEG, WebP or GIF, checked by content (SVG is refused), up to 5 MB. Served from the content origin to those who can view the document. Needs the comment scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | A comment id, from GET /api/review or GET /api/comments. |

**Request body** (`multipart/form-data`, `image/png`)

| Field | Type | Required | Description |
|---|---|---|---|
| `file` | string (binary) | yes |  |
| `width` | integer |  | Display width in px (optional). |
| `height` | integer |  | Display height in px (optional). |

**Responses**

| Status | Meaning |
|---|---|
| 201 | Attached. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 413 | [PayloadTooLarge](#response-payload-too-large) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/comments/e7cb98b7-c5e3-4048-8590-e7c1b1dea787/attachments" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent" \
  -F file=@screenshot.png
```

Response (201):

```json
{
  "id": "b8993718-40ff-49bc-aed5-cf0040a4105e",
  "url": "https://content.ollav.app/a/b8993718-40ff-49bc-aed5-cf0040a4105e?vt=eyJ...",
  "mime": "image/png",
  "width": 1280,
  "height": 720,
  "size": 48213
}
```

## Reactions {#tag-reactions}

Emoji reactions on comments and replies.

### Add or remove a reaction {#reactToComment}

`POST /api/comments/{id}/reactions`

MCP tool: [`react_to_comment`](https://ollav.app/docs/mcp#react_to_comment)

Adds (on: true, the default) or removes (on: false) your reaction on a comment or reply. Any single emoji; the legacy names +1, eyes, check and heart still work. A comment holds at most 20 different reactions. Anyone who may comment on the document; needs the comment scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | A comment id, from GET /api/review or GET /api/comments. |

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `reaction` | string | yes | One emoji, e.g. 👍. |
| `on` | boolean |  |  |

**Responses**

| Status | Meaning |
|---|---|
| 200 | The comment's reactions after the change. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/comments/e7cb98b7-c5e3-4048-8590-e7c1b1dea787/reactions" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent" \
  -H "Content-Type: application/json" \
  -d '{"reaction":"👍"}'
```

Response (200):

```json
{
  "ok": true,
  "commentId": "e7cb98b7-c5e3-4048-8590-e7c1b1dea787",
  "reactions": [
    {
      "reaction": "👍",
      "count": 1,
      "mine": true
    }
  ]
}
```

## Review {#tag-review}

The agent feedback loop: read open threads shaped for editing source, publish a new version, resolve what you fixed.

### Read feedback to act on {#getReview}

`GET /api/review`

MCP tool: [`get_review`](https://ollav.app/docs/mcp#get_review)

The feedback digest for an iterating agent: open threads by default, each with a source-findable anchor (quote = the element's text, reproduceNote, breadcrumbs). `selector` is the rendered DOM, not your source; search your source for `quote`. Pass the response's `cursor` back as `since` to get only threads with new activity. format=markdown returns the same digest as text/markdown. Loop: get_review → edit → publish_new_version → resolve_comment. Needs the read scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | query | string | yes | The document's slug, from the upload response or GET /api/dashboard. |
| `format` | query | `json` \| `markdown` |  | json (default) or markdown (text/markdown, the same threads as a readable summary). |
| `status` | query | `open` \| `resolved` \| `all` |  | Which threads to return. |
| `since` | query | string |  | ISO 8601 or epoch ms. Only threads with activity after this; pass the previous response's cursor. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | The digest. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/review?slug=qkjgjQCQGz" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "slug": "qkjgjQCQGz",
  "title": "Pricing page v2",
  "url": "https://content.ollav.app/p/qkjgjQCQGz",
  "currentVersionId": "f2d1c6e2-6960-488b-a198-6d4f33ab1841",
  "filter": {
    "status": "open",
    "since": null
  },
  "totalThreads": 1,
  "openThreads": 1,
  "returned": 1,
  "cursor": "2026-10-27T13:22:38.402Z",
  "generatedAt": "2026-10-27T13:22:38.836Z",
  "note": "anchor.selector targets the rendered DOM; to locate the element in your SOURCE (JSX/HTML) prefer anchor.quote and anchor.reproduceNote.",
  "threads": [
    {
      "id": "e7cb98b7-c5e3-4048-8590-e7c1b1dea787",
      "status": "open",
      "onCurrentVersion": true,
      "versionId": "f2d1c6e2-6960-488b-a198-6d4f33ab1841",
      "author": {
        "email": "ada@example.com",
        "name": "Ada"
      },
      "createdAt": "2026-10-27T13:22:38.297Z",
      "lastActivityAt": "2026-10-27T13:22:38.402Z",
      "comment": "Make the headline blue",
      "markEmoji": null,
      "attachment": null,
      "anchor": {
        "quote": "Pricing",
        "reproduceNote": null,
        "breadcrumbs": null,
        "selector": "h1"
      },
      "replies": []
    }
  ],
  "markdown": "# Review: Pricing page v2\n\n..."
}
```

## Exports {#tag-exports}

Data exports (GDPR Art. 15/20) as JSON or a streamed ZIP.

### Export one document as JSON {#exportDocument}

`GET /api/documents/{slug}/export`

One JSON bundle: metadata, every version with its HTML inlined, and every comment. For everything in an account or workspace (with images and snapshots) use POST /api/exports. Owner or workspace admin; needs the read scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | path | string | yes | The document's slug, from the upload response or GET /api/dashboard. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | The bundle. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/documents/qkjgjQCQGz/export" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "exportedAt": "2026-10-27T13:22:38.141Z",
  "document": {
    "id": "9908f969-9774-4374-a5bf-d66abcfbe70b",
    "slug": "qkjgjQCQGz",
    "title": "Pricing page v2",
    "filename": "pricing.html",
    "visibility": "workspace",
    "createdAt": "2026-10-27T13:22:36.443Z",
    "updatedAt": "2026-10-27T13:22:37.969Z",
    "archivedAt": null,
    "expiresAt": null,
    "isPermanent": true,
    "url": "https://content.ollav.app/p/qkjgjQCQGz"
  },
  "versions": [
    {
      "id": "f2d1c6e2-6960-488b-a198-6d4f33ab1841",
      "hash": "c0fb1a4309519139808296b940c600f23fa26a67fd1732f7610528fd95298909",
      "r2Key": "docs/c0fb1a4309519139808296b940c600f23fa26a67fd1732f7610528fd95298909",
      "size": 35,
      "createdAt": "2026-10-27T13:22:37.459Z",
      "isCurrent": true,
      "html": "<html><body><p>v3</p></body></html>"
    }
  ],
  "comments": [
    {
      "id": "e7cb98b7-c5e3-4048-8590-e7c1b1dea787",
      "parentId": null,
      "selector": "h1",
      "elementText": "Pricing",
      "authorId": "e970d2fd-66e4-4fa0-bac2-144f81995e32",
      "body": "Make the headline blue",
      "createdAt": "2026-10-27T13:22:38.297Z",
      "resolvedAt": null,
      "resolvedBy": null,
      "versionId": "f2d1c6e2-6960-488b-a198-6d4f33ab1841"
    }
  ]
}
```

### Export data as a ZIP {#exportData}

`POST /api/exports`

MCP tool: [`export_data`](https://ollav.app/docs/mcp#export_data)

Returns a signed download link, valid 15 minutes, to a ZIP streamed on demand. scope `account`: your profile, memberships, owned documents with every version, your comments and reactions (an API key: its own workspace only, partial: true). scope `workspace`: everything in the workspace (owner only). manifest.json in the ZIP lists every item. Needs the read scope; limited to a few exports per hour (429).

**Request body** (`application/json`)

| Field | Type | Required | Description |
|---|---|---|---|
| `scope` | `account` \| `workspace` |  |  |
| `workspaceId` | string |  | Default: the key's workspace. |

**Responses**

| Status | Meaning |
|---|---|
| 201 | The download link. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X POST "https://app.ollav.app/api/exports" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent" \
  -H "Content-Type: application/json" \
  -d '{"scope":"workspace"}'
```

Response (201):

```json
{
  "scope": "workspace",
  "workspaceId": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
  "downloadUrl": "https://app.ollav.app/api/exports/download?token=eyJ...",
  "expiresAt": 1793108258236,
  "partial": false
}
```

### Download an export {#downloadExport}

`GET /api/exports/download`

No API key: the signed link is the credential.

The `downloadUrl` returned by POST /api/exports. The signed token is the credential (no API key needed); access is re-checked when the download starts. Streams application/zip. Expired or invalid tokens get 404: request a new export.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `token` | query | string | yes | The signed token from downloadUrl. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | The ZIP (Content-Disposition attachment). |
| 400 | [BadRequest](#response-bad-request) |
| 404 | [NotFound](#response-not-found) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/exports/download?token=eyJ1aWQiOiJlOTcw..."
```

## Workspaces {#tag-workspaces}

Workspaces own documents. An API key is pinned to one workspace; managing workspaces (create, invite, remove) needs a signed-in browser session.

### List workspaces {#listWorkspaces}

`GET /api/workspaces`

The workspaces the caller belongs to (an API key: its user's memberships) and which is current; for an API key the current one is always the key's own. `billingNotice` is set when the current workspace's payment failed or it is over its plan's limits.

**Responses**

| Status | Meaning |
|---|---|
| 200 | Workspaces. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/workspaces" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "currentId": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
  "workspaces": [
    {
      "id": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
      "name": "Acme",
      "personal": false,
      "role": "owner"
    }
  ],
  "billingNotice": null
}
```

### List a workspace's members {#listWorkspaceMembers}

`GET /api/workspaces/{id}/members`

Members with their roles (owner > admin > member > guest) and the pending invites the caller may see. Guests and non-members get 404. Inviting and removing members needs the web UI.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | A workspace id, from GET /api/workspaces or GET /api/me. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Members and pending invites. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 404 | [NotFound](#response-not-found) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/workspaces/0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90/members" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "workspaceId": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
  "role": "owner",
  "members": [
    {
      "userId": "e970d2fd-66e4-4fa0-bac2-144f81995e32",
      "email": "ada@example.com",
      "name": "Ada",
      "role": "owner",
      "joinedAt": 1793107356443
    }
  ],
  "invites": []
}
```

## Billing {#tag-billing}

Read-only plan, usage and limits for a workspace. Plan changes happen in the web UI through Stripe Checkout and the Customer Portal.

### Get a workspace's plan, usage and limits {#getWorkspaceBilling}

`GET /api/workspaces/{id}/billing`

The workspace's plan, subscription, usage and limits (null = no limit), and which limits it is over. A workspace over a limit is read-only: reads work, writes that grow it answer 402. When billing isn't configured, `enabled` is false and there are no limits. Plan changes happen in the web UI (https://app.ollav.app/pricing). Members of the workspace; needs the read scope.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | A workspace id, from GET /api/workspaces or GET /api/me. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Billing overview. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/workspaces/0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90/billing" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "workspaceId": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
  "enabled": true,
  "role": "owner",
  "canManage": true,
  "plan": "free",
  "subscribedPlan": "free",
  "inGrace": false,
  "graceEndsAt": null,
  "lapsed": false,
  "subscription": null,
  "limits": {
    "documents": 3,
    "members": 1,
    "storageBytes": 52428800,
    "versionRetentionDays": 30,
    "apiCallsPerDay": 100
  },
  "usage": {
    "documents": 1,
    "members": 1,
    "storageBytes": 257,
    "apiCallsToday": 12
  },
  "overLimits": [],
  "readOnly": false,
  "pricingUrl": "https://app.ollav.app/pricing"
}
```

## Account {#tag-account}

Who the API key belongs to.

### Who am I {#getMe}

`GET /api/me`

The user the API key belongs to and the workspace it acts in. A cheap way to check that a key works.

**Responses**

| Status | Meaning |
|---|---|
| 200 | The caller. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/me" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "user": {
    "id": "e970d2fd-66e4-4fa0-bac2-144f81995e32",
    "email": "ada@example.com",
    "name": "Ada"
  },
  "workspace": {
    "id": "0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90",
    "name": "Acme",
    "role": "owner"
  },
  "livemode": true
}
```

## API keys {#tag-api-keys}

Keys are created and revoked in the web UI (https://app.ollav.app/keys). The API lists them by prefix only; a key's secret is shown once, at creation.

### List API keys {#listApiKeys}

`GET /api/keys`

The caller's API keys in the current workspace, by prefix only (a key is shown in full once, when created). Create and revoke keys at https://app.ollav.app/keys.

**Responses**

| Status | Meaning |
|---|---|
| 200 | Keys, newest first. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl "https://app.ollav.app/api/keys" \
  -H "Authorization: Bearer $OLLAV_API_KEY"
```

Response (200):

```json
{
  "keys": [
    {
      "id": "00d97554-4eb5-4d98-b0da-b88d78d6f88e",
      "keyPrefix": "ol_live_mSos",
      "name": "Claude Code",
      "scopes": [
        "upload",
        "read",
        "comment"
      ],
      "createdAt": 1793107356443,
      "lastUsedAt": 1793107360365,
      "revokedAt": null,
      "apiVersion": "2026-11-01",
      "mode": "live"
    }
  ]
}
```

### Revoke an API key {#revokeApiKey}

`DELETE /api/keys/{id}`

Revokes one of the caller's API keys in the current workspace, at once and for good; requests with it then answer 401. A key can revoke itself, e.g. when an agent is done with it. Unknown ids and other people's keys get the same 404. Creating keys needs the web UI (https://app.ollav.app/keys).

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | The key's id, from GET /api/keys. |

**Responses**

| Status | Meaning |
|---|---|
| 200 | Revoked. |
| 400 | [BadRequest](#response-bad-request) |
| 401 | [Unauthorized](#response-unauthorized) |
| 402 | [PlanLimit](#response-plan-limit) |
| 403 | [Forbidden](#response-forbidden) |
| 404 | [NotFound](#response-not-found) |
| 409 | [Conflict](#response-conflict) |
| 429 | [RateLimited](#response-rate-limited) |
| 500 | [InternalError](#response-internal-error) |

```bash
curl -X DELETE "https://app.ollav.app/api/keys/00d97554-4eb5-4d98-b0da-b88d78d6f88e" \
  -H "Authorization: Bearer $OLLAV_API_KEY" \
  -H "X-Ollav-Client: agent"
```

Response (200):

```json
{
  "ok": true
}
```

## Error responses {#error-responses}

The error responses operations share. Each is the JSON error object described in [Basics](#basics); match on `code`, and see [the errors page](https://ollav.app/docs/errors) for every code's cause and fix.

### BadRequest {#response-bad-request}

The request is invalid; error.param names the field and error.hint the fix. Codes: BAD_JSON, BAD_REQUEST, VALIDATION_ERROR, MISSING_HTML, MISSING_FILE, MISSING_SLUG, EMPTY_BODY, EMPTY, NOT_HTML, INVALID_VISIBILITY, UNSUPPORTED_TYPE, UNSUPPORTED_CONTENT_TYPE, TOO_MANY_REACTIONS, CONFIRMATION_REQUIRED, INVALID_API_VERSION (an unknown Ollav-Version) and IDEMPOTENCY_KEY_INVALID.

### Unauthorized {#response-unauthorized}

No valid API key (AUTH_REQUIRED). The key is missing, wrong or revoked, or its user left the key's workspace.

### PlanLimit {#response-plan-limit}

The workspace's plan doesn't allow this (PLAN_LIMIT), it is over its limits and read-only (PLAN_READ_ONLY), or the key's workspace used its daily API calls. Tell the user; don't retry.

### Forbidden {#response-forbidden}

Not allowed: the key lacks a scope (SCOPE_REQUIRED), the X-Ollav-Client header is missing (CSRF_REQUIRED), a browser Origin is foreign (ORIGIN_FORBIDDEN), the caller's role is too low (ROLE_REQUIRED), or the action needs a browser session (FORBIDDEN).

### NotFound {#response-not-found}

Not found (NOT_FOUND), or archived (ARCHIVED). Missing, forbidden and other-workspace resources all return the identical 404 (apart from request_id).

### Conflict {#response-conflict}

Clashes with the current state (CONFLICT, e.g. the comment already has an image), or an Idempotency-Key problem: reused for a different request (IDEMPOTENCY_KEY_REUSED) or still in use by a running request (IDEMPOTENCY_KEY_IN_USE).

### PayloadTooLarge {#response-payload-too-large}

Over the size limit (TOO_LARGE): 15 MB per HTML document, 5 MB per image.

### RateLimited {#response-rate-limited}

Too many requests (RATE_LIMITED): over 30 publishes or 120 comments an hour per account (looser per IP), too many exports, or a test key (ol_test_) over its 60 requests a minute. error.retry_after and the Retry-After header say how many seconds to wait; don't retry in a loop.

### Gone {#response-gone}

The document was taken down for breaking the Terms of Service (DOCUMENT_REMOVED). Don't retry.

### InternalError {#response-internal-error}

Something failed on our side (INTERNAL_ERROR, type api_error). Retry once with the same Idempotency-Key; if it persists, report error.request_id.

## Machine-readable {#machine-readable}

- [OpenAPI 3.1](https://ollav.app/openapi.json): the source of this page
- [This page as Markdown](https://ollav.app/docs/api.md)
- [llms.txt](https://ollav.app/llms.txt): the Ollav docs index for LLMs
