docs/api.md
Rama main · réplica de lectura del repositorio original.
# API contract summary
All endpoints are served by the `app` container. Errors use the shape `{ "error": { "code": "...", "message": "..." } }` unless noted. `message` is an English sentence for people and may be reworded; scripts should check `code`. "Auth" = session cookie required; "Admin" = admin session required. Any `Auth`/`Admin` row can also return `401 unauthorized` (missing/invalid session) or, for `Admin` rows, `403 forbidden` (authenticated but not an admin) — these are omitted from the per-row status codes below for brevity.
A few endpoints also accept a Personal API token as `Authorization: Bearer knb_…` — those are marked `Auth / Token(scope)` (see [API tokens](./api-tokens.md)). On those endpoints a request carrying an `Authorization` header is authenticated only by that header and the session cookie is ignored; the two upload endpoints do the same with a transfer token instead (see their rows); the four bulk transfer endpoints take only a transfer token and never the cookie (see **Bulk transfer** below); everywhere else the header is ignored and the cookie is used. These endpoints expose note metadata, the folders you can see (`GET /api/folders`: their names and how they nest) and, through the `/content` and `/edits` rows below, note content — both reading it and writing it (writing needs `notes:write`); see [AI editing](./ai-editing.md) for the fingerprints, the five write operations and revert. Token requests are rate-limited per user separately from browser sessions (300 reads/min, 60 writes/10 min by default; every figure in this document for the token-read, token-write, content-read, edit, search, upload, AI, bulk-download and bulk-write-back budgets is such a default, and a site admin can lower it in Site admin → Site limits, see `/api/admin/limits`); on top of that, `GET /api/notes/:id/content` and `GET /api/notes/:id/edits` share their own, tighter 120-reads-per-minute-per-user budget, and `POST /api/notes/:id/edits`, `POST /api/notes/:id/edits/:editId/revert` and `POST /api/notes` carrying `content` share a 30-writes-per-minute-per-user one (see their rows below; copying a note and saving, applying, renaming and deleting a note version, which are session-only, draw on it too — creating, renaming, moving and deleting folders and filing a note in a folder don't: they draw on a separate folder budget, `FOLDER_LIMIT`=600 per minute per user), so a token calling them is really capped at 120/min and 30/min. Both tighter budgets are keyed by user and apply to browser sessions too — though in practice the web app never calls the content endpoints itself; it reads and writes note content through the live collaboration connection instead. The MCP tools draw on the same 120-reads-per-minute and 30-writes-per-minute budgets: `read_note_outline`, `read_note_section`, `read_note_image` and `create_transfer_token` with `download` on the 120-reads-per-minute one, `edit_note` (except `rename`), `create_note` carrying `content`, `copy_note`, `delete_note` and `restore_note` on the 30-writes-per-minute one; `create_folder`, `rename_folder`, `move_folder`, `move_note_to_folder` and `delete_folder` on the folder budget. `list_notes`, `search_notes`, `list_trash`, `list_folders` and `open_notes_as_files` draw on neither; `search_notes` has its own budget, 60 searches per minute per user, which it shares with `GET /api/search` (browser sessions and tokens alike). Because one `POST /api/mcp` can carry several tool calls, these are counted **per tool call**, not per request (#108). The bulk transfer endpoints have budgets of their own (see **Bulk transfer** below). A runaway client cannot lock you out of the web UI, and invalid Bearer attempts are limited per IP (30/min). Endpoints that accept a token answer `401`/`403` with a `WWW-Authenticate: Bearer …` challenge (with `error="invalid_token"` / `error="insufficient_scope"` when a Bearer token was presented, and no `error` when no credentials or a non-Bearer scheme such as `Basic` were sent); the two upload endpoints answer a `401` to a request **carrying an `Authorization` header** with `WWW-Authenticate: Bearer realm="knotebook-transfer"` (with `error="invalid_token"` when a Bearer token was presented; without `error` for anything else, such as `Basic`), and without the header they answer like every other endpoint; the four bulk transfer endpoints answer every `401` with that same challenge, with or without the header (with `error="invalid_token"` when a Bearer token was presented); every other endpoint answers a plain `401 unauthorized` with no challenge. The challenge's `resource_metadata` always points at `/api/mcp` regardless of which endpoint was called — see [Known limitations](./known-limitations.md); the challenge of the upload and bulk transfer endpoints carries no `resource_metadata`. There are four scopes: `notes:read`, `notes:write` (which includes read), `notes:move` and `notes:delete` (each of the last two comes only with `notes:write`, and they are independent of each other); a credential holds one of `notes:read`, `notes:read notes:write`, `notes:read notes:write notes:move`, `notes:read notes:write notes:delete` or `notes:read notes:write notes:move notes:delete`. Tokens are issued only by this server (no external identity providers), can never mint a collaboration token (`POST /api/notes/:id/collab-token` is session-only) nor rename an agent or change a credential's scope (`PATCH /api/auth/tokens/:id`, like the rest of `/api/auth/tokens`, is session-only), and revoking one deletes it outright.
Besides personal tokens, an MCP client can obtain its own credential through the built-in **OAuth 2.1 authorization server** (`/.well-known/*`, `/oauth/*` — see the OAuth rows below and [API tokens](./api-tokens.md)). Besides those endpoints, every error body on this server is the `{error:{code,message}}` shape — with `/api/mcp` as a partial exception: everything its HTTP layer rejects (`401`, `403`, `413`, `429`, a `415` from the content-type guard, and a malformed or empty body's `400`) uses the normal shape, while its `405` on `GET`/`DELETE`, the MCP transport's own answers (a `415` when no `Content-Type` was sent at all, a `406` when `Accept` is missing `text/event-stream`) and every tool-level error are JSON-RPC. The OAuth endpoints answer in the RFC 6749 §5.2 / RFC 7591 form `{error, error_description}` (with `Cache-Control: no-store`), including their `404`/`415`, because that is what OAuth clients parse — except `/oauth/authorize`'s pre-redirect responses, which are plain text because they are read by a human, not a client. The issuer, the resource identifier (`<issuer>/api/mcp`) and every endpoint URL are derived from `PUBLIC_URL`'s origin, never from the `Host` header. The two consent-page endpoints under `/api/oauth/*` are ordinary session endpoints with the normal error shape.
| Endpoint | Method | Auth | Notable status codes |
|---|---|---|---|
| `/api/auth/config` | GET | none | 200 `{providers: [{id, displayName, icon}], registration: {enabled: boolean, providerIds: string[]}, passwordLogin: {enabled: boolean}, autoVersionsEnabled: boolean, noteMaxBytes: number, maxUploadBytes: number}` — `noteMaxBytes` is the site's note size limit (in bytes of stored state; 5 MiB by default, adjustable in Site admin → Site limits unless the `NOTE_MAX_BYTES` environment variable is set), which the web editor checks before pasting, dropping content in or applying an AI action's result; `maxUploadBytes` is the size limit for one uploaded file in bytes (10 MiB by default, adjustable in Site admin → Site limits), the same limit `POST /api/notes/:id/uploads` enforces; `providers` are the enabled sign-in services in display order (the login page shows one "Sign in with …" button for each); `registration.enabled` is the **Allow self-registration with a password** setting as stored — whether new accounts may be created with `POST /api/auth/register` (which also needs `passwordLogin.enabled`, so whether it is usable right now depends on both); `registration.providerIds` lists the ids of the enabled sign-in services that have **Creates accounts** on, in display order — a subset of `providers`, and `[]` when the `site_settings` row is missing; `passwordLogin.enabled` says whether password sign-in is accepted right now (the Site admin setting, or forced on by `PASSWORD_LOGIN_FORCE_ENABLE` — which of the two isn't exposed). Each provider's `icon` is `{type: "builtin", name}` (`name` is `gitlab`, `google` or `generic`), `{type: "upload", url}` for an uploaded image (`url` is `/api/auth/providers/:id/icon?v=<version>`), or `null` when the service shows no icon. `autoVersionsEnabled` is the site-wide switch for saving note versions automatically (see **Note versions** below). No issuer or client details are exposed |
| `/api/auth/providers/:id/icon` | GET | none | 200 with the sign-in service's uploaded icon exactly as stored (`Content-Type` `image/png`, `image/jpeg` or `image/webp`; `X-Content-Type-Options: nosniff`; `Content-Security-Policy: default-src 'none'; sandbox`), also while the service is turned off. With `?v=` exactly equal to the icon's current version — the `url` in `icon` carries it — the response is `Cache-Control: public, max-age=31536000, immutable`; otherwise `no-cache`. 404 `not_found` when there is no such service or its icon isn't an uploaded image |
| `/api/auth/oidc/login/:providerId` | GET | none | 302 (browser-navigation endpoint, not JSON — redirects to that provider's authorization endpoint, with `redirect_uri` set to the provider's own callback URL). Accepts an optional `next` query parameter: a same-site page path to return to after sign-in, validated exactly like the login page's own `?next=` (see [Known limitations](./known-limitations.md)); anything that fails validation is silently ignored. On failure before the redirect (unknown, malformed or disabled provider, rate limit, identity provider unreachable), redirects to `/login?error=<code>` instead — **never** carrying `next`: `oidc_unavailable`, `too_many_requests` |
| `/api/auth/oidc/login` | GET | none | Same as above for the provider that still uses the old callback URL below (a site upgraded from v0.5 or earlier, whose `OIDC_*` variables were imported as a provider by v0.6 to v0.15); `oidc_unavailable` when there is none or it is disabled |
| `/api/auth/oidc/callback/:providerId` | GET | none | 302 (browser-navigation endpoint, not JSON). On success, sets the session cookie and redirects to the `next` carried through the flow, or `/`. If the identity isn't linked to any account but exactly one account has its email (compared case-insensitively), nothing is signed in: it redirects to `/link-account` and sets a linking cookie that lasts 15 minutes (see the `pending` rows) — or, when that account has no way to prove it's yours, fails with `oidc_link_no_proof_method`. On failure, redirects to `/login?error=<code>`, carrying `&next=…` **once the state cookie has been opened** — so not for `too_many_requests`, nor for the `oidc_unavailable` raised before that point: `oidc_unavailable`, `too_many_requests`, `oidc_state_mismatch`, `oidc_exchange_failed`, `oidc_claim_too_long`, `oidc_email_missing`, `oidc_conflict`, `oidc_link_no_proof_method`, `registration_disabled` (the sign-in service doesn't have **Creates accounts** on, or the `site_settings` row is missing), `account_disabled`. `oidc_unavailable` is also raised when the service was changed (issuer, client ID or secret) or deleted while the person was at the identity provider. When the flow was started from the linking page's "Confirm by signing in with …", a success signs in to the account being linked (the new identity is linked to it) and redirects to the `next` stored with the linking request, or `/`; failures after the state cookie has been opened go back to `/link-account?error=<code>` instead: `oidc_link_expired`, `oidc_link_proof_mismatch`, `identity_taken`, `identity_already_linked`, `account_disabled`, `oidc_exchange_failed`, `oidc_unavailable`, `oidc_state_mismatch`, `oidc_claim_too_long` |
| `/api/auth/oidc/callback` | GET | none | Same as above for the imported provider |
| `/api/auth/oidc/pending` | GET | linking cookie | 200 `{pendingId, email, providerDisplayName, methods: {password, providers: [{id, displayName, icon}]}}` — `methods` is recomputed on every call: `password` says whether the account has a password, `providers` lists the enabled sign-in services already linked to the account, leaving out any for the same identity provider as the sign-in being linked; `providerDisplayName` is `null` if that provider has since been deleted. `icon` is as in `/api/auth/config`. 401 `unauthorized` (no valid linking cookie), 409 `oidc_link_expired` (the account is gone or its email changed; clears the linking cookie), 409 `oidc_link_no_proof_method` |
| `/api/auth/oidc/pending/confirm` | POST | linking cookie | Body `{password, pendingId}` (strict). 200 `{user, next}` and sets the session cookie (the password is checked first, then whether the account is disabled). 400 `invalid_body`, 401 `unauthorized`/`invalid_credentials`, 403 `account_disabled`, 409 `oidc_link_expired` (also when `pendingId` doesn't match — another tab replaced the request)/`identity_already_linked`/`identity_taken`, 429 `too_many_attempts` (a wrong password counts against the same lockout as the login form for that email and IP; body includes `retryAfterMs`)/`server_busy` |
| `/api/auth/oidc/pending/prove/:providerId` | POST | linking cookie | Body `{pendingId}` (strict, JSON). 200 `{url}` — navigate the browser there to sign in with a provider already linked to the account. 400 `invalid_body`, 401 `unauthorized`, 404 `provider_not_found` (not linked to the account, disabled, for the same identity provider as the sign-in being linked, or not a provider id), 409 `oidc_link_expired`, 429 `too_many_requests` (the login endpoint's per-IP limit), 503 `oidc_unavailable` |
| `/api/auth/oidc/pending/cancel` | POST | none | 204, with or without a linking cookie; clears it |
| `/api/auth/login` | POST | none | 200, 400 `invalid_body`, 401 `invalid_credentials`, 403 `account_disabled`/`password_login_disabled` (password sign-in is turned off; answered after the body's shape is checked but before the account is looked up, so for any well-formed body the response is the same whatever the email and password), 429 `too_many_attempts` (body includes `retryAfterMs`)/`server_busy` |
| `/api/auth/logout` | POST | none | Signs out this browser only: ends the session its cookie belongs to on the server and clears the cookie. The account's sessions in other browsers stay signed in. 204, also without a cookie or with one that is no longer valid. |
| `/api/auth/me` | GET | Auth | 200 (the user object includes `handle`, the URL-facing username, and `autoVersions`, the account's switch for saving versions of its personal notes automatically), 401 `unauthorized` |
| `/api/auth/profile` | PATCH | Auth | Body `{handle?, autoVersions?, displayName?}` (at least one) — `handle` renames the account's username; `autoVersions` turns automatic versions of your personal notes on or off; `displayName` changes the name others see (trimmed; 1–100 characters counted in Unicode code points, no NUL character or unpaired surrogate — the same rule as registering). `autoVersions` and `displayName` don't count against the rename limit. 200 (returns the updated user object), 400 `invalid_body` (for `handle`: ASCII uppercase is lowercased first; then 1–32 chars, `a-z0-9-`, no leading/trailing/double hyphens; or a `displayName` that breaks the rule above), 409 `handle_taken` (taken by someone else's current username, or equal to your current one), 429 `too_many_requests` (5 successful renames per rolling 24 hours — going back to one of your old names counts — counted from a record of renames kept in the database, so the quota survives restarts; failed attempts don't consume it) |
| `/api/auth/password` | POST | Auth | 204 (signs out the account's other sessions; the response sets a new session cookie), 400 `invalid_body`/`password_too_short`, 401 `invalid_credentials`, 429 `server_busy`. Still allowed while password sign-in is turned off. |
| `/api/auth/password/set` | POST | Auth | Body `{newPassword}` (strict). Adds a password to an account that has none. 204 (signs out the account's other sessions; the response sets a new session cookie), 400 `invalid_body`/`password_too_short`, 401 `unauthorized`, 403 `password_login_disabled`, 409 `password_already_set`, 429 `server_busy`. Session-only. |
| `/api/auth/register` | POST | none | Body `{email, password, displayName?}` (strict; `email` is trimmed and lowercased, at most 254 characters; `displayName` is trimmed, can't be empty or contain a NUL character or an unpaired surrogate, is at most 100 characters counted in Unicode code points, and defaults to the part of the email before `@`, cut to 100 code points). 201 (the user object; sets the session cookie), 400 `invalid_body`/`password_too_short`, 403 `registration_disabled` (**Allow self-registration with a password** is off)/`password_login_disabled`, 409 `email_taken` (compared case-insensitively), 429 `too_many_requests` (10 requests per IP per hour, refused ones included)/`server_busy`. An account made from an invitation goes through `POST /api/auth/invite/accept` instead, which `registration_disabled` doesn't affect. |
| `/api/auth/password-reset/inspect` | POST | none | Body `{token}`. Checks a reset link without using it. 200 `{email, expiresAt}`, 400 `invalid_body`, 403 `password_login_disabled`, 410 `reset_link_invalid` (unknown, used, cancelled or expired — not told apart), 429 `too_many_requests` |
| `/api/auth/password-reset` | POST | none | Body `{token, newPassword}`. Sets the new password, uses up the link, signs the person in, and signs out every other browser session of theirs; API tokens and connected apps keep working. 200 (the user) with a session cookie, 400 `invalid_body`/`password_too_short`, 403 `password_login_disabled`, 410 `reset_link_invalid`, 429 `too_many_requests`/`server_busy` (password hashing is busy) |
| `/api/auth/invite/inspect` | POST | none | Body `{token}` (strict). Checks an invitation link without using it. 200 `{expiresAt}`, 400 `invalid_body`, 403 `password_login_disabled`, 410 `invite_invalid` (unknown, used, revoked or expired — not told apart), 429 `too_many_requests` |
| `/api/auth/invite/accept` | POST | none | Body `{token, email, password, displayName?}` (strict; `email` and `displayName` follow the same rules as in `POST /api/auth/register`). Creates the account — always a regular user, never an admin — uses up the invitation and signs the person in. 201 (the user object; sets the session cookie), 400 `invalid_body`/`password_too_short`, 403 `password_login_disabled`, 409 `email_taken` (compared case-insensitively; the invitation stays unused)/`server_busy` (the default plan was deleted at that moment — retry), 410 `invite_invalid`, 429 `too_many_requests`/`server_busy` (password hashing is busy), 500 if the site settings row is missing. It doesn't depend on **Allow self-registration with a password** and doesn't count against the per-IP limit of `POST /api/auth/register`. |
| `/api/auth/identities` | GET | Auth | 200 `{identities: [{id, issuer, providers: [{id, displayName, icon}], createdAt, lastLoginAt, unlinkable}], linkable: [{providerId, displayName, template, icon}], hasPassword, passwordLoginEnabled}` — `providers` are the turned-on sign-in services whose issuer matches the identity; `unlinkable` says whether unlinking it leaves the account another way to sign in (a password counts only while the Site admin setting allows password sign-in — `PASSWORD_LOGIN_FORCE_ENABLE` doesn't count); `linkable` are turned-on services whose issuer none of the account's sign-ins has yet; `hasPassword` says whether the account has a password; `passwordLoginEnabled` says whether password sign-in is accepted right now. `icon` is as in `/api/auth/config`. Session-only (a Bearer token gets 401). |
| `/api/auth/identities/:id` | DELETE | Auth | 204 (nobody is signed out), 404 `not_found` (not yours, missing, or not a uuid), 409 `last_login_method`. Session-only. |
| `/api/auth/oidc/link/:providerId` | POST | Auth | Body `{}` (a JSON body is required). 200 `{url}` — navigate the browser there. Coming back, the identity is linked to the signed-in account and the browser lands on `/settings/login?linked=<providerId>`; failures after the sign-in cookie has been opened land on `/settings/login?link_error=<code>` (`identity_taken`, `identity_already_linked`, `oidc_link_session_mismatch`, `account_disabled`, …), earlier ones on `/login?error=<code>`. 400 `invalid_body`, 401, 403 `forbidden` (the account must change its password first), 404 `provider_not_found`, 409 `identity_already_linked`, 429 `too_many_requests` (the login endpoint's per-IP limit), 503 `oidc_unavailable`. Session-only. |
| `/api/auth/tokens` | GET | Auth | 200 `{tokens: [{id, kind, name, agentLabel, scope, createdAt, lastUsedAt, expiresAt, clientId}]}` — every credential the user holds (Personal API tokens and OAuth app credentials), newest first; never includes a token's plaintext. `agentLabel` is the agent display name in effect, so it is **never `null`**: a credential with no override reports the value derived from its `name` (see [AI editing](./ai-editing.md#agent-display-name)). Session-only: a Bearer token gets a plain `401`. |
| `/api/auth/tokens` | POST | Auth | Body `{name, scope: "notes:read" \| "notes:read notes:write" \| "notes:read notes:write notes:move" \| "notes:read notes:write notes:delete" \| "notes:read notes:write notes:move notes:delete", expiresInDays: 30 \| 90 \| 365 \| null}`; `"notes:write"` is also accepted, as the older name for `"notes:read notes:write"` (the response's `scope` is the full form), while any other value — `"notes:move"` or `"notes:delete"` on its own, for example — is `400`. 201 (the token object plus `token` — the plaintext, returned **only here, once**), 400 `invalid_body`, 403 `forbidden` (account must change its password first), 409 `token_limit` (20 active tokens per user by default; a site admin can lower it), 429 `too_many_requests` (10 creations per user per hour). Session-only. |
| `/api/auth/tokens/:id` | PATCH | Auth | Body `{agentLabel?: string \| null, scope?: "notes:read" \| "notes:read notes:write" \| "notes:read notes:write notes:move" \| "notes:read notes:write notes:delete" \| "notes:read notes:write notes:move notes:delete"}` — strict, at least one of the two; both are written together when both are given. `agentLabel` renames the agent display name shown for this credential (see [AI editing](./ai-editing.md#agent-display-name)); `null` clears the override, so the response falls back to the value derived from `name`. `scope` replaces the credential's scope — raising or lowering it, on a personal token or an app credential alike, whatever an app originally asked for — and applies from the credential's next request; a later refresh of an app's token keeps it (see [API tokens](./api-tokens.md#changing-a-tokens-access)). 200 (the updated token object; its `agentLabel` is never `null`), 400 `invalid_body` (`agentLabel` not matching `^[A-Za-z0-9._-]{1,32}$`, `scope` not one of the five values, including the `"notes:write"` shorthand that `POST` accepts, an empty body, or an unknown field), 403 `forbidden` (the body carries `scope` and the account must change its password first; renaming alone is still allowed), 404 `token_not_found` (no such token, not yours, or not a uuid), 429 `too_many_requests` (`TOKEN_RENAME_LIMIT`=60 per 10 minutes per user, shared by renaming and changing the scope). The limiter sits between those checks and the update, so a 404 for an unknown or somebody else's token has already spent a slot, while a malformed `:id` or body, and the `403`, are refused before it. Session-only. |
| `/api/auth/tokens/:id` | DELETE | Auth | 204 (the token is deleted and stops working immediately), 404 `token_not_found` (no such token, not yours, or not a uuid). Session-only. |
| `/api/notes` | POST | Auth / Token(`notes:write`) | Body `{title?, content?, groupId?, folderId?}` — `content` is Markdown for the new note, written in the same request (see [AI editing](./ai-editing.md)); omit it and the note starts empty. `groupId` creates the note inside that group, owned by the group rather than by you — you must be a member of it whose role can create notes (a site admin is not exempt), and it can be combined with `content`. `folderId` creates the note in that folder (see **Folders** below the table): one of your folders in your notes (without `groupId`), or a folder of the group named by `groupId` (both must be given), where your role must also be able to edit notes; a folder in notes shared with you can't hold new notes. Omit `folderId` for the top level (`null` is `400`). The response's `role` is `owner` for a personal note; for a group note it comes from your role in the group (`editor` if the role can also edit, otherwise `viewer`: a role that can create but not edit gets `viewer` — with `content`, the note is created with that content and is then read-only to you) and `ownerId`/`ownerHandle` are `null` (see **Note objects** below the table). A `title` in the body also sets the note's slug — the same automatic slug a title change computes, de-duplicated among the owner's notes (for a group note, among the group's notes) with a numeric suffix (some titles have no usable URL form and fall back to `untitled`); a request without `title` keeps the database's `untitled-<8 hex characters>` default. Note that recomputing it later takes a `PATCH /api/notes/:id` — a title change, or `slug: null` (see that row below) — which is **session-only**: a token cannot do it — the one exception is a title change made through MCP `edit_note`'s `rename`, which recomputes the automatic slug the same way. **The body is strict: an unknown field is `400 invalid_body`, not ignored.** 201, 400 `invalid_body` (bad field, unknown field, `content` over 262 144 UTF-16 code units, or a deployment without the collaboration component), 400 `unsupported_block`/`unsupported_color`/`unsupported_columns`/`unsupported_size`/`empty_content`/`too_many_blocks` (the `content` failed to parse — nothing is created; `unsupported_block` is a block type the note can't store, content the editor fails to parse, HTML with an event-handler attribute such as `onerror`, or a `<script>`, `<style>`, `<iframe>`, `<object>`, `<noscript>`, `<title>` or `<template>` element anywhere in it, or content the server can't read within its time and memory limits — the message then says it is too complex, see [Self-hosting](./self-hosting.md#parse-workers); `unsupported_color` is a text or background color the editor can't show, such as a color with transparency or a CSS color name other than the ten (what is accepted: [AI editing](./ai-editing.md#colors)); `unsupported_columns` is side-by-side columns not written in the form reading gives, including a row of fewer than 2 or more than 4 columns, or another `<div>` laid out with `display:flex` or `display:grid`, or an element carrying `data-node-type`, and `unsupported_size` an image width or table column width not written in the form reading gives, see [AI editing](./ai-editing.md#columns) and [Sizes](./ai-editing.md#sizes)), 404 `group_not_found` (`groupId` names no group, a group you aren't a member of, or one deleted while the request was running — indistinguishable), 404 `folder_not_found` (`folderId` isn't a folder you can see — checked before `groupId`; a folder that is deleted, or whose group is deleted or transferred, while the request is running gets this answer too, or rarely `group_not_found`), 400 `folder_cross_space` (`folderId` is a folder you can see, but not in the place the note is created: a group folder without that group's `groupId`, one of your own folders with a `groupId`, or a folder in notes shared with you), 403 `forbidden` (`groupId` names a group you're a member of, but your role in it can't create notes — or, with a group folder, can't edit them), 403 `insufficient_scope` (read-only token), 429 `too_many_requests` (token requests always; requests carrying `content` also draw the 30-writes-per-minute-per-user budget), 503 `server_busy` with `Retry-After` (with `content`: reading it, or putting it into the new note, waited longer than `PARSE_QUEUE_WAIT_MS` for a parse worker, or 100 writes were already waiting — nothing is created), 413 `note_too_large` (with `content`: the new note would be larger than `NOTE_MAX_BYTES`; the error carries `limitBytes`; nothing is created), 500 `internal` (`content` could not be applied — the note row just created is removed again). A note created with `content` gets one revertable entry in its edit history, and its `lastEdited` reflects that write |
| `/api/notes` | GET | Auth / Token(`notes:read`) | 200 (notes you own, notes shared with you, and the notes of every group you belong to — each note once; see **Note objects** below the table), 429 `too_many_requests` (token requests only) |
| `/api/notes/by-path/:handle/:slug` | GET | Auth | 200 (resolves a personal note by its owner's username and slug — the `/n/<username>/<slug>` URL form; both segments are normalized first, and a miss falls back first to a forwarding entry for that exact address (one is kept for a month when a note moves into a group, so its old `/n/` address finds it at its `/g/` address), then to the single immediately-previous custom slug — no HTTP redirect in either case: the response is a normal 200 with the note's current address fields (`slug`, `ownerHandle`, `groupId`) and the client updates the address bar — refusing ambiguous matches), 404 `not_found` (same body whether the note doesn't exist or you lack access), 404 `in_trash` with a top-level `noteId` when the note is in the trash and you can restore it (see **Trash** below the table) |
| `/api/notes/by-group-path/:groupId/:slug` | GET | Auth | 200 (resolves a group note by its group's id and slug — the `/g/<group id>/<slug>` URL form; the slug is normalized first and the id is matched case-insensitively; a miss falls back to a forwarding entry for that exact address, then to the single immediately-previous custom slug within the group, the same way as the row above; a forwarding entry is kept for a month when a group is deleted and its notes given to an admin, so the old `/g/` address finds the note as that person's personal note — it resolves only for people who can read the note, which at first is only that admin), 404 `not_found` (byte-identical whether the group id is malformed, the group or the note doesn't exist, or you can't read it), 404 `in_trash` with a top-level `noteId` when the note is in the trash and you can restore it (see **Trash** below the table) |
| `/api/notes/:ref` | GET | Auth / Token(`notes:read`) | 200, 429 `too_many_requests` (token requests only), 404 `not_found` (`:ref` is a note id, a **pre-#122** slug — the frozen legacy snapshot, not the current slug — or a `<vanity>-<id>` path; current slugs resolve via the `by-path` and `by-group-path` rows above), 404 `in_trash` with a top-level `noteId` when the note is in the trash and you can restore it (see **Trash** below the table) |
| `/api/notes/:id/content` | GET | Auth / Token(`notes:read`) | 200 `{markdown, fingerprint, outline:[{sectionId, level, heading, chars, fingerprint}], lastEdited}`; `?section=<id>` returns `{section:{id, level, heading, chars, fingerprint, markdown}, lastEdited}` only; 404 `not_found` / `section_not_found`; 400 `invalid_body`; 429 (`CONTENT_READ_LIMIT`=120/min per user by default); 503 `server_busy` with `Retry-After` (turning the note into Markdown waited longer than `PARSE_QUEUE_WAIT_MS` for a parse worker); 500 `too_complex` (turning it into Markdown took longer than three times `PARSE_TIMEOUT_MS`, or more memory than a parse worker has — retrying won't help; see [Self-hosting](./self-hosting.md#parse-workers)). Read-only: never touches `note_states`. Non-empty `markdown` always ends with a trailing newline. Side-by-side columns read as `<div data-kb-columns …>` and `<div data-kb-column …>` lines with Markdown inside each column, an image shown at a set width as `<figure><img … width="W">…</figure>` (with a `<figcaption>` before `</figure>` when it has a caption; an image whose preview is turned off reads as a link, without its width), and a table with column widths as an HTML table with `colwidth` on its first row's cells — see [AI editing](./ai-editing.md#columns) and [Sizes](./ai-editing.md#sizes). A note that has never been opened in the editor returns `markdown: ""`, while one that has been opened and touched at least once (even back down to empty) returns `"\n"` — both report `chars: 0`, so don't infer "this note is empty" from comparing the markdown string to `""`; check `chars`/`fingerprint` on the outline entry (or section) instead. `lastEdited` is `{at, byHandle, agentLabel}` — who last changed this note and when, `null` if nobody has since the feature landed; `byHandle` is the **editor's** username, not the owner's (`""` if that account was deleted), and `agentLabel` is non-`null` only when that write came through a token or an authorized App (derived from the credential's name), so a browser edit and a cookie-authenticated API write both leave it `null` |
| `/api/notes/:id/edits` | POST | Auth / Token(`notes:write`) | Writes note content. Body is one of five operations — `{op:"replace_all", markdown, if_match}`, `{op:"replace_section", section_id, markdown, if_match}`, `{op:"insert_after", section_id, markdown, if_match}`, `{op:"append", markdown, if_match?}`, `{op:"delete_section", section_id, if_match}` — strict, so an unknown field is `400 invalid_body`. 201 `{editId, fingerprint, outline, unboundWikilinks}` (fingerprint/outline are the note as this write left it — they don't include changes others made to other parts of the note while it was being applied, so when someone is editing the note the next `if_match` built from them can answer `409 fingerprint_mismatch`; read the note again then); 400 `invalid_body`/`unsupported_block`/`unsupported_color`/`unsupported_columns`/`unsupported_size`/`empty_content`/`empty_section`/`too_many_blocks` (the `unsupported_*` codes as for `POST /api/notes` above); 403 `forbidden` (viewer); 404 `not_found`/`section_not_found`; 409 `fingerprint_mismatch` (body carries `current`, the same shape `/content` returns; when `current` can't be read, the answer is that read's `503`/`500` instead, as for `/content`); 413 `content_too_large` (body over 262 144 bytes — this route's own limit); 500 `too_complex` (putting the write into the note took longer than three times `PARSE_TIMEOUT_MS` or more memory than a parse worker has — the note itself is too large or too complex; retrying won't help; nothing written); 413 `note_too_large` (the note would grow past `NOTE_MAX_BYTES`, 5 MiB by default, and be larger than before, and the change adds content or grows it by more than 4 KiB without adding content — see [Self-hosting](./self-hosting.md#note-size); the error carries `limitBytes`; nothing is written); 429 (`EDIT_LIMIT`=30/min per user by default); 503 `server_busy` (another write to the same note held the per-note queue for 10 s, or — with `Retry-After` — the Markdown, or putting it into the note, waited longer than `PARSE_QUEUE_WAIT_MS` for a parse worker, 100 writes were already waiting, or the section its `if_match` names was touched three times in a row while the write was being prepared but ended up the same — changes to other parts of the note are merged, not retried; nothing applied, nothing recorded; note that the same code is a **409** on `/api/notes/:id/links`). Concurrency, addressing and the operations in full: [AI editing](./ai-editing.md) |
| `/api/notes/:id/edits` | GET | Auth / Token(`notes:read`) | 200 `{edits:[{id, op, sectionId, heading, byHandle, agentLabel, createdAt, revertedAt, revertOf, revertable}]}` — this note's recorded API writes, newest first, at most 100 (`op` is one of the five plus `revert`; `heading` is resolved against the note as it is now and is `""` once that section is gone; `revertable` says whether a revert would be accepted right now). Read-only: never opens a direct connection and never reloads an unloaded document. 404 `not_found`, 429 (shares `CONTENT_READ_LIMIT`=120/min per user by default with `/content`) |
| `/api/notes/:id/edits/:editId/revert` | POST | Auth / Token(`notes:write`) | No body. 201 `{editId, fingerprint, outline}` (the revert is itself a recorded entry, and can never be reverted); 403 `forbidden` (viewer); 404 `not_found` (no such note or entry, a non-uuid `:editId`, or the entry belongs to another note); 409 `already_reverted`; 409 `stale` (the note moved on — body carries `current`; when `current` can't be read, that read's `503`/`500` instead); 413 `note_too_large` (the note would grow past `NOTE_MAX_BYTES`, 5 MiB by default, and be larger than before, and the change adds content or grows it by more than 4 KiB without adding content — see [Self-hosting](./self-hosting.md#note-size); the error carries `limitBytes`; nothing is written); 429 (`EDIT_LIMIT`); 503 `server_busy` (the per-note queue as for `/edits`, or — with `Retry-After` — the revert waited longer than `PARSE_QUEUE_WAIT_MS` for a parse worker, or the blocks it puts back were touched three times in a row while it was being prepared but ended up the same; nothing reverted); 500 `too_complex` (the note is too large or too complex to change within three times `PARSE_TIMEOUT_MS` or a parse worker's memory; retrying won't help; nothing reverted). What makes an entry stale differs per operation — `delete_section` has no fingerprint and is judged by an anchor block: see [AI editing](./ai-editing.md#revert) |
| `/api/notes/:id/versions` | GET | Auth (a role that can edit the note) | Query `before?` (a version number; only versions numbered below it are listed), `limit?` (1–100, default 50) and `kind?` (`"manual"` or `"auto"`; only versions of that kind are listed, and left out means all of them — it combines with `before` and `limit`, and `nextBefore` is then the last version of the filtered page; `current` is the same whatever the filter). 200 `{versions: [{id, seq, kind: "auto" \| "manual", name, editors: [{handle, agentLabel}], baseSeq, base, createdAt}], current: {baseSeq, base, dirty, nextSeq, autoEnabled}, nextBefore}` — highest number first; pass `nextBefore` as `before` for the next page (`null` = no more). `editors` are the accounts whose edits went into that version, an account appearing once per `agentLabel` (the agent label of the token an API or MCP write was made with, otherwise `null`); a version added by `POST` also lists whoever saved it; a deleted account's `handle` is `""`. A version's `baseSeq` is set only when, at the time it was saved, the content was based on a version other than the newest one (an older version had been applied — the web app shows it as "based on" followed by that version's name and short code); otherwise it is `null`. `base` describes that version: `null` when `baseSeq` is `null`, otherwise `{id, name, deleted}` — `id` is its id (`null` if it had already been deleted before this release started recording the ids of bases, or if the version was saved while the site ran 0.10 after a rollback), `deleted` is `true` when it has been deleted since or `id` is `null`, and `name` is its name while it exists (`null` if it has none, has been deleted or `id` is `null`). `current.baseSeq` is the version the current content is based on (`null` = none) and `current.base` describes it like `base` above (after a rollback to 0.10 and an upgrade again, until a new version is saved or a version is applied, it can describe the base the note had at the rollback, or have `id` `null`), `dirty` whether there is something new to save (see **Note versions** below; also `true` when the server can't tell), `nextSeq` the number the next new version will get (kept for API clients — the web app no longer shows version numbers), `autoEnabled` whether versions of this note are being saved automatically. 400 `invalid_body` (bad `before`/`limit`/`kind`, or any other query parameter), 403 `forbidden` (read-only access), 404 `not_found` |
| `/api/notes/:id/versions/:seq` | GET | Auth (a role that can edit the note) | 200 `{id, seq, ydoc}` — the version's full content as a base64 Yjs update, sent with `Cache-Control: private, no-store` (a version number can point at different content after the note's history is cleared by a move into a group or a group transfer — see **Note versions** below). 403 `forbidden`, 404 `not_found` |
| `/api/notes/:id/versions` | POST | Auth (a role that can edit the note) | Body `{name?}` (strict; trimmed, blank means no name, at most 120 characters counted in Unicode code points, no NUL or unpaired surrogate characters). Saves the current content as a manual version, which automatic thinning never removes. 201 the version (as in the list) plus `upgraded`: `false` = a new version; `true` = the content was the same as the version it is based on, so instead of adding a version, that one was made manual (and given the name, if a non-blank one was sent). 400 `invalid_body`, 403 `forbidden`, 404 `not_found`, 429 `too_many_requests` (shares the 30-writes-per-minute (by default) budget of `POST /api/notes/:id/edits`), 503 `server_busy` (retry) |
| `/api/notes/:id/versions/:seq/apply` | POST | Auth (a role that can edit the note) | Body `{versionId, discardUnsaved}` (strict, both required; `versionId` is the version's `id` from the list). Replaces the note's content with that version for everyone editing it (block ids are kept); the content is then based on that version, unless the version was deleted meanwhile (or, rarely, the server can't record it). Applying isn't recorded as an AI edit, so it can't be reverted through `/edits` — apply another version instead. 200 `{current}` (as in the list, once the apply has finished). 400 `invalid_body`, 403 `forbidden`, 404 `not_found`, 409 `version_unsaved_changes` (there is something new to save — `dirty` — and `discardUnsaved` is `false`, or the note kept changing while the version was being applied; nothing is changed), 409 `version_mismatch` (that version number now holds a different version — refresh the list), 413 `note_too_large` (the note would grow past `NOTE_MAX_BYTES`, 5 MiB by default, and be larger than before, and the change adds content or grows it by more than 4 KiB without adding content — see [Self-hosting](./self-hosting.md#note-size); the error carries `limitBytes`; nothing is written), 429 `too_many_requests`, 503 `server_busy` |
| `/api/notes/:id/versions/:seq` | PATCH | Auth (a role that can edit the note) | Body `{name: string \| null}` (strict, required; `null` or blank clears the name, otherwise the same rules as `POST`). Renames the version and makes it manual, so automatic thinning never removes it. 200 the version (as in the list), 400 `invalid_body`, 403 `forbidden`, 404 `not_found`, 429 `too_many_requests` |
| `/api/notes/:id/versions/:seq` | DELETE | Auth (a role that can edit the note) | Deletes the version, manual or automatic. 204, 403 `forbidden`, 404 `not_found`, 409 `version_is_base` (the current content is based on this version), 429 `too_many_requests` |
| `/api/notes/:id` | PATCH | Auth | 200, 400 `invalid_body`, 403 `forbidden` (a body carrying `slug` — even `slug: null` — needs the note's owner or, on a group note, a role that can manage public links, i.e. `permissions.changeSlug`; a body carrying `title` — with or without `slug` — also needs edit access, so a role that can manage public links but not edit gets 403 for `{slug, title}`; it can still send `slug` alone), 404 `not_found`, 409 `slug_taken` (custom slug collides with **another of your own notes**, or on a group note with another note of the same group — slugs are per-user for personal notes since #122, and per-group for group notes), 409 `conflict` (the note moved to a different owner or group after your request was authorized — e.g. it was moved into a group; nothing is written), 429 `too_many_requests` (any request whose body carries `slug` — including `slug: null`, which now means "back to an automatic slug recomputed from the title" rather than clearing it, and **does** count against the quota; title-only changes never do). A title change on a note without a custom slug recomputes its automatic slug as part of the same update — collisions are resolved with a numeric suffix, never a 409. API tokens can change a note's title only through MCP `edit_note`'s `rename`. |
| `/api/notes/:id` | DELETE | Auth | 204 (moves the note to the trash — it disappears from every list, search, link and public URL, and can be restored from `/api/trash`; nothing is deleted yet: attachments, versions, shares and public links are kept), 403 `forbidden` (you can read the note but not delete it: a personal note can only be deleted by its owner, a group note by a role that can delete notes), 404 `not_found` (also for a note that is already in the trash) |
| `/api/trash` | GET | Auth | 200 `{retentionDays, items: [{id, title, deletedAt, purgeAt, deletedBy, group, folderPath}], truncated}` — the notes in the trash that you can restore (see **Trash** below the table): your own personal notes, and the notes of every group where your role can both edit and delete notes; most recently moved there first (then by id, descending), at most `TRASH_LIST_LIMIT`=1000, with `truncated: true` when there are more. `retentionDays` is the site's retention period (`null` = notes aren't deleted automatically, see `/api/admin/trash/settings`); `purgeAt` is `deletedAt` plus that many days — when the note becomes due to be permanently deleted, which usually happens within the next hour (the clean-up runs once an hour and deletes at most 2,000 notes per pass, so with more due at once it takes several passes) — or `null` when `retentionDays` is `null`; `deletedBy` is `{handle, displayName}` of whoever moved it there, `null` once that account has been deleted; `group` is `{id, name}`, `null` for a personal note; `folderPath` is the path of the folder the note is in — or was in, if that folder has since been deleted — as folder names from the top level down, which is where restoring puts it back (see **Trash**); `[]` for a note at the top level. 500 `internal` if the site settings row is missing. Session-only (a request with only an API token gets `401`). |
| `/api/trash/restore` | POST | Auth | Body `{ids}` — strict; `ids` is an array of 1 to `TRASH_BATCH_MAX`=200 note ids, counted as sent (an id sent twice counts twice), matched case-insensitively, and an id sent more than once, in any letter case, is handled once. Takes each note out of the trash, back to where it was: its address, shares, public link and custom public URL, versions, edit history and backlinks are as they were, and it is back in its folder, re-created if it was deleted — unless its group was deleted with `mode: "transfer"` while it was in the trash, which changes its owner and address and removes its public link (see **Trash** for both). Storage isn't checked — the note's attachments never stopped counting. 200 `{restored, skipped}` — `restored` holds the restored notes as note objects (see **Note objects** below the table), and `skipped` is `[{id, reason}]` for every other id; both follow the order the ids were sent in, with ids in lowercase. `reason` is `not_found` (no such note, not in the trash, or you can't restore it — indistinguishable) or `not_in_trash` (it was in the trash when your request was checked, but someone else restored it before your request got to it). Rarely, a note that is restored and then, before the response is put together, moved to the trash again or put out of your reach is in neither list. 400 `invalid_body` (not an object, an unknown field, or `ids` empty, longer than 200 or holding anything that isn't a note id; nothing is done), 409 `server_busy` (when the transaction had to give way to a concurrent conflict (nothing was changed; retry) — not expected in normal use). Session-only (a request with only an API token gets `401`). |
| `/api/trash/purge` | POST | Auth | Body `{ids}` — the same rules as `POST /api/trash/restore`. Permanently deletes each note that is in the trash, with its shares, versions, edit history, the wikilinks recorded for it (so it drops out of other notes' backlinks) and attachments (their files are removed from disk best-effort after the deletion is committed); links to it in other notes' content are left as they are, pointing at a note that no longer exists; a note that isn't in the trash is never deleted. 200 `{purged, skipped}` — `purged` is the ids deleted and `skipped` is as for restore (`not_in_trash`: someone restored it before your request got to it); both follow the order the ids were sent in, with ids in lowercase. 400 `invalid_body` (as for restore; nothing is deleted), 409 `server_busy` (when the transaction had to give way to a concurrent conflict (nothing was changed; retry) — not expected in normal use). There is no endpoint that empties the trash: send the ids `GET /api/trash` returned, so a note moved there after the list was loaded isn't deleted with them. Session-only (a request with only an API token gets `401`). |
| `/api/folders` | GET | Auth / Token(`notes:read`) | 200 `{folders}` — every folder you can see (see **Folders** below the table): your folders in your notes and in notes shared with you, and the folders of every group you belong to. Ordered by space — group folders first (by group id), then your notes, then notes shared with you — then by `parentId` (top-level folders first), then by name ignoring case (in the database's sort order), then by id; not in tree order. Each folder object is `{id, name, parentId, space, createdAt, permissions}`: `parentId` is `null` for a top-level folder; `space` is `{kind: "personal"}` (your notes), `{kind: "shared"}` (notes shared with you) or `{kind: "group", groupId}`; `permissions` is three booleans, `manage`, `deleteNotes` and `createNote` (see **Folders**). 429 `too_many_requests` (token requests only) |
| `/api/folders` | POST | Auth | Body (strict) `{name, parentId}` — a folder inside that folder — or `{name, space}` — a top-level folder, with `space` as in the folder object: `{kind: "personal"}`, `{kind: "shared"}` or `{kind: "group", groupId}`; exactly one of the two. `name` follows the rules under **Folders**. Needs a parent folder you can manage or, for a group's top level, a role in the group that can edit notes. Counts against the folder budget (`FOLDER_LIMIT`=600 per minute per user, shared by the four folder endpoints and MCP's folder tools; not the 30-writes-per-minute `EDIT_LIMIT`). 201 (the folder object), 400 `invalid_body` (the body's shape, an unknown field, or the name — checked first), 403 `forbidden` (you can see the parent folder, or are in the group, but can't manage folders there), 404 `folder_not_found` (`parentId` isn't a folder you can see), 404 `group_not_found` (`groupId` is malformed, names no group, or names a group you aren't a member of — indistinguishable; also when the group is deleted while the request is running), 409 `folder_name_taken`, 409 `folder_too_deep`, 409 `folder_limit`, 409 `server_busy` (retry), 429 `too_many_requests`. Session-only (a request with only an API token gets `401`); a token with `notes:write` can create folders over MCP with `create_folder` (see [MCP](./mcp.md)). |
| `/api/folders/:id` | PATCH | Auth | Body (strict) `{name?, parentId?}` — at least one. `name` renames the folder; `parentId` moves it, with everything in it, into another folder of the same space, or to the top level with `null`. Moving a folder to where it already is, or giving it the name it already has, changes nothing; a new name that differs only in letter case is saved. Needs a folder you can manage. Counts against `FOLDER_LIMIT`. 200 (the folder object), 400 `invalid_body`, 400 `folder_cross_space` (`parentId` is a folder you can see in another space — a folder can't move between spaces), 403 `forbidden` (you can see the folder but can't manage it), 404 `folder_not_found` (the folder, or `parentId`, isn't one you can see — checked before `folder_cross_space`), 409 `folder_name_taken` (where the folder would end up, another folder already has that name, ignoring case), 409 `folder_cycle` (moving it into itself or one of its own folders), 409 `folder_too_deep` (moving only), 409 `server_busy` (retry), 429 `too_many_requests`. Session-only (a request with only an API token gets `401`); a token with `notes:write` can rename and move folders over MCP with `rename_folder` and `move_folder` (see [MCP](./mcp.md)). |
| `/api/folders/:id` | DELETE | Auth | Deletes the folder and every folder in it. In your notes or a group: every note in them that isn't in the trash yet is moved to the trash, as with `DELETE /api/notes/:id`; the notes in the trash from these folders — those just moved there and those already there — stay there and keep the path they were at, so restoring one re-creates it (see **Trash**); the folders themselves don't go to the trash, and restoring re-creates only the folders on the restored note's path. In notes shared with you: the notes are only taken out of the folders, back to the top level of your notes shared with you, and nothing goes to the trash. All or nothing. Body (strict): none, `{}` or `{onlyIfEmpty}`; `onlyIfEmpty: false` is the same as none. With `onlyIfEmpty: true` the folder is deleted only if it is empty — no folder in it, and, in your notes or a group, no note in it that isn't in the trash (notes in the trash don't count and are handled as above) or, in notes shared with you, no note filed in it; otherwise the answer is `409 folder_not_empty` and nothing changes. With it, `404 folder_not_found` and `403 forbidden` still come first, a folder that still has notes outside the trash gets `409 folder_not_empty` rather than `403 folder_delete_blocked`, and so does a folder a note is put into while the request is running, rather than `409 conflict`. Needs a folder you can manage and, when there are notes to move to the trash, the right to delete each of them (see **Folders**). Counts against `FOLDER_LIMIT`. In your notes or a group, a `409 folder_not_empty` doesn't count when the server finds notes outside the trash in the folder, or in a folder in it, before it starts the deletion; one it finds only during the deletion — because there is a folder in it, or a note was put in it meanwhile — counts, and so does every `409 folder_not_empty` in notes shared with you. 200 `{trashedNoteIds, deletedFolderIds}` — the ids of the notes this request moved to the trash (always `[]` in notes shared with you) and of the folders deleted, each in id order. 400 `invalid_body` (the body's shape or an unknown field), 403 `forbidden` (you can see the folder but can't manage it), 403 `folder_delete_blocked` (there are notes to move to the trash and you can't delete them all; nothing is deleted), 404 `folder_not_found`, 409 `conflict` (a note was put into one of the folders, or your access to one of its notes changed, while the request was running; nothing is deleted — retry), 409 `folder_not_empty` (`onlyIfEmpty: true` only, see above), 409 `server_busy` (retry), 429 `too_many_requests`. Session-only (a request with only an API token gets `401`); a token with `notes:delete` can delete folders over MCP with `delete_folder` (see [MCP](./mcp.md)), which has no `onlyIfEmpty`. |
| `/api/notes/:id/folder` | PUT | Auth | Body (strict) `{folderId}` — a folder id, or `null` for the top level. Files the note in that folder. For your own note, or a group note (your role must be able to edit notes), `folderId` must be a folder of the note's own space — your notes, or that group's — and that is where the note is for everyone who sees that space. For a note someone else shared with you (as editor or viewer), it must be one of your folders in notes shared with you, and only you see it: the owner's folders and yours are separate, and neither of you sees where the other filed the note. Doesn't change the note's address, `updatedAt`, versions or who can see it; filing a note where it already is changes nothing. Counts against `FOLDER_LIMIT`. 200 (the note, see **Note objects** below the table), 400 `invalid_body`, 400 `folder_cross_space` (a folder you can see, but not in the space above), 403 `forbidden` (a group note your role can't edit), 404 `not_found` (you can't see the note, it's in the trash, or it stopped being shared with you while the request was running — checked before the folder), 404 `folder_not_found`, 409 `server_busy` (retry), 429 `too_many_requests`. Session-only (a request with only an API token gets `401`); a token with `notes:write` can file notes over MCP with `move_note_to_folder` (see [MCP](./mcp.md)). |
| `/api/notes/:id/move` | POST | Auth (personal note's owner) | Body `{groupId, folderId?}` (strict). Moves your personal note into a group where your role lets you create notes: its per-person shares and its public link (and its custom public URL) are removed (its public page width is kept), and the note gets the group's address — its slug is kept, or, if the group already has a note with that slug, given a numeric suffix (shortened to fit if needed; in the rare case that every numbered candidate is taken, an `untitled-<8 hex characters>` slug); `slugIsCustom` is unchanged and `prevSlug` is cleared — while its old `/n/<username>/<slug>` address forwards to it for a month. `updatedAt` doesn't change. The note lands at the group's top level, or, with `folderId`, in that folder of the group, where your role must also be able to edit notes (see **Folders** below the table). 200 (the note, now a group note; its `role` and `permissions` come from your role in that group — a role that can create notes but not edit them leaves you a `viewer`), 400 `invalid_body`, 400 `folder_cross_space` (`folderId` is a folder you can see that isn't in that group), 403 `forbidden` (you can read the note but don't own it, or it's a group note — or, with `folderId`, your role in the group can't edit notes), 404 `not_found`, 404 `group_not_found` (not a group id, no such group, you aren't a member, your role there can't create notes, or the group was deleted meanwhile — indistinguishable), 404 `folder_not_found` (`folderId` isn't a folder you can see; also when the folder, or with `folderId` the group, is deleted meanwhile), 409 `conflict` (the note was moved or changed hands after your request was authorized), 409 `storage_quota_exceeded` (the group's storage space has no room for the note's attachments; nothing changed; a note without attachments is never refused for space), 409 `server_busy` (retry). No endpoint moves a single note out of a group; deleting the group with `mode: "transfer"` turns all of its notes into one admin's personal notes (see `DELETE /api/groups/:id`). Also available to API tokens over MCP as `move_note_to_group` / `copy_note` (see [MCP](./mcp.md)). |
| `/api/notes/:id/copy` | POST | Auth | Body `{}` (copy to your own notes) or `{groupId}` (copy into a group where your role lets you create notes); strict. Either can add `folderId` to put the copy in that folder, under the same rules as `POST /api/notes` (see **Folders** below the table); without it the copy lands at the top level. Needs read access to the note; the original is left unchanged. Copies the content and stores a new copy of each attachment the note owns whose block URL is exactly `/api/uploads/<id>`; a block pointing at another note's upload, an absolute `https://…/api/uploads/…` URL, an external image, an upload URL inside a text link, and an attachment whose file is missing on the server are kept as they are. Not copied: AI edit history, backlinks, shares, public link, previous slugs. The copy's slug comes from the title, de-duplicated where it lands (`slugIsCustom` false). Counts against the 30-writes-per-minute budget (`EDIT_LIMIT`), and against the upload limit (`UPLOAD_LIMIT`=120 per 10 minutes per user by default, shared with `POST /api/notes/:id/uploads`) one unit per attachment of the note it would copy (an attachment whose file turns out to be missing still counts; a copy with none doesn't touch it); a note with more attachments than the whole limit can be copied only when none of the current window has been used, and then uses all of it. When the upload limit refuses, the answer is `429` and nothing is written or counted against it (the write already counted against `EDIT_LIMIT`). 201 (the copy; on a personal copy your `role` is `owner`, in a group it comes from your role there — a role that can create notes but not edit them gives you a `viewer`), 400 `invalid_body`, 400 `folder_cross_space` (as for `POST /api/notes`), 403 `forbidden` (a group folder, and your role in the group can't edit notes), 404 `not_found`, 404 `group_not_found` (as for `move`), 404 `folder_not_found` (`folderId` isn't a folder you can see — checked before `groupId`; a folder that is deleted, or whose group is deleted or transferred, while the request is running gets this answer too, or rarely `group_not_found`), 409 `storage_quota_exceeded` (the place you copy into — your personal space, or the group — has no room for the attachments being copied; a copy of a note with no attachments is never refused for space; a full space can refuse a copy whose attachment files are all missing — see [Known limitations](./known-limitations.md)), 409 `server_busy` (retry), 413 `note_too_large` (the note is larger than `NOTE_MAX_BYTES`, so its copy would be too; the error carries `limitBytes`; nothing is copied), 429 `too_many_requests`. Also available to API tokens over MCP as `move_note_to_group` / `copy_note` (see [MCP](./mcp.md)). |
| `/api/notes/:id/links` | POST | Auth | 204 (replaces the note's outgoing wikilink set wholesale), 400 `invalid_body` (non-uuid target, or more than `MAX_LINK_TARGETS`=1000 targets after de-duplication), 403 `forbidden` (viewer), 404 `not_found`, 409 `not_loaded` (the note isn't currently loaded in the collaboration server for this connection — the client's own self-healing resubmit on next `synced` recovers this, no user-visible retry needed), 409 `server_busy` (transient write conflict — a concurrent request should be retried) |
| `/api/notes/:id/backlinks` | GET | Auth | 200 `{backlinks: [{id, title, slug, ownerHandle, groupId}]}` (notes that link to `:id`, filtered to ones you can read, newest-updated first, capped at `MAX_BACKLINKS`=200; `ownerHandle` is the source note owner's username, for building its `/n/` link — `null` when the source is a group note, whose `groupId` builds its `/g/` link instead; `groupId` is `null` for a personal note), 404 `not_found` |
| `/api/notes/:id/collab-token` | POST | Auth | Body `{noteSchemaVersion}` (optional; the editor version the client was built for — a missing or different value still gets a token, but the collaboration server then refuses the connection with reason `client-outdated`). 200 (issues a short-lived token for the real-time collab connection; if you have no access to the note this still returns 200 with `role:'none'` in the body rather than 403/404 — the token itself is then rejected when the client tries to connect), 429 `too_many_requests` |
| `/api/notes/:id/shares` | GET | Auth (personal note's owner) | 200, 403 `forbidden` (you can read the note but aren't its owner — group notes always answer `403`: they have no per-person sharing), 404 `not_found` |
| `/api/notes/:id/shares` | PUT | Auth (personal note's owner) | 200, 400 `invalid_body`/`cannot_share_with_self`, 403 `forbidden` (as above — group notes always answer `403`), 404 `not_found`/`user_not_found`, 409 `note_in_group` (the note was moved into a group after your request was authorized — add the person to the group instead) |
| `/api/notes/:id/shares/:userId` | DELETE | Auth (personal note's owner) | 204, 403 `forbidden` (as above — group notes always answer `403`), 404 `not_found`/`share_not_found` |
| `/api/users/suggest?q=` | GET | Auth (session only; an `Authorization` header is ignored) | Suggestions for the email field of the **Members only** share form and a group's **Members** page. Only people you already know are searched: members of any group you are in, and people you have shared one of your own notes with — never the whole site, never the owners of notes shared with you, never yourself or disabled accounts. `q` is trimmed and must be 2–100 characters; it matches anywhere in the email or display name, case-insensitively, with `%`, `_` and `\` taken literally. 200 `{users: [{userId, email, displayName}]}` — at most 8, those whose email or display name starts with `q` first, then by display name. 400 `invalid_body` (`q` missing, repeated, too short or too long, or another query parameter), 429 `too_many_requests` (60 per minute per user) |
| `/api/search?q=` | GET | Auth / Token(`notes:read`) | Full-text search over the notes you can see — the sidebar's search uses it. `q` is trimmed and must then be 1–200 characters (UTF-16 code units); it matches note titles and body text case-insensitively and literally (`%` and `_` are not wildcards). MCP's `search_notes` doesn't trim. `limit` is 1–50 (default 20). `match` is `all` (the default: title or body) or `body` (only notes whose body text matches; the sidebar asks for this, so that when many titles match, notes that match only in their body aren't pushed off the page). The order is the same as `search_notes`: exact titles, then titles that start with `q`, then other title matches, then notes that match only in the body; within each, the most recently updated first. 200 `{hits: [{note, matchedOn, matches: [{sectionId, heading, snippet}]}], nextCursor}`: `note` has the same shape as in `GET /api/notes` but without `sizeBytes`; `matchedOn` is `title`, `body` or `both`; `matches` lists up to 3 sections that contain `q`, in document order — empty when `matchedOn` is `title`, and it can be empty for a body match too; `heading` is cut at 200 code units and is empty for the part of the note before its first heading (`sectionId` `_top`); `snippet` is an excerpt of at most 160 code units near the first hit. Append `#b-<sectionId>` to the note's URL to open it at that section (not for `_top`). `nextCursor` is `null` when there is nothing more; otherwise pass it back as `cursor`, with the same `q` and `match`, for the next page. Pages are read live: a note changed between two pages can be missed or show up twice. 400 `invalid_body` (`q` missing, empty after trimming, too long, repeated or containing NUL; a `limit` or `match` that isn't one of the values above; another query parameter; or a `cursor` this server didn't issue for this `q` and `match`), 429 `too_many_requests` (60 searches per minute per user by default, shared with MCP's `search_notes`; a request that fails validation isn't counted) |
| `/api/notes/:id/public-link` | GET | Auth (owner; on a group note, a role that can manage public links) | 200 `{token: string \| null, slug: string \| null, width}` (`slug` is the custom public URL name, #122; always `null` on a group note; `width` is the public page width, `"standard"`, `"wide"` or `"full"`, returned even while the link is off), 403 `forbidden`, 404 `not_found` |
| `/api/notes/:id/public-link` | PUT | Auth (owner; on a group note, a role that can manage public links) | 200 `{token, slug, width}` (regenerates the token on every call; the custom public URL, if set, and the width are untouched and echoed back), 403 `forbidden`, 404 `not_found`, 409 `conflict` (the note was moved into a group after your request was authorized), 429 `too_many_requests` |
| `/api/notes/:id/public-link` | DELETE | Auth (owner; on a group note, a role that can manage public links) | 204 (clears the token **and** the custom public URL in one update; the public page width is kept), 403 `forbidden`, 404 `not_found`, 429 `too_many_requests` |
| `/api/notes/:id/public-width` | PUT | Auth (owner; on a group note, a role that can manage public links) | Body `{width}` — strict; `width` is `"standard"`, `"wide"` or `"full"`. Sets the width readers see on the note's public page; works whether or not the public link is on, and is kept when the link is turned off. `updatedAt` doesn't change. 200 `{width}`, 400 `invalid_body` (checked first, never counted), 403 `forbidden`, 404 `not_found`, 409 `conflict` (the note moved into or out of a group after your request was authorized; nothing is written), 429 `too_many_requests` (60 per user per 10 minutes, its own bucket, counted after the authorization check — it never uses up the bucket of `PUT`/`DELETE public-link`, so turning a link off is never refused because of width changes). |
| `/api/notes/:id/public-link/slug` | PUT | Auth (owner; on a group note, a role that can manage public links) | Body `{slug}` — sets the custom public URL (#122). 200 `{token, slug, width}`, 400 `invalid_body` (bad format, the note has no public link — setting a custom URL requires the link to be on first — it's a group note, which has no custom public URL, or the note changed hands after your request was authorized), 403 `forbidden`, 404 `not_found`, 409 `public_slug_taken` (unique per owner), 429 `too_many_requests`. ⚠ Throttling: these two `/slug` endpoints share the *slug-change* bucket (10 per user per 10 min, same one as `PATCH /api/notes/:id` with a `slug` key) and count *after* the authorization check — unlike the `PUT`/`DELETE` `public-link` endpoints above, which use their own bucket counted *before* authorization (`GET public-link` isn't throttled at all). `PUT public-width` has a third bucket of its own (60 per user per 10 min), counted after the authorization check. Three buckets, deliberately. |
| `/api/notes/:id/public-link/slug` | DELETE | Auth (owner; on a group note, a role that can manage public links) | 204 (idempotent; the token is untouched), 403 `forbidden`, 404 `not_found`, 429 `too_many_requests` |
| `/api/public/notes/:token` | GET | none | 200 `{title, ydoc: base64, links, width}` with `Cache-Control: no-store` (the note's title and its content snapshot as a Yjs update; deliberately no note id and no timestamp — see [Sharing](./sharing.md); `width` is the public page width set with `PUT /api/notes/:id/public-width`; `links` is `[{id, title, path}]` for the notes this note links to that have a public link with a custom URL, `path` being `/p/<handle>/<name>` — a note that is public only through an anonymous link is never listed, and neither is this note itself), 404 `not_found` (byte-identical for a revoked, regenerated, never-issued, or malformed token — the response never distinguishes "no such note" from "not shared publicly"), 429 `too_many_requests` (two buckets: per-IP for requests that resolve to nothing, per-IP-and-token for reads of a live link) |
| `/api/public/notes/:handle/:slug` | GET | none | Custom-public-URL twin of the row above (#122): same 200 shape and headers (never includes the token), same byte-identical 404 (unknown user, no such custom URL, custom URL without a live public link, bad format — all indistinguishable), same 429 buckets (misses share the per-IP bucket with the token form; hits count per-IP-and-note, so the same note read via both forms gets twice the read quota — accepted, see [known limitations](./known-limitations.md)) |
| `/api/public/notes/:token/uploads/:uploadId` | GET | none | 200 (streams the image with the same headers as `/api/uploads/:id`), 404 `not_found` (bad token — same shape as above — or an upload that doesn't belong to *this* note: a public token only authorizes blobs uploaded to the note it shares), 429 `too_many_requests` |
| `/api/public/notes/:handle/:slug/uploads/:uploadId` | GET | none | Custom-public-URL twin of the row above (#122): same headers, same cross-note 404 rule, same buckets |
| `/api/notes/:id/uploads` | POST | Auth / Transfer token | Takes a session cookie, or a transfer token from the MCP tool `create_transfer_token` (`purpose: "upload"`) as `Authorization: Bearer knbt_…` — an API token (`knb_…`) is refused with `401` pointing at that tool. A transfer token is good for one upload, only on the note it was issued for (another note answers `403`, as does a `download` token), and is used up once the server starts reading the file — a later `413`/`415`/`400`, or a `409` after the file was read (`storage_quota_exceeded` because the file doesn't fit, or `server_busy`), still uses it; `401` (invalid, expired, already used, or its credential revoked or expired; invalid Bearer attempts count against the per-IP limit), a `403`, a `429`, a `409 storage_quota_exceeded` answered before the upload is read (the space is already full), and the `404` for a note you can't see don't use it up (the `404` for a note moved to the trash or deleted mid-upload comes after it was used). 201 `{id, url}` (multipart — send the image as field `file`; only the first file part is kept, any others are ignored — image formats only), 400 `invalid_body` (missing file, or a malformed multipart body), 403 `forbidden` (viewer — has read access but not editor+), 403 `insufficient_scope` (a transfer token whose credential no longer has `notes:write` — for example after its scope was lowered with `PATCH /api/auth/tokens/:id`), 404 `not_found` (no read access to the note at all — note the reversal from the usual pattern: here "can't even see it" is 404 and "can see it, can't edit it" is 403, same as `PATCH`/`DELETE` on the note itself — or the note was moved to the trash or deleted while the upload was being processed), 409 `storage_quota_exceeded` (the note's storage space — its owner's personal space, or its group's — has no room for the file: answered before the upload is read when the space is already full, otherwise after; see **Storage quotas** below), 409 `server_busy` (the storage check couldn't run in time; nothing was stored — retry, with a new transfer token if you used one), 413 `file_too_large` (over the upload size limit: 10 MiB by default, adjustable in Site admin → Site limits), 415 `unsupported_media_type` (magic-byte sniff failed — the declared `Content-Type` is never trusted), 429 `too_many_requests` (120 uploads per user per 10 minutes by default; `POST /api/notes/:id/copy` draws on the same limit — see its row) |
| `/api/mcp` | POST (GET/DELETE → `405`) | Auth / Token(`notes:read`; tools may need `notes:write`, `notes:move` or `notes:delete`) | MCP endpoint (Streamable HTTP, stateless JSON responses). `POST` carries JSON-RPC; `GET`/`DELETE` authenticate and then answer `405` with `Allow: POST` and a JSON-RPC error body. Twenty tools: `list_notes`, `search_notes`, `read_note_outline`, `read_note_section`, `read_note_image`, `list_trash`, `list_folders` need `notes:read`; `edit_note`, `create_note`, `move_note_to_group`, `copy_note`, `delete_note`, `restore_note`, `create_folder`, `rename_folder`, `move_folder`, `move_note_to_folder`, `delete_folder` need `notes:write` and are absent from `tools/list` on a read-only credential; `move_note_to_group` also needs `notes:move` and is absent without it, and `copy_note` with `group_id` answers the tool error `insufficient_scope` without it (unless a `folder_id` in the same call is refused first); `delete_note`, `restore_note` and `delete_folder` also need `notes:delete` and are absent without it; `create_transfer_token` is offered only to a token credential — `download` needs `notes:read`, `upload` needs `notes:write`; so is `open_notes_as_files`, and only on a deployment with the collaboration component — it needs `notes:read`, and the token it issues can send files back only while the credential has `notes:write`. An unauthenticated request still gets `401` with a `WWW-Authenticate` challenge advertising `scope="notes:read notes:write notes:move notes:delete"`. |
| `/.well-known/oauth-protected-resource/api/mcp` | GET | none | 200 (RFC 9728 protected-resource metadata; `Cache-Control: public, max-age=3600`). Only this path-based form exists — the root form is a `404` because the site has a single resource identifier, `<issuer>/api/mcp`. |
| `/.well-known/oauth-authorization-server` | GET | none | 200 (RFC 8414 metadata: `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, PKCE `S256` only, `token_endpoint_auth_methods_supported: ["none"]`). Deliberately does **not** advertise `client_id_metadata_document_supported` — clients fall back to dynamic registration. |
| `/oauth/register` | POST | none (JSON) | 201 `{client_id, client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method, client_id_issued_at}` (RFC 7591 dynamic registration; no secret is issued). 400 `invalid_redirect_uri` (every `redirect_uri` must be `http(s)://localhost`, `127.0.0.1` or `[::1]` on any port, with no query/fragment/userinfo), 400 `invalid_client_metadata` (unsupported `grant_types`/`response_types`/`token_endpoint_auth_method`, a `client_name` that is empty, over 64 characters, or contains control/bidi/zero-width characters). `client_name` defaults to `MCP client`. Rate-limited 30/hour per IP → 429. |
| `/oauth/authorize` | GET | none (browser) | Query: `response_type=code`, `client_id`, `redirect_uri`, `code_challenge` (43–128 chars) + `code_challenge_method=S256`, `resource` (**required**, must equal `<issuer>/api/mcp` exactly), optional `scope` and `state` (≤ 2048, echoed verbatim). Success → `302` to the consent page (`/authorize?req=…`). When `client_id` is unknown or `redirect_uri` does not match a registered one (scheme/host/path compared, **port ignored** per RFC 8252 §7.3), the server does **not** redirect: `400 text/plain` with a static explanation. Every other problem is a `302` back to `redirect_uri` with `error` (`unsupported_response_type`, `invalid_request`, `invalid_target`), `error_description`, `iss`, and `state`. Rate-limited 30/min per IP (every request that clears the parameter check counts, including successful ones) → `429 text/plain`. |
| `/api/oauth/request` | GET | Auth | 200 `{clientName, redirectHost, scope, scopes, existingScope, replacesExisting}` for the consent page (`?req=`); does not consume the request, not rate-limited. `existingScope` is the scope of the credential this app (this `client_id`) already holds for you, or `null` when it has none; `replacesExisting` is `true` exactly when it isn't `null`. The page starts its boxes from `existingScope`, limited to what is also in `scope`, or all unticked when it is `null` — only a default: the grant is decided by the decision below. 410 `oauth_request_invalid` (unknown, expired, or malformed `req`), 404 `not_found`. |
| `/api/oauth/decision` | POST | Auth | body `{req, decision: "deny"}` or `{req, decision: "allow", scope}`, where `scope` — what was ticked on the consent page — is `"notes:read"`, `"notes:read notes:write"`, `"notes:read notes:write notes:move"`, `"notes:read notes:write notes:delete"` or `"notes:read notes:write notes:move notes:delete"`; `deny` ignores `scope`. The app is granted only the scopes that are both in `scope` and in the scope it asked for: anything beyond its request is cut back, not refused. An `allow` without a valid `scope` is `400 invalid_body` and leaves the request unconsumed — a consent page left open from before the upgrade gets this, and reloading it shows the request again while it hasn't expired. 200 `{redirectTo}` — the client's `redirect_uri` carrying either `code`+`state`+`iss` or `error=access_denied`; the browser must navigate there. Both decisions **consume** the request (a second call is `410 oauth_request_invalid`). 400 `invalid_body`, 403 `forbidden` (must change password first), 404, 409 `token_limit` (20 credentials by default, counted together with tokens; a site admin can lower it — the request is consumed even then). |
| `/oauth/token` | POST | none (`application/x-www-form-urlencoded`) | `grant_type=authorization_code` (`code`, `code_verifier`, `client_id`, `redirect_uri`, `resource`) or `grant_type=refresh_token` (`refresh_token`, `client_id`, optional `resource`). 200 `{access_token, token_type: "Bearer", expires_in: 86400, refresh_token, scope}` — the access token is a normal `knb_…` token (24 h), the refresh token (`knbr_…`, no expiry) is rotated on every use and only works here. Every *protocol* error is `400` (RFC 6749 §5.2) — `invalid_grant` (used/expired/unknown code, PKCE mismatch, `client_id`/`redirect_uri` mismatch, disabled account, credential limit, revoked or already-rotated refresh token), `invalid_target` (`resource` missing or not `<issuer>/api/mcp`), `invalid_request` (missing or wrong-length parameter), `unsupported_grant_type` — the two exceptions are `415` for a JSON body and `429` when rate-limited (60/min per IP). Re-authorizing the same app replaces its previous credential. |
| `/api/uploads/:id` | GET | Auth / Transfer token | Takes a session cookie, or a transfer token (`purpose: "download"`) — usable any number of times until it expires, for uploads of the note it was issued for only (another note's upload answers `403`, as does an `upload` token); each such request that gets past the access checks counts against the 300-reads-per-minute token budget (`429 too_many_requests` once it runs out). An API token is refused with `401`. 200 (streams the file with `Cache-Control: private, max-age=31536000, immutable`), 403 `forbidden` (authenticated, upload exists, but you have no access to the note it belongs to — deliberately not 404: the id is an unguessable `crypto.randomUUID()`, so there's nothing to hide by pretending it doesn't exist), 404 `not_found` (bad/non-uuid id, or no such upload, or the DB row exists but the file is missing from disk) |
| `/api/transfer/export` | GET | Bulk transfer token | 200 `application/gzip`: every note in the token's range as a `.tar.gz`, written while the notes are read (see **Bulk transfer** below). 403 `forbidden` (the token's group or folder can no longer be seen), 413 `too_many_notes` (more than `TRANSFER_EXPORT_MAX_NOTES`=3000 notes in the range; counts as a download), 429 `too_many_requests` (another download is running on the site — `Retry-After: 30`, not counted — or the 10 downloads per user per hour are used up). `HEAD` answers `404`. |
| `/api/transfer/notes/:id` | GET | Bulk transfer token | 200 `text/markdown; charset=utf-8`: one note's file, as in the download, with `Content-Disposition: attachment; filename*=UTF-8''<file name>` and `Cache-Control: no-store`. 403 `forbidden` (the note is outside the token's range, or the token's group or folder can no longer be seen), 404 `not_found` (a note you can't see — including one you could see through the token's group or folder until you lost access to it — one that doesn't exist, one in the trash and a malformed id all answer the same), 429 `too_many_requests` (the content-read budget, 120 reads per minute by default, spent only once the checks before it have passed), 503 `server_busy` with `Retry-After` and 500 `too_complex` (turning the note into Markdown, as for `GET /api/notes/:id/content`). `HEAD` answers `404`. |
| `/api/transfer/notes` | PUT | Bulk transfer token whose `access` is `write` | Body: one file, as `text/markdown`, at most `TRANSFER_WRITE_BODY_LIMIT`=262144 bytes, the same as the body limit of `POST /api/notes/:id/edits`. 200 (a file written back to the note its `id` names — including `unchanged` and a rename only), 201 (a file with no `id`: a new note). The reply's fields, and the statuses this endpoint shares with the other three, are under **Bulk transfer** below. |
| `/api/transfer/token` | DELETE | Bulk transfer token | 204 — the token used for this request stops working. |
| `/api/groups` | GET | Auth | 200 `[{id, name, myRole, canManageMembers, canManageGroup, autoVersions, createdAt}]` — the groups you belong to: first those where your role is the built-in Admin, then the built-in Member, then a custom role; within each, by name, then id (the sidebar's **Groups** tab lists them in this order). `myRole` is your role in the group, `{id, builtin, name, permissions, memberCount}`: `builtin` is `"admin"` or `"member"` for the two built-in roles (whose `name` is `null`), `permissions` holds the role's seven flags (`read`, `create`, `edit`, `delete`, `managePublicLink`, `manageMembers`, `manageGroup`; `read` is always `true`: every role can read the group's notes), and `memberCount` is how many of the group's members hold that role. `canManageMembers` / `canManageGroup` say whether you can manage the group's members / rename and delete the group and manage its roles: your role's flag, or always for a site admin. `autoVersions` is the group's switch for saving versions of its notes automatically |
| `/api/groups` | POST | Auth | Body `{name}` — strict; 1–80 characters after trimming (counted in Unicode code points, not UTF-16 units — so 80 single-code-point emoji such as 😀 fit). 201 (the group, shaped as above; it starts with the two built-in roles and you are its first member, holding the Admin role; it starts on the default storage plan for new groups), 400 `invalid_body`/`invalid_name`, 409 `server_busy` (the default plan was deleted at that moment — retry). If the site settings row is missing, creating a group fails with 500. |
| `/api/groups/:id` | PATCH | Auth (a role that can manage the group; site admins always) | Body `{name?, autoVersions?}` (strict, at least one) — `name` renames; `autoVersions` turns automatic versions of the group's notes on or off. 200 (the group), 400 `invalid_body`/`invalid_name`, 403 `forbidden` (a member whose role can't manage the group), 404 `not_found` |
| `/api/groups/:id` | DELETE | Auth (a role that can manage the group; site admins always) | Body (strict, required) `{mode: "transfer", transferTo}` or `{mode: "delete"}`. Deletes the group, its roles and its memberships. `transfer`: `transferTo` is the user id of a member who holds the group's built-in Admin role (it can be you); every note in the group becomes that person's personal note (notes in the group's trash stay in the trash, now that person's) — its slug is kept, or given a numeric suffix if they already have a note with that slug (the same way as a move, see `POST /api/notes/:id/move`); `slugIsCustom` is unchanged, `prevSlug` is cleared, `publicToken` and `publicSlug` become `null` (the public link is turned off; the public page width is kept), `updatedAt` doesn't change — and its old `/g/<group id>/<slug>` address forwards to it for a month. The group's folders go with them, into a folder named after the group in that person's notes (see **Folders** below the table). With transfer, the group's attachments count against the receiving person's personal space: if they don't fit, the answer is 409 `storage_quota_exceeded` and nothing changes — a site admin can give that person a larger plan, the notes can go to another admin, or the group can be deleted with mode `"delete"` instead. `delete`: the notes are permanently deleted with the group, including those in its trash, and their attachments; they don't go to the trash; the group's folders are deleted too. 204, 400 `invalid_body` (including a missing body), 403 `forbidden`, 404 `not_found` (including: the group was deleted by someone else while the request was being processed), 409 `not_admin` (see below), 409 `storage_quota_exceeded` (transfer only, see above; `usedBytes`/`quotaBytes` only for the receiving admin and site admins), 409 `server_busy` (the deletion ran into a simultaneous links update or another transfer to the same person, or the storage check couldn't run in time, or a folder change in the group or in the receiving person's notes kept it waiting too long; nothing changed — retry), 409 `group_not_empty` (a safeguard that isn't expected to occur: a note being added while the group is being deleted waits for the deletion and then fails) |
| `/api/groups/:id/roles` | GET | Auth (member; site admins always) | 200 `[{id, builtin, name, permissions, memberCount}]` — the group's roles, the same shape as `myRole` above: the built-in Admin role, then the built-in Member role, then the custom roles by name (case-insensitive), then id. 404 `not_found` |
| `/api/groups/:id/roles` | POST | Auth (a role that can manage the group; site admins always) | Body `{name, permissions}` — strict. Creates a custom role. `permissions` must carry exactly the six settable flags as booleans — `create`, `edit`, `delete`, `managePublicLink`, `manageMembers`, `manageGroup` — and no `read` (every role can read the group's notes); any combination is accepted, none implies another. `name` is trimmed and NFC-normalized, then must be 1–40 characters (counted in Unicode code points); it must be unique within the group ignoring case, and must not be a built-in role's display name (`Admin`, `Member`, `管理員`, `一般成員`, ignoring case). 201 (the role, shaped as in `GET …/roles`: `builtin: null`, the normalized `name`, `permissions` with `read: true`, `memberCount: 0`), 400 `invalid_body` (a missing or extra field, including `read`), 400 `invalid_name`, 403 `forbidden`, 404 `not_found`, 409 `role_name_taken` |
| `/api/groups/:id/roles/:roleId` | PATCH | Auth (a role that can manage the group; site admins always) | Body `{name?, permissions?}` — strict, at least one of the two; `permissions`, when given, carries all six settable flags and no `read`; `name` follows the rules of `POST` above. 200 (the role as it is now), 400 `invalid_body`/`invalid_name`, 403 `forbidden`, 404 `not_found` / `role_not_found`, 409 `builtin_role` (any change to the built-in Admin role; renaming the built-in Member role — its permissions can be changed), 409 `role_name_taken` (a rename to a built-in role's display name answers this one whichever role it targets — see **Group endpoints** below). A change to `edit` re-checks the open editors of everyone holding the role: losing it turns their editors read-only, regaining it makes them editable again; nobody loses read access |
| `/api/groups/:id/roles/:roleId` | DELETE | Auth (a role that can manage the group; site admins always) | Deletes a custom role; everyone holding it is moved to the built-in Member role and their open editors are re-checked against it. 204, 403 `forbidden`, 404 `not_found` / `role_not_found`, 409 `builtin_role` (either built-in role) |
| `/api/groups/:id/members` | GET | Auth (member; site admins always) | 200 `[{userId, email, displayName, roleId, builtin}]` — `roleId` is the member's role (see `GET …/roles`), `builtin` that role's `builtin`; members holding the built-in Admin role first, then by display name, then user id. 404 `not_found` |
| `/api/groups/:id/members` | PUT | Auth (a role that can manage members; site admins always) | Body `{email, roleId?}`; `roleId` defaults to the group's built-in Member role. Adds a person, matched case-insensitively by email (a disabled account can be added). Only adds: someone already in the group gets `409 already_member` and keeps their role. 200 (the member), 400 `invalid_body`, 403 `forbidden`, 404 `not_found` (the group) / `user_not_found` / `role_not_found` |
| `/api/groups/:id/members/:userId` | PATCH | Auth (a role that can manage members; site admins always) | Body `{roleId}`. Changes the member's role. 200 (the member), 400 `invalid_body`, 403 `forbidden`, 404 `not_found` / `role_not_found`, 409 `last_admin` (would leave the group without anyone holding the Admin role) |
| `/api/groups/:id/members/:userId` | DELETE | Auth (a role that can manage members, or `:userId` is yourself; site admins always) | Removes the person — or, on yourself, leaves the group. Their open editors on the group's notes are disconnected. 204, 403 `forbidden`, 404 `not_found`, 409 `last_admin` |
| `/api/storage` | GET | Auth | 200 `{usedBytes, quotaBytes, planName, byKind}` for your personal space; `quotaBytes` is `null` when the plan has no limit. `byKind` is `{image, audio, video, document, other}`, the bytes of the attachments in your personal notes, by kind, all five always present (by MIME type: `image/*`, `audio/*`, `video/*`; PDF, `text/*`, Word, Excel, PowerPoint and RTF count as `document`; anything else is `other`). It is read separately from `usedBytes`, so while an upload is in progress the two can briefly disagree |
| `/api/groups/:id/storage` | GET | Auth (any member of the group, whatever their role; site admins always) | 200, the same shape for the group's space, plus `byMember: [{userId, handle, displayName, bytes}]` — how much of the group's space each person has taken up, largest first, people with nothing left out. A file uploaded into one of the group's notes counts for whoever uploaded it; when someone moves a note into the group, every file of that note counts for them, whoever uploaded it; when someone copies a note into the group, the files copied with it — the note's own attachments that its content links to — count for them. Moves made before v0.10 weren't recorded: those files still count for whoever uploaded them. People who have left the group are still listed, and files counted for an account that has since been deleted are added up in one entry whose `userId`, `handle` and `displayName` are `null`. 404 `not_found` (as for the other group endpoints) |
| `/api/admin/users` | GET | Admin | 200 — each user carries `storage: {planId, planName, usedBytes, quotaBytes}`: their storage plan and how much of their personal space is used (`quotaBytes` is `null` when the plan has no limit; disabled users are listed the same way), and `lastLoginAt`: when they last signed in — with a password, through a sign-in service, by proving the account is theirs while linking a sign-in, by registering, by accepting an invitation, or by setting a new password with a reset link — or `null` if they haven't since the upgrade that added it. Changing or adding a password doesn't count as signing in. Recording it never holds up a sign-in: a sign-in made while another change to the same account is in progress can go unrecorded. `hasPassword`: whether the account has a password. `passwordResetExpiresAt`: when the person's password reset link (unused, not cancelled, not expired) expires, or `null` if there is none; it is shown even while password sign-in is off. `lastActiveAt`: when they last did something signed in from the browser — a request to an endpoint that requires being signed in (API tokens and connected apps don't count, and neither does coming back from linking a sign-in service), opening a note over the live editing connection (a read-only viewer opening one counts too), or a change they make over that connection — or signed in, or `null` if that was never recorded; the upgrade that added it filled it in from `lastLoginAt`. It is recorded at most once a minute per account, without holding anything up, so it can trail the latest activity (see [known limitations](./known-limitations.md)) |
| `/api/admin/invites` | POST | Admin | Body `{expiresInDays, label?}` (strict) — `expiresInDays` is an integer from 1 to 7; `label` is trimmed (an empty one is stored as none), at most 100 characters counted in Unicode code points, no NUL character or unpaired surrogate (400 otherwise), and only admins see it. Makes a one-time invitation link. 201 `{invite, token}` — `token` is the secret in the link `/register#invite=<token>` and appears in this response only; it can't be read back later. 400 `invalid_body`, 403 `password_login_disabled` (an invitation can't be created while password sign-in is turned off). |
| `/api/admin/invites` | GET | Admin | 200, an array of invitations, newest first. Each has `id`, `label` (or `null`), `status` (`pending`, `used`, `revoked` or `expired`), `createdAt`, `expiresAt`, `usedAt`, `revokedAt`, `createdBy` and `usedBy` (`{id, email}`, or `null`). The token isn't included. Used, revoked and expired invitations are removed 30 days after they ended; the cleanup has no schedule and runs when a password reset link or an invitation is created. |
| `/api/admin/invites/:id` | DELETE | Admin | Revokes an invitation; the link stops working. 204 (also for one that was already revoked or has expired — nothing changes), 404 `invite_not_found` (missing, not a uuid, or not an invitation), 409 `invite_used`. Still allowed while password sign-in is turned off. |
| `/api/admin/users/:id/disable` | POST | Admin | Also revokes the person's unused password reset link. 204, 400 `cannot_disable_self`, 404 `user_not_found`, 409 `last_site_admin` (they are the only enabled admin — disabled admins don't count) |
| `/api/admin/users/:id/enable` | POST | Admin | 204, 404 `user_not_found` |
| `/api/admin/users/:id/promote` | POST | Admin | 204, 404 `user_not_found` |
| `/api/admin/users/:id/demote` | POST | Admin | Removes the user's admin role. 204 (also when they weren't an admin), 400 `cannot_demote_self` (you can't remove your own), 404 `user_not_found` (including ids that aren't uuids), 409 `last_site_admin` (no other enabled admin would be left — disabled admins don't count) |
| `/api/admin/users/:id/password-reset` | POST | Admin | Body `{expiresInHours}`, an integer from 1 to 24. Makes a one-time password reset link for the person, replacing their previous unused one. 201 `{token, expiresAt}` — the token appears only in this response and cannot be read back again; the link to send is `<site address>/reset-password#token=<token>`. 400 `invalid_body`, 403 `password_login_disabled`, 404 `user_not_found`, 409 `user_disabled`/`password_not_set` |
| `/api/admin/users/:id/password-reset` | DELETE | Admin | Cancels the person's unused password reset link. 204 — also when there is none, and also while password sign-in is off; 404 `user_not_found` |
| `/api/admin/users/:id/storage-plan` | PATCH | Admin | Body `{planId}` (strict). Puts the user's personal space on that plan (disabled users included). 200 (the user, as in `GET /api/admin/users`), 400 `invalid_body`, 404 `user_not_found`/`storage_plan_not_found` (including ids that aren't uuids) |
| `/api/admin/storage-plans` | GET | Admin | 200 `{plans: [{id, name, quotaBytes, userCount, groupCount, overQuotaCount, isDefaultForUsers, isDefaultForGroups, createdAt, updatedAt}], defaults: {userPlanId, groupPlanId}}`, sorted by name ignoring case. `quotaBytes` is `null` for no limit; `overQuotaCount` is how many users and groups on the plan already use more than its limit. 500 if the site settings row is missing |
| `/api/admin/storage-plans` | POST | Admin | Body `{name, quotaBytes}` (strict; `quotaBytes` a whole number of bytes from 0 to 2^50, or `null` for no limit; the name is trimmed and must be 1–40 characters, counted in Unicode code points, with no NUL character or unpaired surrogate). 201 (the plan), 400 `invalid_body`/`invalid_name`, 409 `storage_plan_name_taken` (same name ignoring case) |
| `/api/admin/storage-plans/:id` | PATCH | Admin | Body `{name?, quotaBytes?}` (strict, at least one; same rules as POST). Lowering the limit below what a space already uses deletes nothing — that space just can't add attachments until it's under the limit again; raising it applies to the next upload. 200 (the plan), 400 `invalid_body`/`invalid_name`, 404 `storage_plan_not_found`, 409 `storage_plan_name_taken` |
| `/api/admin/storage-plans/:id` | DELETE | Admin | 204, 404 `storage_plan_not_found`, 409 `storage_plan_is_default` (it is the default for new users or new groups — checked first, so a default that is also in use answers this), 409 `storage_plan_in_use` (users or groups are on it) |
| `/api/admin/storage-plans/defaults` | PATCH | Admin | Body `{userPlanId?, groupPlanId?}` (strict, at least one). The plan new users / new groups start on; people and groups that already exist keep theirs. 200 `{userPlanId, groupPlanId}`, 400 `invalid_body`, 404 `storage_plan_not_found`, 500 if the site settings row is missing |
| `/api/admin/groups` | GET | Admin | 200 — every group, whether or not you're a member: `[{id, name, createdAt, memberCount, storage: {planId, planName, usedBytes, quotaBytes}}]`, sorted by name ignoring case |
| `/api/admin/groups/:id/storage-plan` | PATCH | Admin | Body `{planId}` (strict). Puts the group's space on that plan. 200 (the group, as in the row above), 400 `invalid_body`, 404 `group_not_found`/`storage_plan_not_found` (including ids that aren't uuids) |
| `/api/admin/auth/providers` | GET | Admin | 200 `{providers: [{id, template, displayName, issuerUrl, clientId, hasSecret, enabled, autoCreateEnabled, sortOrder, legacyCallback, callbackUrl, insecureIssuer, issuerResolved, createdAt, iconKind, icon}]}` in display order (`sortOrder`, then creation time) — `clientId` is `null` while it hasn't been entered yet. Never includes the client secret (`hasSecret` instead). `callbackUrl` is the URL to register on the identity provider; `insecureIssuer` is true for a plain `http://` issuer; `issuerResolved` says whether Knotebook has recorded the issuer the identity provider reports — set when its discovery document is read successfully at the start of a sign-in or by `…/test` (for a provider imported from `OIDC_*` by v0.6 to v0.15, possibly already at import, from existing sign-ins), and cleared when the issuer changes; `iconKind` is the icon setting (`template`, `gitlab`, `google`, `upload` or `none`) and `icon` what it resolves to, in the same form as `/api/auth/config`; `autoCreateEnabled` is **Creates accounts** — whether a first sign-in through the service creates an account for an email that has none |
| `/api/admin/auth/providers` | POST | Admin | Body `{template: "gitlab" \| "google" \| "oidc", displayName, issuerUrl, clientId?, clientSecret?}` (strict). `displayName`, `issuerUrl` and `clientId` are trimmed; a `clientId` left out is stored as `null` (not entered yet); `clientSecret` is sent as-is (not only whitespace, at most 4096 characters). `displayName` 1–40 characters and `clientId`, when sent, 1–512 (both counted in Unicode code points); `issuerUrl` must start with lowercase `http://` or `https://`, parse as a URL, and be at most 512 characters. No field may contain a NUL character or an unpaired surrogate. `autoCreateEnabled` isn't accepted (400 `invalid_body`); a new service starts with it off. 201 (the provider, always turned off), 400 `invalid_body` |
| `/api/admin/auth/providers/:id` | PATCH | Admin | Body `{displayName?, issuerUrl?, clientId?, clientSecret?, enabled?, autoCreateEnabled?, sortOrder?, iconKind?}` (strict, at least one; same field rules as POST; `sortOrder` an integer 0–100000). A field left out is unchanged. `autoCreateEnabled` is a boolean (**Creates accounts**); changing the issuer doesn't change it, and switching it doesn't make a sign-in already in progress through the service fail and isn't subject to the sign-in lockout check below. `iconKind` is `template`, `gitlab`, `google` or `none` (`upload` is set only by uploading an image); setting it discards an uploaded image. An `issuerUrl` different from the stored one clears the stored secret (unless `clientSecret` is in the same request) and the recorded issuer, and turns the provider off (even if the same request sends `enabled: true`). 200 (the provider), 400 `invalid_body`, 404 `not_found`, 409 `provider_secret_missing` (turning on a provider with no client secret), 409 `provider_client_id_missing` (turning on a provider with no client ID). If both the client ID and the secret are missing, the response is `provider_client_id_missing`. While the password sign-in setting is off, a change that turns the provider off (including an `issuerUrl` change) is refused with 409 `sso_provider_required`/`admin_sso_link_required` under the same rule as `/api/admin/auth/settings`; nothing is saved. |
| `/api/admin/auth/providers/:id` | DELETE | Admin | 204, 404 `not_found`, 409 `provider_enabled` (turn it off first). Linked sign-ins are kept |
| `/api/admin/auth/providers/:id/icon` | PUT | Admin | `multipart/form-data`; the first file part is used, whatever its field name. The file must be PNG, JPEG or WebP — judged by its content, not its name or declared type — and at most 256 KB (262144 bytes). Sets the service's icon to that image, as is. 200 (the provider), 400 `invalid_body` (malformed multipart body, or no file part), 403 `forbidden` (an `Origin` header that doesn't match the host the request was sent to), 404 `not_found`, 413 `file_too_large`, 415 `unsupported_media_type` (a body that isn't `multipart/form-data`, or a file that isn't PNG, JPEG or WebP). The `Content-Type` and `Origin` checks run before the admin check, so a request that fails them gets that `415` or `403` even when it isn't signed in as an admin |
| `/api/admin/auth/providers/:id/impact` | GET | Admin | 200 `{linkedUsers, lockedOutUsers, issuerResolved, actingAdminLockedOut}`, computed whether or not the provider is currently on: `linkedUsers` counts accounts (not disabled) with a sign-in linked to this provider; `lockedOutUsers` counts those of them with no usable password (none, or the password sign-in setting is off) and no sign-in linked to another enabled provider; `actingAdminLockedOut` says whether you would be left with no sign-in linked to an enabled provider other than this one, given that you have one linked to it. Sign-ins are matched by the recorded issuer, or the typed one while `issuerResolved` is false. 404 `not_found` |
| `/api/admin/auth/providers/:id/test` | POST | Admin | No body. Reads the provider's discovery document only (no token request, the client secret isn't sent). 200 `{issuer, warnings}` — `warnings` may include `insecure_issuer`, `client_secret_post_not_advertised`, `client_id_missing`, `secret_undecryptable`; on success the reported issuer is recorded (unless its issuer, client ID or secret was changed in the meantime). 404 `not_found`, 502 `oidc_discovery_failed` |
| `/api/admin/auth/discover` | POST | Admin | Body `{issuerUrl}` (strict; same rules as POST). Reads that issuer's discovery document before saving; stores nothing. 200 `{issuer, warnings}` (`insecure_issuer`, `client_secret_post_not_advertised`), 400 `invalid_body`, 502 `oidc_discovery_failed` |
| `/api/admin/auth/settings` | GET | Admin | 200 `{registrationEnabled, passwordLoginEnabled, passwordLoginForced, passwordLoginImpact: {usersWithoutSso, actingAdminHasSso, enabledProviders}}` — `registrationEnabled` is **Allow self-registration with a password**, which only governs `POST /api/auth/register` (a sign-in service's first-sign-in account creation is its own `autoCreateEnabled`); `passwordLoginEnabled` is the setting itself; `passwordLoginForced` says whether `PASSWORD_LOGIN_FORCE_ENABLE` is `true`; `usersWithoutSso` counts accounts (not disabled) with no sign-in linked to an enabled provider; `actingAdminHasSso` says whether you have one; `enabledProviders` counts the turned-on sign-in services. |
| `/api/admin/auth/settings` | PATCH | Admin | Body `{registrationEnabled?, passwordLoginEnabled?}` (strict, at least one; `registrationEnabled` only governs password self-registration). 200 (same shape as GET), 400 `invalid_body`, 409 `sso_provider_required`/`admin_sso_link_required` — only when the body sends `passwordLoginEnabled: false`: at least one sign-in service must be turned on and you must have a sign-in linked to one; nothing in the request is saved. Doesn't sign anyone out. |
| `/api/admin/versions/settings` | GET | Admin | 200 `{keepAllDays, dailyUntilDays, autoVersionsEnabled, autoWaitMinutes}` — how automatic note versions are thinned out: those less than `keepAllDays` days old are all kept; from that age up to `dailyUntilDays` days, the newest of each UTC day; older ones, the newest of each UTC week (weeks start on Monday). A note's manual versions, the version its content is based on and its newest version are never thinned out. A note is thinned each time a new version of it is saved, and in an hourly background pass that takes up to 100 notes at a time. `autoVersionsEnabled` is the site-wide switch for saving versions automatically; `autoWaitMinutes` is how many minutes a note's content must go without a new change before it is saved as an automatic version (1–60, default 5) |
| `/api/admin/versions/settings` | PATCH | Admin | Body `{keepAllDays?, dailyUntilDays?, autoVersionsEnabled?, autoWaitMinutes?}` (strict, at least one; whole numbers, 1 ≤ `keepAllDays` ≤ `dailyUntilDays` ≤ 3650 must hold after merging with the stored values, and `autoWaitMinutes` must be from 1 to 60). A field left out is unchanged. 200 (same shape as GET), 400 `invalid_body` (nothing is saved). New day counts apply from the next thinning; a new `autoWaitMinutes` applies from the server's next check for notes due to be saved (within about 30 seconds), including notes already waiting; a change to `autoVersionsEnabled` doesn't reach a note that is open at the time right away (see **Note versions** below) |
| `/api/admin/trash/settings` | GET | Admin | 200 `{retentionDays}` — how many days a note stays in the trash before it is permanently deleted, a whole number from 1 to 3650 (7 by default), or `null` when notes in the trash are never deleted automatically. 500 if the site settings row is missing |
| `/api/admin/trash/settings` | PATCH | Admin | Body `{retentionDays}` (strict, required; a whole number from 1 to 3650, or `null` to stop deleting notes in the trash automatically). 200 (same shape as GET), 400 `invalid_body` (nothing is saved), 500 if the site settings row is missing. The new value applies from the next clean-up pass, which runs once an hour: shortening the period deletes nothing right away, and that pass then starts deleting the notes that have been in the trash longer than the new period (measured from when it was moved there), oldest first and at most 2,000 per pass — with more than that due, it takes several passes. |
| `/api/admin/limits` | GET | Admin | 200 `{items, machine, changes}`. `items` has one entry per site limit, in a fixed order: `key`, `value` (the limit in force now), `source` (`env`, `admin`, `default` or `auto`), `locked` (true when an environment variable sets it), `stored` (the value saved in Site admin, or `null`; returned even when an environment variable overrides it), `storedIgnored: true` when the saved value isn't one this limit accepts (out of range or not a multiple of its step) and so isn't applied, and — for the two parse worker items that default to `auto` — `autoValue` (what the server would pick on its own; `parseWorkers` also has `autoSource`, `cpu` or `memory`). `machine` is the CPU count and memory the automatic values are computed from. `changes` is the 50 most recent changes, newest first: `id`, `key`, `oldValue`/`newValue` (what was stored), `oldEffective`/`newEffective` (the limit in force before and after), `actor` (`{id, email, displayName}`, or `null` once that account is gone) and `changedAt`. Seventeen limits are listed: upload and note size, the parse worker settings, nine per-user rate budgets, the sign-in lifetime and the per-user token limit. |
| `/api/admin/limits` | PATCH | Admin | Body `{set: {<key>: number \| null, …}}` (strict, at least one key; `null` returns that limit to its default or automatic value). Every value is a whole number in the limit's own unit, from its minimum to its maximum, and a multiple of the step the admin pages use for it. Takes effect at once, without a restart (a new sign-in lifetime applies to sign-ins made after the change), and each change is added to `changes`. Nothing is saved unless every key passes; the first failing key is the one reported. 200 (the same body as `GET`), 400 `invalid_body` (the body's shape, a value out of range — `<key> must be a whole number from <min> to <max>.` — or not a multiple of the step — `<key> must be a multiple of <step>.`), 403 `forbidden`, 409 `limit_locked_by_env` (the key is set by an environment variable — `NOTE_MAX_BYTES` or one of the `PARSE_*` ones — and can't be changed or reset here; `locked` is true on its item) |
| `/api/admin/ai/providers` | GET | Admin | 200 (never includes the encrypted key; `hasKey` boolean instead) |
| `/api/admin/ai/providers` | POST | Admin | 201, 400 `invalid_body`, 400 `base_url_has_credentials` (`baseUrl` contains a username or password) |
| `/api/admin/ai/providers/:id` | PATCH | Admin | 200, 400 `invalid_body`, 400 `base_url_has_credentials` (`baseUrl` contains a username or password), 404 `not_found` (a `baseUrl` different from the stored one clears the stored key unless the same request also supplies `apiKey`; a request that sends `apiKey` alone binds that key to whatever `base_url` is stored at the time) |
| `/api/admin/ai/providers/:id` | DELETE | Admin | 204, 404 `not_found` (cascades: its models are deleted, and any action bound to one of them falls back to the next available chat model) |
| `/api/admin/ai/providers/:id/test` | POST | Admin | 200 `{ok:true}` (fetches the provider's model list upstream with the stored key — never echoes upstream response bodies back), 404 `not_found`, 502 `upstream_error`, 503 `provider_unavailable` (key can't be decrypted with the current `APP_SECRET` — re-enter it) |
| `/api/admin/ai/models` | GET | Admin | 200 |
| `/api/admin/ai/models` | POST | Admin | 201, 400 `invalid_body` (bad `providerId`), 409 `model_taken` |
| `/api/admin/ai/models/:id` | PATCH | Admin | 200, 400 `invalid_body`, 404 `not_found`, 409 `model_taken` |
| `/api/admin/ai/models/:id` | DELETE | Admin | 204, 404 `not_found` |
| `/api/admin/ai/actions` | GET | Admin | 200 (each action carries `builtin` and `kind`: `"instruct"` for the built-in Instruct action, `"quick"` for every other action) |
| `/api/admin/ai/actions` | POST | Admin | 201, 400 `invalid_body` (`userTemplate` must contain `{{text}}`) |
| `/api/admin/ai/actions/:id` | PATCH | Admin | 200, 400 `invalid_body` (`userTemplate` must contain `{{text}}`, and for the built-in Instruct action also `{{instruction}}`), 400 `instruct_preview_only` (the built-in Instruct action can't be set to `direct`), 404 `not_found` |
| `/api/admin/ai/actions/:id` | DELETE | Admin | 204, 400 `builtin_action` (the five built-in actions can't be deleted, only renamed/disabled), 404 `not_found` |
| `/api/ai/actions` | GET | Auth | 200 `{actions: [{id, name, applyMode, kind}]}` (only actions that currently resolve to a usable model — i.e. "not configured" means "doesn't appear", same resolution logic as `POST /api/ai`; `kind` is `"instruct"` for the built-in Instruct action and `"quick"` otherwise) |
| `/api/ai` | POST | Auth | Body `{action_id, note_id, text, instruction?, focus?}` (snake_case, a deliberate exception to the rest of the API's camelCase). `action_id` and `note_id` are lowercased first. `instruction` (1–500 code points after trimming) is required for the built-in Instruct action, which also accepts `focus` (up to 2000 code points); any other action must send neither (an empty string counts as sending it). Breaking either rule returns 400 `invalid_body` before the access check, without counting against the rate limit. On success, streams `text/event-stream` (SSE `delta`/`done`/`error` events) proxying the resolved upstream provider; once streaming starts, failures surface only as an SSE `error` event, never an HTTP error status. Before streaming starts: 400 `invalid_body`, 403 `forbidden` (viewer), 404 `not_found` (no access to the note, or the action doesn't exist/is disabled), 429 `too_many_requests` (30 requests/minute per user by default), 503 `ai_not_configured` (action's model doesn't resolve to anything usable), 503 `provider_unavailable` (provider has no key yet, or its key can't be decrypted with the current `APP_SECRET`) |
| `/collab` | WebSocket (upgrade) | collab token (Hocuspocus auth message, not a header/cookie — obtained from `POST /api/notes/:id/collab-token`) | Real-time collaboration endpoint (Yjs/Hocuspocus); a reverse proxy in front of Knotebook must forward WebSocket upgrades on this path — see [Deployment prerequisites](./self-hosting.md#deployment-prerequisites) |
| `/healthz` | GET | none | 200 |
Body/query validation failures generally return `400 invalid_body`. Passwords must be at least 12 characters (`password_too_short` otherwise). Globally: any state-changing request (`POST`/`PUT`/`PATCH`/`DELETE`) that carries a body must set `Content-Type: application/json`, or the server rejects it with `415` before it reaches the route handler. Outside `/oauth/*` and `/.well-known/*` that is `415 unsupported_media_type`, and the exceptions are `POST /api/notes/:id/uploads` and `PUT /api/admin/auth/providers/:id/icon`, which require `multipart/form-data` instead (and reject `application/json` there with the same `415`), and `PUT /api/transfer/notes`, which requires `text/markdown` (and rejects anything else with the same `415`). Under `/oauth/*` and `/.well-known/*` the `415` is in the OAuth form (`{error: "invalid_request", error_description}`), and the exception is `POST /oauth/token`, which requires `application/x-www-form-urlencoded` instead (see the OAuth rows).
**Note objects.** Note objects returned by `/api/notes` (`GET` and `POST`), `/api/notes/:ref`, `/api/notes/by-path/…`, `/api/notes/by-group-path/…`, `PATCH /api/notes/:id`, `POST /api/notes/:id/move`, `POST /api/notes/:id/copy` and `PUT /api/notes/:id/folder` carry, besides the note's own fields: `ownerId` and `ownerHandle` (both `null` on a group note — a group note has no owner), `groupId` and `group: {id, name}` (both `null` on a personal note), your `role`, and `permissions` — seven booleans saying what you can do with this note: `read`; `edit`; `delete` (moves the note to the trash); `manageShares` (the per-person sharing endpoints — only a personal note's owner); `managePublicLink` (the `public-link` endpoints); `changeSlug` (a `PATCH` carrying `slug` — if the same body also carries `title`, `edit` is needed as well); and `moveToGroup` (`POST /api/notes/:id/move` — only a personal note's owner). They also carry `folderId`, the folder the note is in as you see it (`null` = the top level): for your own notes and group notes, the folder it is in; for a note someone else shared with you, the folder you filed it in yourself with `PUT /api/notes/:id/folder` — the owner's folders are never shown to you. `GET /api/notes` also gives each note `sizeBytes`: the bytes of the note's stored editing state plus all of its attachments (every file counted as in **Storage quotas** below). The editing state also keeps traces of earlier edits, so a short note that has been edited a lot can come out larger than its text suggests.
**Roles and groups.** Your `role` on a personal note is `owner` or the role of a per-person share. On a group note it comes from your role in the group — `editor` if the role can edit, `viewer` if it can only read — and is never `owner`; `permissions` spells out what that role allows on this note. A group has two built-in roles — Admin, which allows everything on the group's notes except `manageShares` and `moveToGroup` and can't be changed, and Member, which starts out allowing `read` and `edit` on them and creating notes in the group — and any number of custom roles (see the `…/roles` rows above). Every role allows `read`; Member's and the custom roles' other flags are whatever the group set them to.
**Group endpoints.** On `/api/groups/:id…`, a `404 not_found` is byte-identical whether the id is malformed, the group doesn't exist, or you aren't a member (and aren't a site admin). A site admin can manage every group on these endpoints — members, roles and the group itself — whether or not they're a member; `GET /api/groups` still lists only groups they belong to, and a rename by a site admin who isn't a member reports `myRole: null` (with `canManageMembers`/`canManageGroup` true). A malformed `roleId` — in a body or in the path — answers `404 role_not_found`; so does a well-formed one that isn't in the group, once the body is valid. `409 builtin_role` means the request would change the built-in Admin role, rename the built-in Member role, or delete either. `409 role_name_taken` has two causes, which the response doesn't tell apart: another role in the group already has that name (ignoring case), or the name is a built-in role's display name. The body — including the name's length and display-name checks — is validated before the role is looked up, so a rename to one of those names answers `role_name_taken` whichever role it targets (either built-in role, or a well-formed `roleId` that isn't in the group), and a bad body answers `400` before an unknown well-formed `roleId` would answer `404 role_not_found`. On `DELETE /api/groups/:id`, `409 not_admin` means `transferTo` isn't someone the notes can go to — it isn't a user id, the person isn't a member, or their role isn't the built-in Admin role (a custom role with every permission doesn't count, and neither does being a site admin); the response doesn't tell these apart.
**Storage quotas.** Every personal space (a user's own notes) and every group has a storage plan; its limit (a plan can also have none) counts the bytes of the attachments stored in that space's notes. A note's attachments are every file uploaded to it or copied into it, whether or not its content still shows them: removing an image from a note doesn't free its space, which stays counted until the note is permanently deleted — moving it to the trash doesn't free it (see [Known limitations](./known-limitations.md)). Note text, note versions, AI edit history and sign-in service icons don't count. An attachment counts against the space of the note it's in: uploading into someone else's note that's shared with you uses the owner's space, and a copy counts against the place it's copied into. An upload, a copy, a move into a group or a group transfer that would add attachment bytes to a space is refused with `409 storage_quota_exceeded` when the space's usage plus the new bytes would go over the limit (exactly reaching it is allowed); nothing that is already stored is ever deleted because of a plan, and moving or copying a note that has no attachments, or transferring a group that has none, is never refused for space. The response carries a top-level `storage` object: `incomingBytes` (the bytes this request would add, or `null` when the request was refused before they were measured) and, only if you can see that space's usage — it's your personal space, you can manage the group, or you're a site admin — `usedBytes` and `quotaBytes`. On a group transfer the space is the receiving admin's personal space, so only that person and site admins get the numbers.
**Note versions.** Every note has a numbered history of versions, each a full copy of its content (`/api/notes/:id/versions`). A version is saved automatically only when there is something new to save — the content differs from the version it is based on, or, for a note not based on any version, it isn't empty and has changed since the server loaded the note (for changes already waiting to be saved: since the load during which the first of them was made) — and then only once the note's content has gone `autoWaitMinutes` minutes (`/api/admin/versions/settings`, 5 by default) without a new change, whoever made the changes — a person in the editor, or an AI or API write (`POST /api/notes/:id/edits`, a revert, `POST /api/notes` with `content`, and the MCP `edit_note` and `create_note` tools); changes from several people and assistants within that quiet stretch go into one version. The server checks for such notes every 30 seconds, up to 100 at a time, so the version appears within about half a minute after that. Leaving a note doesn't save a version, and neither does an AI or API write by itself. Block and inline attributes that merely restate the editor's defaults (for example a paragraph aligned left) do not count as a difference. `POST /api/notes/:id/versions` makes a manual version even when there is nothing new to save (see its row). A note saves versions automatically only while both the site-wide switch (`/api/admin/versions/settings`) and its space's switch are on — the owner's for a personal note (`PATCH /api/auth/profile`), the group's for a group note (`PATCH /api/groups/:id`); all of them are on by default. A note that is open when a switch changes keeps its previous behaviour until everyone has left it or it is moved to another space. Versions don't count towards storage plans. Copying a note doesn't copy its versions; moving a note into a group, or deleting a group by transferring its notes, clears their history and starts the numbering again from 1; permanently deleting a note (from the trash) deletes its versions. Only people who can edit a note can see or use its versions, and only from a signed-in session (a Bearer token gets `401`).
**Trash.** Deleting a note (`DELETE /api/notes/:id`, or MCP's `delete_note`) moves it to the trash. Who can see a note in the trash — and so restore it or permanently delete it — is narrower than who can see the note: for a personal note only its owner, for a group note the members whose role can both edit and delete notes (a role that can delete but not edit can move a group note to the trash, but not see it there). Site admins have no special access. To anyone else, `GET /api/trash`, `POST /api/trash/restore` and `POST /api/trash/purge`, and MCP's `list_trash` and `restore_note`, treat a note in the trash exactly like a note that doesn't exist. While a note is in the trash it is left out of `GET /api/notes`, search and wikilink suggestions; links from it don't appear among other notes' backlinks; every endpoint for the note answers as it does for a note you can't see — mostly `404 not_found`, and `404 in_trash` (with the note's id as `noteId`) from `GET /api/notes/:ref` and the two `by-path` rows to people who can restore it; the MCP tools other than `list_trash` and `restore_note` answer as for a note that doesn't exist; its public link (both forms, and their images) answers like a link that doesn't exist; and `GET /api/uploads/:id` answers `403 forbidden` for its attachments, the same as for an upload you can't see. Its attachments still count toward storage, and its address and custom public URL stay taken (see [Known limitations](./known-limitations.md)). Restoring it brings all of that back as it was, its shares and public link included — unless its group was deleted with `mode: "transfer"` while it was in the trash, which changes its owner and address and removes its public link (see the sentence on `mode: "transfer"` below). Only a note in the trash can be permanently deleted — with `POST /api/trash/purge`, or automatically once it has been there longer than the site's retention period (`/api/admin/trash/settings`). Deleting a group with `mode: "delete"` permanently deletes all of its notes, including those in its trash, without moving them to the trash; with `mode: "transfer"`, the group's notes in the trash become the receiving admin's personal notes and stay in the trash — that person's — with their original deletion time (and, like every transferred note, without a public link). A note in the trash stays in its folder until that folder is deleted, and then keeps the folder's path as it was at that moment; `folderPath` in `GET /api/trash` shows the folder's current path in the first case and the kept one in the second. Restoring a note puts it back in its folder. If the folder has been deleted, the folders of the kept path are found or created again, from the top level down, in the note's space: a folder already there with the same name (ignoring case) is used, so notes from the same deleted folder end up together again; the folder limits (see **Folders**) don't apply here; and only folders on a restored note's path come back. In the rare case that a group is transferred while a restore of one of its notes whose folder was deleted is being processed, that note — if you can still restore it — is restored at the top level of the receiving person's notes instead, and its kept path is dropped.
**Folders.** Notes can be filed in folders (`/api/folders`, `PUT /api/notes/:id/folder`). There are three kinds of space, each with its own folders: your notes (`space.kind` `"personal"`), notes shared with you (`"shared"`) and each group (`"group"`). Your folders in your notes and in notes shared with you are seen only by you; a group's folders by every member of the group (every role can read notes). Site admins have no special access. A folder you can't see answers exactly like one that doesn't exist — `404 folder_not_found`, also for a malformed id. What you can do is in the folder object's `permissions`:
- **Your notes**: everything (`manage`, `deleteNotes` and `createNote` are all `true`).
- **Notes shared with you**: your folders there hold the notes other people shared with you (as editor or viewer); you can create, rename, move and delete them and file those notes in them (`manage` and `deleteNotes` are `true`), but not create notes in them (`createNote` is `false`). Where you file a shared note is yours alone; the owner's folders and yours are separate.
- **A group**: a role that can edit notes can create, rename, move and delete the group's folders and file the group's notes in them (`manage`). Deleting a folder that still has notes outside the trash also needs a role that can delete notes (`deleteNotes`); without it the whole request is refused. Creating a note in a group folder, or copying or moving one into it, needs a role that can both create and edit notes (`createNote`).
A folder's name is trimmed and normalized to Unicode NFC, then must be 1 to `FOLDER_NAME_MAX`=80 characters counted in Unicode code points, with no line break, tab or other control character (including U+2028/U+2029 and the bidirectional-text controls), NUL or unpaired surrogate; otherwise the answer is `400 invalid_body`. Within the same parent folder, or among the top-level folders of a space, names are unique ignoring letter case (`409 folder_name_taken`) — for letters outside ASCII, as far as the database's locale treats them as upper and lower case of each other. Folders nest up to `FOLDER_DEPTH_MAX`=5 levels, a top-level folder being level 1: creating or moving a folder deeper is `409 folder_too_deep`, and moving a folder into itself or one of its own folders is `409 folder_cycle`. Each space holds at most `FOLDERS_PER_SPACE_MAX`=1000 folders (`409 folder_limit`). The depth is checked only when you create or move a folder, and the count only when you create one: restoring from the trash (see **Trash**) and transferring a group can make a tree deeper or a space hold more, and renaming in such a tree still works. Creating, renaming and moving folders, and filing notes in them, don't change any note's address, `updatedAt`, versions, sharing or who can see it.
When a group is deleted with `mode: "transfer"` and it has folders — or notes in its trash whose folder was deleted — a new top-level folder is created in the receiving person's notes and the group's folders, with the notes in them, and the group's top-level notes all go into it; notes in the trash keep their paths inside it. The new folder is named after the group: the group's name without control characters, normalized to NFC and trimmed, and cut so it fits within 80 characters with a suffix; if nothing is left, `群組` when the `Accept-Language` of the request deleting the group starts with `zh`, otherwise `Group`; if a top-level folder of that name (ignoring case) is already there, ` (2)`, ` (3)` … is added. A group with neither gets no such folder, and its notes land at the top level of that person's notes. With `mode: "delete"`, the group's folders are deleted with it.
Wherever a request names a folder, whether you can see it (`404 folder_not_found`) is checked before whether it's in the right space (`400 folder_cross_space`), and both before any `403 forbidden` or `409` about the folder; the body is checked first, and the note in the path — including a `403 forbidden` about the note — before the folder. The folder error codes:
| Code | Status | Meaning |
|---|---|---|
| `folder_not_found` | 404 | The folder id is malformed, no such folder, or you can't see it. |
| `folder_cross_space` | 400 | You can see the folder, but it's in another space than the request needs. |
| `folder_delete_blocked` | 403 | The folder has notes outside the trash that you can't delete. |
| `folder_name_taken` | 409 | A folder with that name, ignoring case, is already there. |
| `folder_too_deep` | 409 | The folder would be deeper than `FOLDER_DEPTH_MAX` levels. |
| `folder_cycle` | 409 | A folder can't be moved into itself or one of its own folders. |
| `folder_limit` | 409 | The space already has `FOLDERS_PER_SPACE_MAX` folders. |
| `folder_not_empty` | 409 | Only from `DELETE /api/folders/:id` with `onlyIfEmpty: true`: the folder has a folder in it or, in your notes or a group, notes outside the trash in it, or, in notes shared with you, notes filed in it. |
**Bulk transfer.** The MCP tool `open_notes_as_files` issues a transfer token (`knbt_…`) for downloading notes as markdown files and sending changed files back; the four `/api/transfer/*` rows above are the endpoints its `curl` commands call, and [MCP](./mcp.md) describes the download, the files and what sending one back does. These endpoints exist only on a deployment with the collaboration component. They take only such a token, as `Authorization: Bearer knbt_…` — never the session cookie, and not an API token or an upload or download transfer token. Each request checks again your account, the scope the token's credential has at that moment, and what you can see at that moment. A `PUT` is checked for `Content-Type` and `Origin` before the token is looked at.
- No `Authorization` header: `401`, message `A transfer token is required.`; it doesn't count against the per-IP limit on invalid Bearer attempts.
- An API token (`knb_…`): `401`, message `This endpoint doesn't accept API tokens. Get a transfer token from the MCP tool open_notes_as_files.`
- A token that is malformed, unknown, expired or revoked, whose credential was revoked or has expired, or whose account is disabled or has to change its password: `401`, message `The transfer token is invalid, expired or revoked. Get a new one from the MCP tool open_notes_as_files.` A scheme other than Bearer gets `401` too, with message `Not signed in.`
- An upload or download token from `create_transfer_token`: `403 forbidden`, message `This transfer token can't be used for this action.`
- `PUT` with a token whose `access` is `read`, or whose credential no longer has `notes:write`: `403 insufficient_scope`, message `This token's scope doesn't allow this action.`
- Every `401` to a request that carries an `Authorization` header counts against the per-IP limit on invalid Bearer attempts (30 per minute); once it is used up, the answer is `429` with `Retry-After` and no challenge.
`PUT /api/transfer/notes` reads the body as a file. A UTF-8 byte order mark and `\r\n` line endings are accepted. The file has front matter when it starts with a `---` line and, within its first 200 lines, has another line that is exactly `---` with a line starting `knotebook`, `id` or `base` followed by `:` (spaces or tabs before the `:` are allowed) between the two; a file that starts with a `---` line and has such a line within its first 200 lines, but no closing `---` line among them, is refused with `400 invalid_body`; otherwise the whole file is the new note's text. In the front matter only `id`, `base`, `title` and `folder_id` are read, each named up to its line's first `:` without the spaces or tabs before it, and other lines are ignored. A value that starts with `"` is read as a JSON string, one in single quotes as a YAML single-quoted string, and anything else as it stands, without the spaces around it. A file with an `id` must also have a `base` (16 lowercase hex characters); front matter with a `base` but no `id` is refused with `400 invalid_body`; a file without either creates a new note. With no `title` line, the title doesn't change (a new note is called `Untitled`). The reply to a file written back is `200 {id, created: false, result, applied, same, editIds, renamed, renameFailed?, unboundWikilinks, base: null}`; to a new note, `201 {id, created: true, result: "created", base: null, unboundWikilinks}`; to an identical new-note file sent again within 30 minutes, `200 {id, created: false, result: "unchanged", base: null}` (see [MCP](./mcp.md) for what each field means).
Every `429` from these four endpoints carries `Retry-After`, in seconds, at least 1. The budgets: a download counts against 10 per user per hour by default, and only one download runs on the whole site at a time; fetching one note counts against the 120-reads-per-minute content-read budget; a file whose text or title differs from the copy it was downloaded as, or that creates a note, counts against 30 per user per minute by default (`TRANSFER_WRITE_LIMIT`), which is separate from every other write budget; and a file that changes nothing counts against the 300-reads-per-minute token budget. A file whose text differs counts against the 30 before the server parses that text, so it counts even when it is then refused with a `400` for what the text contains; one that turns out to change nothing (it only adds blank lines, for example) is given back and counts against the token budget instead. So once the 30 are used up, every file whose text or title differs, or that creates a note, answers `429` — even one that would change nothing — unless a check made before the budget refuses it first (for example `409 base_unavailable` with `note_changed`, `409 not_mergeable`, `403` or `404`); a file the same as its downloaded copy still answers `unchanged`, and a new-note file identical to one that created a note in the last 30 minutes still returns that note.
| Code | Status | Meaning |
|---|---|---|
| `invalid_body` | 400 | `PUT`: the body isn't UTF-8 or contains a NUL character; the file starts with front matter (a `---` line, then a `knotebook:`, `id:` or `base:` line within the first 200 lines) that has no closing `---` line within those lines; an `id`, `base`, `title` or `folder_id` line appears twice in the front matter, or its value is malformed or contains a NUL or an unpaired surrogate; there is an `id` but no `base`, or a `base` but no `id`; `title` is empty; or a line the file added or changed opens a code block (```` ``` ```` or `~~~`) that is never closed before the end of the file. Nothing was written. |
| `unsupported_block`, `unsupported_color`, `unsupported_columns`, `unsupported_size`, `empty_content`, `too_many_blocks` | 400 | `PUT`: what the file changed can't be parsed or stored — the same codes, for the same reasons, as `POST /api/notes/:id/edits` — or, in a file sent back to a note, the text is empty, only HTML comments, or has an HTML comment that isn't closed and leaves a changed part with nothing in it. Nothing was written. |
| `unauthorized` | 401 | See the list above. |
| `forbidden` | 403 | An upload or download token; the token's group or folder can no longer be seen; the note is outside the token's range; `PUT`: your role on the note can't edit it, or a new note's place isn't allowed — a token for only notes shared with you, one of your folders for notes shared with you, a folder outside the token's range, or a group where your role can't create notes or, for one of its folders, can't edit them; an `Origin` header that doesn't match the host the request was sent to. |
| `insufficient_scope` | 403 | `PUT` with a token whose `access` is `read`, or whose credential no longer has `notes:write`. |
| `not_found` | 404 | The note can't be seen, doesn't exist or is in the trash — also when a file that only renames a note arrives as the note is moved to the trash. |
| `folder_not_found` | 404 | `PUT` of a new note: its `folder_id` is a folder you can't see or one that doesn't exist, also when it is deleted while the note is being created. |
| `base_unavailable` | 409 | `PUT`: this copy can't be written back. The body's `error` also has `reason`: `note_changed` (the file changes the note's text or title, and the note has changed since the download — its title, or any of its blocks; nothing was written, not even the title), `already_sent` (the copy's content was already sent back with other changes), `other_note` (its `base` belongs to another note or another person), `expired` (the server no longer has that copy — each person keeps the 10 most recent copies of a note, and a copy unused for 30 days is removed — or `APP_SECRET` has changed since the download) or `reverted` (what this copy wrote was reverted in the AI edit history; the copy is then no longer accepted at all, not even to change only the `title`). The note is unchanged; download it again. |
| `not_mergeable` | 409 | `PUT`: the note can't be written back as a file (the manifest marks it `mergeable: false`) and the file changes its content; change it with MCP's `edit_note`. |
| `content_too_large` | 413 | `PUT`: the body is over 262 144 bytes. |
| `note_too_large` | 413 | `PUT`: writing the file back would make the note larger than `NOTE_MAX_BYTES` and larger than it was, while adding content (or growing it by more than 4 KiB without adding content), or a new note from the file would be larger than it (see [Self-hosting](./self-hosting.md#note-size)); the error carries `limitBytes`; nothing was written or created. |
| `too_many_notes` | 413 | Download: the token's range has more than 3 000 notes. |
| `unsupported_media_type` | 415 | `PUT` with a `Content-Type` other than `text/markdown`. |
| `too_many_requests` | 429 | A budget above is used up, another download is running, or invalid Bearer attempts from your address are used up; always with `Retry-After`. |
| `server_busy` | 503 | `PUT`: the note's write queue stayed busy; or the file — reading it, or putting it into the note — waited longer than `PARSE_QUEUE_WAIT_MS` for a parse worker or 100 writes were already waiting (with `Retry-After`; see [Self-hosting](./self-hosting.md#parse-workers)); or the note changed while the file was being written, each of three tries (the server compares the note with the copy downloaded, and puts the file in only when nobody has changed the note since that comparison) — nothing was written; or a lock or serialization conflict while a new note was being created — nothing was created. The single-note fetch also answers it, with `Retry-After`, when turning the note into Markdown waited longer than `PARSE_QUEUE_WAIT_MS`. |
| `too_complex` | 500 | `PUT`: putting the file into the note took longer than three times `PARSE_TIMEOUT_MS`, or more memory than a parse worker has — the note itself is too large or too complex; nothing was written. The single-note fetch: turning the note into Markdown took longer than three times `PARSE_TIMEOUT_MS`, or more memory than a parse worker has. Retrying won't help. In a download, such a note is left out and listed in the manifest's `skipped` with `reason: "failed"` (a note that waited longer than `PARSE_QUEUE_WAIT_MS` for a parse worker is listed with `reason: "busy"` instead: fetch it again later). |
| `internal` | 500 | Something unexpected failed. If it happens after a file's text was written into the note, what sending the same file again answers depends on the step that failed: if recording the change failed, the note is changed but the change has no row in its AI edit history, and the file answers `409 note_changed`; if the text was written and recorded and changing the title failed, it answers `409 already_sent`. Either way the text isn't written a second time. |