REST API reference
Every endpoint an API key can call, generated from the OpenAPI 3.1 spec (version 1.3.0), which CI checks against the running API. Prefer MCP from an agent? See the MCP docs. New here? Publish your first prototype with curl.
Basics
- Base URL:
https://app.ollav.app. Shared documents are served fromhttps://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 scopesupload,readandcomment.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 oldersk_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. - Errors are JSON
{"error": {"type", "code", "message", "param", "hint", "doc_url", "request_id"}}. Match oncode; every code is on the errors page. Every response has aRequest-Idheader. - Examples use
$OLLAV_API_KEY; set it to a test key while you build:export OLLAV_API_KEY=ol_test_....
Endpoints
- Documents:
GET /api/documents,POST /api/documents,GET /api/documents/{slug},PATCH /api/documents/{slug},DELETE /api/documents/{slug},POST /api/documents/{slug}/archive,POST /api/documents/{slug}/unarchive,POST /api/documents/{slug}/expiry,GET /api/dashboard - Versions:
GET /api/documents/{slug}/versions,POST /api/documents/{slug}/versions - Comments:
GET /api/comments,POST /api/comments,DELETE /api/comments/{id},POST /api/comments/{id}/resolve,POST /api/comments/{id}/unresolve,POST /api/comments/{id}/attachments - Reactions:
POST /api/comments/{id}/reactions - Review:
GET /api/review - Exports:
GET /api/documents/{slug}/export,POST /api/exports,GET /api/exports/download - Workspaces:
GET /api/workspaces,GET /api/workspaces/{id}/members - Billing:
GET /api/workspaces/{id}/billing - Account:
GET /api/me - API keys:
GET /api/keys,DELETE /api/keys/{id}
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
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/documents" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
[
{
"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
POST /api/documents
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 409 | Conflict |
| 413 | PayloadTooLarge |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"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
GET /api/documents/{slug}
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/documents/qkjgjQCQGz" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"ok": true,
"visibility": "link"
}Delete a document (session only)
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 |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | NotFound |
| 500 | InternalError |
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):
{
"deleted": true,
"r2": {
"deleted": 3,
"pending": 0
}
}Archive a document
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
curl -X POST "https://app.ollav.app/api/documents/qkjgjQCQGz/archive" \
-H "Authorization: Bearer $OLLAV_API_KEY" \
-H "X-Ollav-Client: agent"Response (200):
{
"ok": true,
"archived": true
}Unarchive a document
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
curl -X POST "https://app.ollav.app/api/documents/qkjgjQCQGz/unarchive" \
-H "Authorization: Bearer $OLLAV_API_KEY" \
-H "X-Ollav-Client: agent"Response (200):
{
"ok": true,
"archived": false
}Extend, set or remove a document's expiry
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"ok": true,
"isPermanent": false,
"expiresAt": 1800883357891
}List documents with version and comment counts
GET /api/dashboard
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/dashboard" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
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
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/documents/qkjgjQCQGz/versions" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
POST /api/documents/{slug}/versions
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 410 | Gone |
| 413 | PayloadTooLarge |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"deduped": true,
"slug": "qkjgjQCQGz",
"url": "https://content.ollav.app/p/qkjgjQCQGz",
"versionId": "f2d1c6e2-6960-488b-a198-6d4f33ab1841",
"hash": "c0fb1a4309519139808296b940c600f23fa26a67fd1732f7610528fd95298909",
"warnings": [],
"livemode": true
}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
GET /api/comments
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/comments?slug=qkjgjQCQGz" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
POST /api/comments
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"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
DELETE /api/comments/{id}
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"deleted": true
}Resolve a comment thread
POST /api/comments/{id}/resolve
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"ok": true,
"resolved": true
}Reopen a comment thread
POST /api/comments/{id}/unresolve
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"ok": true,
"resolved": false
}Attach an image to a comment
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 413 | PayloadTooLarge |
| 429 | RateLimited |
| 500 | InternalError |
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.pngResponse (201):
{
"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
Emoji reactions on comments and replies.
Add or remove a reaction
POST /api/comments/{id}/reactions
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"ok": true,
"commentId": "e7cb98b7-c5e3-4048-8590-e7c1b1dea787",
"reactions": [
{
"reaction": "๐",
"count": 1,
"mine": true
}
]
}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
GET /api/review
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/review?slug=qkjgjQCQGz" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
Data exports (GDPR Art. 15/20) as JSON or a streamed ZIP.
Export one document as JSON
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/documents/qkjgjQCQGz/export" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
POST /api/exports
MCP tool: 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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"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
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 |
| 404 | NotFound |
| 500 | InternalError |
curl "https://app.ollav.app/api/exports/download?token=eyJ1aWQiOiJlOTcw..."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
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/workspaces" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 404 | NotFound |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/workspaces/0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90/members" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
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
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/workspaces/0f3c2a8e-1b7d-4c1e-9a52-6d1f0e4b7a90/billing" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
Who the API key belongs to.
Who am I
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/me" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
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
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 429 | RateLimited |
| 500 | InternalError |
curl "https://app.ollav.app/api/keys" \
-H "Authorization: Bearer $OLLAV_API_KEY"Response (200):
{
"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
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 |
| 401 | Unauthorized |
| 402 | PlanLimit |
| 403 | Forbidden |
| 404 | NotFound |
| 409 | Conflict |
| 429 | RateLimited |
| 500 | InternalError |
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):
{
"ok": true
}Error responses
The error responses operations share. Each is the JSON error object described in Basics; match on code, and see the errors page for every code's cause and fix.
BadRequest
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
No valid API key (AUTH_REQUIRED). The key is missing, wrong or revoked, or its user left the key's workspace.
PlanLimit
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
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
Not found (NOT_FOUND), or archived (ARCHIVED). Missing, forbidden and other-workspace resources all return the identical 404 (apart from request_id).
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
Over the size limit (TOO_LARGE): 15 MB per HTML document, 5 MB per image.
RateLimited
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
The document was taken down for breaking the Terms of Service (DOCUMENT_REMOVED). Don't retry.
InternalError
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
- OpenAPI 3.1: the source of this page
- This page as Markdown
- llms.txt: the Ollav docs index for LLMs