# Errors

Every `/api` error has the same JSON shape and a matching HTTP status. Match on `code`: codes are stable and are the closed list on this page. `type` groups them for coarse handling. `hint` says what to do next, written for an agent to act on. `request_id` is also in the `Request-Id` header of every response; quote it when reporting a problem.

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "INVALID_VISIBILITY",
    "message": "visibility must be one of: workspace, link, public",
    "param": "visibility",
    "hint": "visibility must be 'workspace' (default), 'link' or 'public'. Only use 'public' if the user asked to share outside the company.",
    "doc_url": "https://ollav.app/docs/errors#INVALID_VISIBILITY",
    "request_id": "req_6f1c2a9b0d4e8f7a1b2c3d4e"
  }
}
```

| Field | Meaning |
|---|---|
| `type` | One of the 7 types below |
| `code` | What went wrong, machine-readable and stable |
| `message` | What went wrong, for people and models |
| `param` | The field or header at fault, when there is one |
| `hint` | What to do next |
| `doc_url` | This page, at the code's anchor |
| `request_id` | The request, for support (also the `Request-Id` header) |
| `docs` | Older link to the agent quickstart's error section, kept for existing clients |

Plan-limit errors (402) also carry `limit` and `plan`. MCP tool errors carry the same `code` and `doc_url` in the tool result.

## Retrying safely {#retrying}

Send an `Idempotency-Key` header (any unique string, e.g. a UUID) on every POST, PATCH and DELETE. If a request times out or fails with an `api_error` or `rate_limit_error`, retry it with the same key and body: it runs at most once, and a retry of a finished request returns the first response with `Idempotent-Replayed: true`. An error response doesn't use the key up: fix the request, or wait, and retry with the same key. Keys last 24 hours. Don't retry `invalid_request_error`, `authentication_error`, `permission_error` or `not_found_error` unchanged: they fail the same way again.

## Types {#types}

| Type | Meaning |
|---|---|
| [`invalid_request_error`](#invalid_request_error) | The request is wrong: a missing or invalid field, header or body. Fix the request; retrying it unchanged fails again. |
| [`authentication_error`](#authentication_error) | No valid credential. Check the API key. |
| [`permission_error`](#permission_error) | The caller may not do this: a scope, role, plan limit or a browser-session-only action. Tell the user; don't retry. |
| [`not_found_error`](#not_found_error) | No such resource for this caller. Missing and forbidden look the same, on purpose. |
| [`rate_limit_error`](#rate_limit_error) | Too many requests. Wait, then retry once. |
| [`idempotency_error`](#idempotency_error) | An Idempotency-Key problem: invalid, reused for a different request, or still in use. |
| [`api_error`](#api_error) | Something failed on Ollav's side. Retry once with the same Idempotency-Key; quote request_id if it persists. |

## invalid_request_error {#invalid_request_error}

The request is wrong: a missing or invalid field, header or body. Fix the request; retrying it unchanged fails again.

### `CSRF_REQUIRED` {#CSRF_REQUIRED}

HTTP 403 · param `X-Ollav-Client`

**Cause.** A state-changing request (POST, PATCH, DELETE) arrived without the X-Ollav-Client header.

**Fix.** Add the header 'X-Ollav-Client: agent' to every POST, PATCH and DELETE request, then retry.

### `LOGIN_CODE_INVALID` {#LOGIN_CODE_INVALID}

HTTP 400 · param `code`

**Cause.** The sign-in code is wrong, expired (codes last 15 minutes), already used, replaced by a newer email, or spent by 5 wrong tries. These look the same on purpose.

**Fix.** Check the code in the latest sign-in email. If it still fails, send a new code from https://app.ollav.app/signin.

### `BAD_JSON` {#BAD_JSON}

HTTP 400

**Cause.** The body isn't valid JSON.

**Fix.** The request body isn't valid JSON. Send Content-Type: application/json with a JSON object.

### `BAD_REQUEST` {#BAD_REQUEST}

HTTP 400

**Cause.** The request is malformed; the message says how.

**Fix.** The request is malformed. Check the body against https://ollav.app/openapi.json.

### `VALIDATION_ERROR` {#VALIDATION_ERROR}

HTTP 400

**Cause.** A field has an invalid value; the message and error.param name it.

**Fix.** A field has an invalid value; the message names it. Valid shapes are in https://ollav.app/openapi.json.

### `MISSING_HTML` {#MISSING_HTML}

HTTP 400 · param `html`

**Cause.** A JSON upload has no 'html' field.

**Fix.** JSON uploads need an 'html' string field: {"html": "<!DOCTYPE html>..."}. For files on disk use -F file=@page.html instead.

### `MISSING_FILE` {#MISSING_FILE}

HTTP 400 · param `file`

**Cause.** A multipart upload has no 'file' field.

**Fix.** Multipart uploads need the file in a field named 'file': -F file=@page.html.

### `MISSING_SLUG` {#MISSING_SLUG}

HTTP 400 · param `slug`

**Cause.** The document slug is missing.

**Fix.** Pass the document slug as ?slug=... (from the upload response or list_documents).

### `EMPTY_BODY` {#EMPTY_BODY}

HTTP 400 · param `html`

**Cause.** The HTML is empty.

**Fix.** The HTML was empty. Send the file with -F file=@page.html, a JSON body {"html": "..."}, or a text/html body.

### `EMPTY` {#EMPTY}

HTTP 400 · param `file`

**Cause.** The uploaded file is empty.

**Fix.** The upload was empty. Send a non-empty file.

### `NOT_HTML` {#NOT_HTML}

HTTP 400 · param `html`

**Cause.** The content doesn't look like HTML.

**Fix.** Send an HTML document (it should contain tags such as <html>, <body> or <div>). Upload a .html file with -F file=@page.html, a JSON body {"html": "..."}, or a text/html body.

### `INVALID_VISIBILITY` {#INVALID_VISIBILITY}

HTTP 400 · param `visibility`

**Cause.** visibility isn't workspace, link or public.

**Fix.** visibility must be 'workspace' (default), 'link' or 'public'. Only use 'public' if the user asked to share outside the company.

### `INVALID_EMAIL` {#INVALID_EMAIL}

HTTP 400 · param `email`

**Cause.** The email address isn't valid.

**Fix.** Send a valid email address.

### `UNSUPPORTED_TYPE` {#UNSUPPORTED_TYPE}

HTTP 400 · param `file`

**Cause.** The image isn't PNG, JPEG, WebP or GIF (checked by content; SVG is refused).

**Fix.** Comment images must be PNG, JPEG, WebP or GIF (SVG is refused), up to 5 MB.

### `UNSUPPORTED_CONTENT_TYPE` {#UNSUPPORTED_CONTENT_TYPE}

HTTP 400 · param `Content-Type`

**Cause.** The upload's Content-Type isn't multipart/form-data, application/json or text/html.

**Fix.** Use multipart/form-data (-F file=@page.html), application/json ({"html": ...}) or text/html.

### `TOO_LARGE` {#TOO_LARGE}

HTTP 413

**Cause.** Over the size limit: 15 MB per HTML document, 5 MB per image.

**Fix.** Ollav hosts one HTML file up to 15 MB. Shrink it: convert inlined PNG screenshots to WebP, downscale images wider than about 2000px, or load large media from absolute https:// URLs. Then upload again.

### `TOO_MANY_FILES` {#TOO_MANY_FILES}

HTTP 400 · param `file`

**Cause.** A multipart upload had more than one file. Each upload is one self-contained HTML file.

**Fix.** Send exactly one file in the 'file' field (-F file=@page.html). Inline the other assets into the HTML, or upload each page as its own document.

### `TOO_MANY_REACTIONS` {#TOO_MANY_REACTIONS}

HTTP 400 · param `reaction`

**Cause.** The comment already has 20 different reactions.

**Fix.** React with an emoji the comment already has, or leave it.

### `CONFIRMATION_REQUIRED` {#CONFIRMATION_REQUIRED}

HTTP 400 · param `confirm`

**Cause.** A deletion wasn't confirmed by typing the name.

**Fix.** Deletions need a signed-in session in the web UI and typing the name to confirm; an API key can't do them.

### `CONFLICT` {#CONFLICT}

HTTP 409

**Cause.** The request clashes with the current state; the message says how (e.g. the comment already has an image).

**Fix.** The thing already exists or the request clashes with the current state; the message says which. Don't retry unchanged.

### `SOLE_OWNER` {#SOLE_OWNER}

HTTP 409

**Cause.** The account is the only owner of a workspace other people use.

**Fix.** Transfer ownership first (POST /api/workspaces/{id}/transfer in the web UI), then delete the account.

### `CONSUMER_DOMAIN` {#CONSUMER_DOMAIN}

HTTP 400 · param `domain`

**Cause.** Public email providers (gmail.com and the like) can't be claimed as a workspace domain.

**Fix.** Claim your organisation's own domain instead.

### `TOO_MANY_MEMBERS` {#TOO_MANY_MEMBERS}

HTTP 409

**Cause.** The chosen plan is for fewer members than the workspace has.

**Fix.** Choose a plan that fits the workspace's members (Team is billed per member).

### `ALREADY_SUBSCRIBED` {#ALREADY_SUBSCRIBED}

HTTP 409

**Cause.** The workspace already has a subscription.

**Fix.** Change plans from Manage billing in the web UI instead of starting a new checkout.

### `NO_SUBSCRIPTION` {#NO_SUBSCRIPTION}

HTTP 409

**Cause.** The workspace has no billing account yet.

**Fix.** Upgrade first; the billing portal exists once there is a subscription.

### `METHOD_NOT_ALLOWED` {#METHOD_NOT_ALLOWED}

HTTP 405

**Cause.** Wrong HTTP method for this path.

**Fix.** Wrong HTTP method for this path. See https://ollav.app/openapi.json.

### `INVALID_API_VERSION` {#INVALID_API_VERSION}

HTTP 400 · param `Ollav-Version`

**Cause.** The Ollav-Version header names no API version.

**Fix.** Send one of the versions listed at https://ollav.app/docs/changelog in Ollav-Version, or leave the header out to use your key's pinned version.

## authentication_error {#authentication_error}

No valid credential. Check the API key.

### `AUTH_REQUIRED` {#AUTH_REQUIRED}

HTTP 401 · param `Authorization`

**Cause.** No valid credential: the API key is missing, wrong or revoked, or its user left the key's workspace.

**Fix.** Send the API key as 'Authorization: Bearer ol_live_...' (or ol_test_...). If you did, the key is wrong, revoked (rotating a key revokes the old one), or its user left the key's workspace: ask the user for a new key from https://app.ollav.app/keys.

### `ONBOARDING_EXPIRED` {#ONBOARDING_EXPIRED}

HTTP 401

**Cause.** The sign-up step timed out (it lasts 30 minutes).

**Fix.** Sign in again at https://app.ollav.app/signin to finish setting up the account.

### `SIGNATURE_INVALID` {#SIGNATURE_INVALID}

HTTP 400

**Cause.** A Stripe webhook's signature didn't verify.

**Fix.** Only Stripe calls this endpoint. Check STRIPE_WEBHOOK_SECRET matches the endpoint in the Stripe dashboard.

## permission_error {#permission_error}

The caller may not do this: a scope, role, plan limit or a browser-session-only action. Tell the user; don't retry.

### `SCOPE_REQUIRED` {#SCOPE_REQUIRED}

HTTP 403

**Cause.** The API key doesn't have the scope this endpoint needs (upload, read or comment).

**Fix.** This API key lacks a required scope. Ask the user to create a key with upload, read and comment at https://app.ollav.app/keys.

### `ORIGIN_FORBIDDEN` {#ORIGIN_FORBIDDEN}

HTTP 403 · param `Origin`

**Cause.** A browser sent the request from a foreign Origin.

**Fix.** Server-side clients should send no Origin header (curl and agents don't by default). Browsers may only call the API from https://app.ollav.app.

### `ROLE_REQUIRED` {#ROLE_REQUIRED}

HTTP 403

**Cause.** The caller's role in the workspace (owner > admin > member > guest) doesn't allow this.

**Fix.** The user's role in this workspace doesn't allow this. Tell the user rather than retrying; a workspace admin can change roles.

### `FORBIDDEN` {#FORBIDDEN}

HTTP 403

**Cause.** This caller may not do this; the message says why (often: it needs a signed-in browser session, not an API key).

**Fix.** This caller isn't allowed to do that; the message says why. Tell the user rather than retrying.

### `NOT_ALLOWED` {#NOT_ALLOWED}

HTTP 403

**Cause.** Sign-up is invite-only for this address.

**Fix.** Ask an existing Ollav member to invite this email address.

### `NO_WORKSPACE` {#NO_WORKSPACE}

HTTP 409

**Cause.** The account isn't a member of any workspace yet.

**Fix.** This account has no current workspace. The user should sign in at https://app.ollav.app once to finish setup.

### `TERMS_REQUIRED` {#TERMS_REQUIRED}

HTTP 400

**Cause.** The current Terms and Privacy Policy haven't been accepted.

**Fix.** The user must accept the current terms by signing in at https://app.ollav.app.

### `PLAN_LIMIT` {#PLAN_LIMIT}

HTTP 402

**Cause.** The workspace's plan doesn't allow this: a document, member, storage or daily API-call limit. error.limit says which.

**Fix.** This workspace has reached a limit of its plan; the message and 'limit' say which. Tell the user and don't retry.

### `PLAN_READ_ONLY` {#PLAN_READ_ONLY}

HTTP 402

**Cause.** The workspace is over its plan's limits, so it is read-only until it upgrades or makes room.

**Fix.** This workspace is over its plan's limits and is read-only: reading still works, adding doesn't. Tell the user and don't retry.

### `PLAN_REQUIRED` {#PLAN_REQUIRED}

HTTP 403

**Cause.** The feature (e.g. domain auto-join) isn't on the workspace's plan.

**Fix.** Tell the user this needs a plan that includes it; see https://app.ollav.app/pricing.

### `ACCOUNT_SUSPENDED` {#ACCOUNT_SUSPENDED}

HTTP 403

**Cause.** The account was suspended by Ollav for breaking the Terms of Service: sign-in, its API keys and MCP connections are all refused.

**Fix.** This account is suspended. Stop and tell the user; retrying or using another of its keys won't work.

## not_found_error {#not_found_error}

No such resource for this caller. Missing and forbidden look the same, on purpose.

### `NOT_FOUND` {#NOT_FOUND}

HTTP 404

**Cause.** No such resource for this caller. Missing, forbidden and other-workspace resources all look the same.

**Fix.** Check the slug or id. Slugs come from the upload response or list_documents (GET /api/dashboard). An API key only sees its own workspace, so documents elsewhere also return 404, and test and live are separate: an ol_test_ key sees only its own test documents (deleted 30 days after upload), a live key never sees them.

### `ARCHIVED` {#ARCHIVED}

HTTP 404

**Cause.** The document is archived (manually or because it expired).

**Fix.** The document is archived. Restore it with POST /api/documents/{slug}/unarchive (upload scope), or share a new one.

### `VERSION_NOT_FOUND` {#VERSION_NOT_FOUND}

HTTP 404

**Cause.** The version id doesn't belong to this document.

**Fix.** That version id doesn't belong to this document. List versions with GET /api/documents/{slug}/versions.

### `DOCUMENT_REMOVED` {#DOCUMENT_REMOVED}

HTTP 410

**Cause.** The document was taken down by Ollav for breaking the Terms of Service. Its page answers 410 and it takes no new versions.

**Fix.** This document was taken down and can't be published to or restored through the API. Tell the user; don't retry.

### `NO_VERSION` {#NO_VERSION}

HTTP 404

**Cause.** The document has no current version.

**Fix.** The document has no current version. Publish one with POST /api/documents/{slug}/versions.

## rate_limit_error {#rate_limit_error}

Too many requests. Wait, then retry once.

### `RATE_LIMITED` {#RATE_LIMITED}

HTTP 429

**Cause.** Too many requests of this kind in the window: more than 30 publishes or 120 comments an hour per account (looser limits apply per IP address), more than 10 abuse reports an hour per IP, too many exports, or more than 60 requests a minute with one test (ol_test_) key.

**Fix.** Too many requests. Wait error.retry_after seconds (also the Retry-After header; the message says it in words), then retry once with the same Idempotency-Key; don't retry in a loop. A test key allows 60 requests a minute.

### `TOO_MANY_REQUESTS` {#TOO_MANY_REQUESTS}

HTTP 429

**Cause.** Too many requests of this kind in a short window.

**Fix.** Too many requests. Wait before retrying and don't retry in a loop.

### `SIGNIN_RATE_LIMITED` {#SIGNIN_RATE_LIMITED}

HTTP 429

**Cause.** Too many sign-in emails or codes: more than 5 emails to one address, 20 emails from one network, or 30 code tries from one network in an hour.

**Fix.** Wait an hour before asking for another sign-in email (Retry-After says how long, in seconds), or sign in with Google.

## idempotency_error {#idempotency_error}

An Idempotency-Key problem: invalid, reused for a different request, or still in use.

### `IDEMPOTENCY_KEY_INVALID` {#IDEMPOTENCY_KEY_INVALID}

HTTP 400 · param `Idempotency-Key`

**Cause.** The Idempotency-Key header is empty, longer than 255 characters, or has characters outside printable ASCII.

**Fix.** Send 1-255 printable ASCII characters in Idempotency-Key, e.g. a UUID: Idempotency-Key: 5f0c3b1e-....

### `IDEMPOTENCY_KEY_REUSED` {#IDEMPOTENCY_KEY_REUSED}

HTTP 409 · param `Idempotency-Key`

**Cause.** This Idempotency-Key was already used in the last 24 hours with a different method, path or body.

**Fix.** Use a new Idempotency-Key for a new request. Reuse a key only to retry the identical request.

### `IDEMPOTENCY_KEY_IN_USE` {#IDEMPOTENCY_KEY_IN_USE}

HTTP 409 · param `Idempotency-Key`

**Cause.** A request with this Idempotency-Key is still being processed.

**Fix.** Wait a second and retry with the same key and body; you will get the first request's result.

## api_error {#api_error}

Something failed on Ollav's side. Retry once with the same Idempotency-Key; quote request_id if it persists.

### `R2_MISSING` {#R2_MISSING}

HTTP 404

**Cause.** The document's stored file is missing from storage.

**Fix.** Publish the document again (POST /api/documents/{slug}/versions). If it keeps happening, tell the user and include the request id.

### `INTERNAL_ERROR` {#INTERNAL_ERROR}

HTTP 500

**Cause.** Something went wrong on Ollav's side.

**Fix.** Retry once after a few seconds with the same Idempotency-Key. If it keeps failing, tell the user and include error.request_id.

### `PARSE_ERROR` {#PARSE_ERROR}

HTTP 502

**Cause.** The MCP server got a non-JSON response from the REST layer.

**Fix.** Retry once. If it keeps failing, tell the user and include the request id.

### `PROCESSING_FAILED` {#PROCESSING_FAILED}

HTTP 500

**Cause.** A Stripe webhook event couldn't be processed; Stripe retries it.

**Fix.** Nothing to do: Stripe retries the event.

### `STRIPE_ERROR` {#STRIPE_ERROR}

HTTP 502

**Cause.** The payment provider couldn't be reached.

**Fix.** Try again in a minute.

### `BILLING_CANCEL_FAILED` {#BILLING_CANCEL_FAILED}

HTTP 502

**Cause.** The subscription couldn't be cancelled before deleting, so nothing was deleted.

**Fix.** Try again in a minute; if it persists, cancel the plan from Manage billing first.

### `BILLING_NOT_CONFIGURED` {#BILLING_NOT_CONFIGURED}

HTTP 503

**Cause.** Billing isn't set up on this server.

**Fix.** Nothing to buy here: this server has no plans or limits.

### `EMAIL_SEND_FAILED` {#EMAIL_SEND_FAILED}

HTTP 502

**Cause.** The sign-in email couldn't be sent: the email provider refused it or couldn't be reached.

**Fix.** Try again in a minute, or sign in with Google.

### `EMAIL_NOT_CONFIGURED` {#EMAIL_NOT_CONFIGURED}

HTTP 503

**Cause.** Email sign-in isn't set up on this server.

**Fix.** Sign in with Google instead.

### `GOOGLE_NOT_CONFIGURED` {#GOOGLE_NOT_CONFIGURED}

HTTP 404

**Cause.** Google sign-in isn't set up on this server.

**Fix.** Use another sign-in method.
