# 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 (`/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=`), 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=` 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=`, 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=` 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=`; failures after the sign-in cookie has been opened land on `/settings/login?link_error=` (`identity_taken`, `identity_already_linked`, `oidc_link_session_mismatch`, `account_disabled`, …), earlier ones on `/login?error=`. 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 `