Skip to main content
This guide lists the supported routes and fully describes the contracts with non-obvious semantics. Suchi does not currently publish a complete generated client schema; integrations should pin the Suchi release they target and use the stable JSON fields documented here. Most /api/ responses are JSON; successful mutations may return an empty body, and previews, downloads, thumbnails and avatars return bytes. Browser routes serve HTML or redirect. Every request gets an X-Request-Id header — carry it into bug reports. See Errors for the JSON error contract.

Endpoint index (compact)

Auth

The same authentication chain accepts browser sessions and API credentials:
  • Cookie — set by local-password JSON login, the POST /login form, or the OIDC callback. OIDC configuration disables local bootstrap/password login and redirects anonymous browser entry points to the provider. Cookies are HttpOnly + SameSite=Lax; Secure when PUBLIC_URL uses HTTPS.
  • API token — Authorization: Token <hex> is preferred; Authorization: Bearer <hex> is also accepted. Suchi tokens are exactly 64 lowercase hex characters. Other Bearer values go to the OIDC authenticator.
OIDC ID tokens must contain email and the boolean email_verified: true. This applies to direct Bearer authentication and browser sign-in; see provider requirements. Invalid credentials do not fall back to an existing browser session. API tokens are limited by an explicit route allowlist in distro/app/serve_token_policy.go. Document reads (including search, chat, previews, and shared vocabulary) require documents:read; document mutations require documents:write; the activity feed requires events:read. Write scope does not imply read scope, and scopes never bypass ownership, role, capability, or document-visibility checks. Account/settings administration, API-token management, credential vaults, taxonomy administration, profile management, raw-original downloads (raw=1), and profiling require a browser/OIDC session. Unlisted routes fail closed for API tokens with 403 token_route_forbidden; a listed route with a missing scope returns 403 insufficient_scope. /metrics retains admin-token support. New HTTP routes must explicitly declare their token access in the public assembly policy. Compiled routes added through app.Options.Configure cannot extend that policy and remain session-only.

Filing-system request context

After first prefixed import, collection requests select ?system=S01. Without it, session collections use original system 1, never an arbitrary accessible system or server-wide “last selected” state. Tokens use their bound system as default and ceiling. Unqualified numeric item requests derive the actual object’s system; an explicit mismatched system is not ignored, even for an administrator. All document/object access also needs an active account, system entry and existing object permission. Unknown, inaccessible or mismatched systems return 404 system_unavailable; unavailable objects return 404 not_found, without foreign labels. Ordinary authentication/role failures remain 401/403. Metadata, query name/ code resolution, saved views, mailboxes, passwords, automations, workflows, jobs, counts, events and research are scoped before hydration/pagination. Identity/groups, global capabilities and server configuration remain instance-wide. GET /api/jd/systems returns {introduced,default_system_code,results:[{code,name,is_default}]}. Before introduction the list may be empty. Afterwards it contains only enterable systems (a token only its binding), sorted by code; the default code is empty if the original system is unavailable. Both this route and address resolution accept documents:read tokens. GET /api/jd/resolve?address=S01.21 returns {system_code,jd_category_id,jd_address} for an enterable existing category in an introduced filing system. The original archive has no addresses before introduction, so numeric forms such as 20.21 are 400 bad_address; use the jd_category_id filter or jd:21 instead. Resolution requires membership, the token’s system ceiling, and any explicit ?system= to agree. Invalid syntax, including former SYS.AC.documentID values, is 400 bad_address; missing or inaccessible systems/categories are 404 without foreign labels. Resolution grants no document access; the subsequent Documents query applies visibility in SQL. Document, search, reference, upload/version-receipt, MCP-relayed search, and native-export projections expose system_code plus category-level jd_address. Introduced systems use SYS.AC; before introduction the field is omitted and clients show jd_category_code. The field can repeat across documents and versions. Numeric id, document URLs, and titled document links remain exact identity. Category system_code is distinct from its protected system boolean. API version 1 is unchanged. Session admins manage direct membership with GET/PUT /api/admin/jd/systems/{code}/members, representation {"user_ids":[...]}. PUT replaces the explicit set: preserve existing inactive members unless removing them deliberately; duplicate/unknown IDs and new grants to inactive users fail. Admins enter implicitly; their access is not an editable membership grant. PATCH /api/admin/jd/systems/{code} accepts {"name":"..."}, nonblank and at most 80 Unicode characters. Codes cannot change; there is no enable/disable/delete endpoint. Removing membership atomically revokes that member’s system shares, tokens and pending mobile pairings. Re-adding membership does not revive those credentials. Existing ACL rows remain ineffective without membership; re-admission can make retained ACLs useful again. New user ACL grants require the recipient to be active and enter the object’s system (member or admin), checked in the caller’s write transaction; granting a document to a nonmember fails rather than granting entry. Writes and receipt replay recheck current authority in the writer transaction; already delivered bytes cannot be retracted. See Permissions for the shared administrator/host/backup trust boundary. Token creation and mobile pairing capture the selected system. Password exchange at /api/token/?system=S01 accepts a target query code; omission uses original system 1. Pairing exchange consumes the code and issues its bound token in one transaction. Changing a device’s system needs a new pairing/token; scope names do not broaden it.

Endpoints (selected — see index above for the full list)

The sections below group routes by task. Auth and filing-system request context apply throughout.

Health and client compatibility

GET /healthz

Process-alive. Cheap. Never touches the DB. Suitable for k8s liveness probes. 200 {"status":"ok"}

GET /readyz

DB-reachable + schema at the expected user_version. Suitable for load-balancer readiness gating. 200 {"status":"ok","user_version":<int>} 503 {"status":"db_unreachable"|"schema_stale", ...}

GET /metrics

Prometheus exposition. Admin auth required: anonymous requests return 401 and authenticated non-admin requests return 403. Per-route request counts and job-queue depth are internals a public scraper should not see. Send Authorization: Token <admin-token> on scrapes. When SUCHI_PPROF=1, /debug/pprof/* requires an admin browser/OIDC session (API tokens are not accepted). Health probes (/healthz, /readyz) stay public. Metrics include:
  • suchi_http_requests_total{method,route,status}
  • suchi_http_request_duration_seconds{method,route} (histogram)
  • suchi_jobs_pending, suchi_jobs_running (gauges)
  • suchi_jobs_retries_total{kind}, suchi_jobs_dead_total{kind}
  • Standard Go runtime + process collectors.

GET /api/handshake

Public compatibility probe for first-party clients. Call it before sending credentials. The response contains no deployment or user state: 200 {"product":"suchi","api_version":1,"mobile_contracts":["suchi-companion-v1"]} Clients must require product to equal suchi. mobile_contracts advertises wire-behavior profiles independently of server and app release numbers. The suchi-companion-v1 profile means the mobile-v1 endpoint and fixture behavior remains available. Servers may advertise additional profiles alongside it; they must omit v1 if they deliberately drop that behavior. A client that understands v1 rejects an explicit list without v1. When a server makes no declaration, the client may proceed to authenticated scope and system checks instead of inferring incompatibility from release numbers. Redirects are not compatibility responses and must not receive credentials. Pairing clients use the current exchange shape, including device_name. api_version remains available to general API consumers. It does not determine Companion compatibility; mobile clients use the declared wire profile instead of requiring an exact API or app release number. Managing the mobile wire contract:
  • Treat a profile as a promise about the endpoints, payloads, authentication requirements, and error behavior used by that mobile client—not a Suchi release number or a token lifetime. Keep advertising suchi-companion-v1 while its mobile-v1 requests and responses remain usable. Compatible server releases do not need a new profile or a mobile release.
  • The v1 fixture set includes the generic mixed approval-task response and a pending-date page used by Companion. The server does not add mobile-only approval filters or counts: Companion selects supported document-change title, category and tag rows plus pending dates locally, and ignores every other workflow row.
  • When a change would break that promise (for example, removing a required endpoint/field or changing a response or scope requirement), define a new profile and its canonical fixtures under core/api/testdata/mobile/, document the changed wire behavior here, and advertise both profiles only if the server actually implements both. Update core/api/handshake.go, the exact response assertion in handshake_test.go, and the fixture registry in mobile_contract_fixtures_test.go; check them with go test ./core/api -run 'TestGetHandshake|TestMobileContractFixturesMatchWireTypes'.
  • Release a Companion that understands the new profile before withdrawing v1. Remove v1 from mobile_contracts only when its behavior is intentionally unavailable and rejecting existing v1-only clients is acceptable. A present empty or nonmatching list is an explicit refusal; omitting the field is not a way to retire a profile because clients treat omission as no declaration. Coordinate the mobile fixture mirror and exact server-commit pin with that change. Neither adding nor withdrawing a profile rotates existing API tokens.

Authentication and accounts

POST /api/login

Local-password browser login. Send a JSON body; success plants a session cookie and returns 204:
two_factor_code is optional for accounts without two-factor authentication. For an enabled account it accepts either the current six-digit authenticator code or one unused recovery code. A correct password with an omitted factor returns 401 two_factor_required; every wrong password, factor, replayed authenticator step, and used recovery code returns 401 invalid_credentials. After five consecutive wrong codes the factor is blocked for 30 seconds, doubling with each further failure up to one hour; blocked requests return 429 too_many_attempts with Retry-After. After 100 consecutive failures the factor returns 403 two_factor_locked until an administrator or the offline command resets it. Any accepted code clears the count. API clients exchange the same JSON fields at POST /api/token/, which returns {"token":"<hex>"} without creating a browser session or setting a cookie. The separate POST /login browser form accepts URL-encoded email, password, and optional two_factor_code, then redirects after setting the same kind of session cookie. Failures redirect to /login?error=CODE with one of missing, invalid_credentials, two_factor_required, two_factor_invalid, too_many_attempts, or two_factor_locked; the page shows the code field only for the two factor codes. Authenticator-step advancement or recovery-code consumption and session/token issuance use one transaction. That transaction also confirms a password-only exchange still has no enrolled factor, so two-factor enablement that commits while a credential is being prepared rejects the exchange without creating the credential: the JSON routes return 401 two_factor_required and the /login form redirects to /login?error=two_factor_required. Enablement also deletes the user’s pending mobile pairing code, which can no longer be exchanged. POST /api/tokens/ and POST /api/mobile/pairing recheck the calling browser session inside their writer transaction, so a request from a session that enablement (or logout) revoked returns 401 unauthorized without creating a token or pairing. An issuance failure does not consume the factor; a successful recovery-code exchange cannot be replayed. When the bounded Argon2 worker is already at capacity, local-password login and token exchange fail quickly with 503, Retry-After: 1, and the same response for known and unknown accounts. Password hashing for account and protected-share creation uses the same bound and returns a retryable 503 when unavailable. Credential routes and demo session creation share their respective rate-limit buckets across plain, trailing-slash and percent-encoded slash paths.

GET /oidc/login / GET /oidc/callback / POST /oidc/email-change

OIDC routes exist only when OIDC is configured. Login and explicit email synchronization use a ten-minute, versioned, HMAC-authenticated transaction cookie with a fixed purpose, state, nonce, PKCE S256 verifier and expiry. The cookie is HttpOnly, SameSite=Lax, Secure for HTTPS deployments, process-local, and expired before every callback outcome is handled. Every response is Cache-Control: no-store. A browser callback resolves a returning account only by the exact verified issuer/subject pair. If that pair is unknown, browser login may provision a new account only when the verified email is unused; it never adopts an existing email-matched row. Direct OIDC Bearer authentication is resolution-only and cannot provision, bind, or change an account. ADMIN_EMAIL grants the first matching browser-provisioned user the administrator role only when no administrator exists. POST /oidc/email-change requires a live browser session, strict same-origin provenance, OIDC account mode, and no Authorization header. It forces provider reauthentication with prompt=login and binds the transaction to that exact user and session. The callback then:
  • binds an eligible unbound row only when the fresh verified email still equals its canonical email;
  • updates a bound row’s email only when issuer and subject still match; or
  • makes no identity write when the provider email is unchanged.
A binding or email change preserves users.id, rotates every browser session, plants one replacement cookie, preserves API/mobile tokens, and requires a retained audit row in the same transaction. An email change also enqueues rerenders for folder-layout templates that use the exact owner variable. The numeric watched-folder owner remains attached to the same user without a watcher reload. Invalid or reserved email, a collision, a stale session, a disabled mode, a development-seeded account, or an audit failure leaves identity and sessions unchanged. Success redirects only to the fixed Settings notices identity_bound, email_changed, or email_checked.

POST /api/logout

Revokes the current browser session and, when the request authenticated with an API token, that token. The SPA calls this endpoint before returning to the login screen. 204 No Content on success. Logging out is idempotent.

GET /api/whoami

Returns the authenticated Principal + profile fields. The SPA reads display_name for the topbar avatar menu and avatar_url for the image (initials fallback until an avatar is set). build_version identifies the running server; build_revision is its short source revision when known, with .dirty for locally modified source. These read-only fields are not user profile settings.
display_name + avatar_url are omitted when unset. instance_host comes from the configured PUBLIC_URL and lets the SPA preview the identity shown on public share pages. 401 if anonymous. Row-backed responses reread canonical email, role, display name, and avatar state from users; stateless demo principals retain their constrained fallback identity. email_change_mode is the authority for account-email controls:
  • password — a row-backed local account may use POST /api/users/me/email;
  • oidc — the configured provider owns the email and the account may use POST /oidc/email-change; and
  • disabled — tokens, demo identities, and the fixed development account have no email mutation path.
Do not infer email authority from authn_by; it remains authentication/audit metadata. scopes copies the authenticated API token’s granted scopes. Cookie/OIDC sessions return []; clients must not infer token grants from user role or capabilities. Token responses also include the credential’s filing-system system_id, system_name, and system_code. These fields are omitted for cookie/OIDC sessions. system_code remains present but may be empty before the original archive is assigned a permanent code.

POST /api/mobile/pairing

Create a five-minute mobile pairing from a browser/OIDC session. API tokens and demo identities cannot create or cancel pairings. An optional JSON {"name":"My phone"} supplies a fallback token label; omission or blank uses Suchi mobile, and names are limited to 64 characters. Each new pairing replaces that user’s previous unused code. 201 {"pairing_url":"suchi://pair?…","qr_data_url":"data:image/png;base64,…","expires_at":1793495100,"name":"My phone","code":"<64 lowercase hex chars>"} The link contract is suchi://pair?v=1&server=<URL-encoded origin>&code=<code>. Its server is the configured PUBLIC_URL origin, never a request or forwarded host. The server can encode HTTPS, or HTTP only for localhost, loopback literal IPs, RFC1918 IPv4, and unique-local IPv6. Companion accepts those local HTTP origins only in a debug build; profile and release builds reject every HTTP origin before exchange. Pairing creation returns 503 pairing_unavailable for other HTTP hosts (including .local/private DNS names and link-local IPs), invalid origins, or ports outside 1–65535. Configure HTTPS or a supported debug literal address; a refused origin does not replace an existing pending code. The 320-pixel PNG is generated locally, without a QR service. The code has 256 random bits; storage contains only its SHA-256 digest, owner, system, name, and expiry. Treat the code, link, and QR as temporary credentials: anyone holding one can pair until it expires, is replaced, is cancelled, or is used. Pairing endpoints use Cache-Control: no-store, create no browser session, and never log the code, link, QR, or token. Creation and exchange share the login rate limit, including trailing-slash and percent-encoded slash aliases.

DELETE /api/mobile/pairing

Session-only cancellation accepts JSON {"code":"<code>"} and returns 204 even if that user’s matching code is already gone. It deletes only the supplied code, so closing an old dialog cannot cancel a newer pairing. Invalid code syntax returns 400 pairing_invalid.

POST /api/mobile/pairing/exchange

Public exchange accepts JSON {"code":"<64 lowercase hex chars>","device_name":"Ritesh’s iPhone"}. device_name is optional: a nonblank name overrides the browser’s label, while omission or blank retains it. The server trims surrounding whitespace and rejects names over 64 Unicode code points or containing control characters with 400 bad_name before consuming the code. Correcting the name can therefore reuse the pending code. The name is stored on the paired API token and returned in both the exchange response and connected-app list. The app must first validate the scanned link against the pairing origin rules, confirm the server with the user, and run the compatibility handshake without credentials or redirects. Production origins require HTTPS; supported HTTP origins are for explicit local-development testing. 200 {"token":"<API token>","name":"My phone","scopes":"documents:read,documents:write"} Scopes are a comma-separated string, matching the existing token-management response. Code consumption, active-user/system-entry checks and bound token issuance share one writer transaction; concurrent scans cannot issue two tokens. Invalid, expired, replaced, used, disabled-account and no-longer-enterable-system codes return 400 pairing_invalid. An unavailable issuer is checked before consumption and failed issuance rolls back consumption, so an unexpired code can be retried. If successful issuance commits but the response is lost, create a new pairing: consumed codes are not replayed. Successful issuance stores source: "mobile_pairing" in that transaction; generating or failing to exchange a code does not register an app.

GET /api/tokens/

Session-only GET /api/tokens/?system=S01 returns active credentials in the selected system with id, user_id, name, scopes, created_at, optional last_used_at, optional source, and system_code after introduction. Paired credentials have source: "mobile_pairing"; manual and older credentials omit it. Members see their own credentials; administrators see all accounts only within that system. Omission uses the original system. The list uses Cache-Control: no-store and contains no token or token hash. DELETE /api/tokens/{id} revokes a device; logout-revoked tokens are also excluded. last_used_at is coarse activity metadata, not a real-time audit record. Successful token authentication advances it at most once per hour per token; a timestamp write failure is best-effort and never turns valid authentication into a failed request.

PATCH /api/users/me

Update only the caller’s display name:
display_name is trimmed and must contain 1–120 characters. The strict decoder rejects email and every other unknown field with 400 bad_json; an empty body returns 400 no_fields. Successful writes emit user.display_name_changed and return the fresh UserSelf shape.

POST /api/users/me/email

Change a local-password account’s sign-in email:
two_factor_code is needed only when the account has two-factor authentication. After the password matches and the address is free, a missing code returns 401 two_factor_required, and the factor errors above apply. The accepted code is spent even if the change later fails. This route requires email_change_mode: "password", a live browser session, strict same-origin provenance, and no Authorization header. It is unavailable to API tokens, OIDC accounts, demo identities, and development-seeded accounts. The target passes the shared trim/lowercase, 254-byte, syntax and reserved-email validation. The current password is verified outside the writer through the bounded Argon2 worker; saturation returns 503 password_hash_unavailable with Retry-After: 1. The writer rechecks the canonical email, password hash, mode, development marker, enabled account and initiating session. It then preserves users.id, changes the email, queues rerenders for folder layouts that use the exact owner template variable, rotates every browser session, and inserts retained user.email_changed as one transaction. Failure rolls back all of those effects. 204 No Content plants the replacement cookie; every old browser cookie is invalid, while API/mobile tokens, document ownership, memberships, permissions, watched-folder ownership and other numeric user references remain unchanged. No watcher reload is needed. Expected failures include:

Local two-factor account routes

These routes exist only when local auth owns sign-in. They require a live browser session, reject Authorization credentials and demo identities, return Cache-Control: no-store, and require exact same-origin provenance for every POST. OIDC assemblies do not register them. GET /api/account/two-factor returns:
Start setup by reauthenticating:
The response carries a Base32 secret, an otpauth:// provisioning_uri, an opaque enrollment_token, and expires_at. The token is AEAD-sealed for ten minutes and bound to the current user and browser session. The seed is not stored yet. Do not log or persist any of these values. Enable the factor with a code generated from that exact seed:
Success returns {"recovery_codes":["…"],"active_api_tokens":2} with ten one-time codes and the number of the user’s unrevoked API and mobile tokens, which keep working. This is the only response that reveals the codes. The seed, recovery-code digests, current session replacement, revocation of other browser sessions, deletion of any pending mobile pairing code, and retained user.two_factor_enabled audit event commit together. API/mobile tokens remain valid. An enabled account can replace every unused recovery code:
Success returns a fresh ten-code recovery_codes array and rotates browser sessions. Disable uses the same body at POST /api/account/two-factor/disable, returns 204, deletes the seed and recovery codes, and rotates browser sessions. Both changes preserve API/mobile tokens and require their retained audit event in the transaction. Expected lifecycle failures include 401 session_required, two_factor_required, invalid_credentials, or invalid_enrollment; 409 two_factor_enabled or two_factor_disabled; 403 two_factor_locked; and 429 too_many_attempts.

POST /api/users/me/avatar

Multipart upload with field name avatar. Server-side hardening:
  • Content-Type sniffed on the first 512 bytes; only image/png and image/jpeg accepted (a .png filename over plain text is refused).
  • Size cap 2 MiB; dimensions cap 2048×2048 (checked via image.DecodeConfig before allocating the decoded frame).
  • The image is decoded and re-encoded as PNG. EXIF and any post-image polyglot bytes never survive; the on-disk representation is normalized. The original upload bytes are not stored.
  • Blob lands in the CAS; users.avatar_sha gets the fresh SHA-256.
Returns the fresh UserSelf on success. Emits user.avatar_changed audit event with old + new sha.

GET /api/users/{id}/avatar

Serves any user’s avatar as image/png with a strong ETag from users.avatar_sha and Cache-Control: private, no-cache; clients must revalidate cached pixels. Profile responses append ?v=<avatar_sha> to avatar_url so changed pixels also refresh image elements and bypass older cached URLs. If-None-Match returns 304 for the current avatar or the new bytes after an upload changes it. Returns 404 if unset.

PATCH /api/admin/users/{id}

Administrator session only. Sparse updates accept display_name, role (admin or member), disabled, and capabilities; success returns the updated user. Admin creation (POST /api/admin/users) and updates normalize stored capabilities to [] when the resulting role is admin; supplied slugs are still validated. Demotion to member starts with no capabilities unless the request explicitly supplies them, so legacy hidden grants cannot reappear. When OIDC is configured, POST /api/admin/users returns 409 oidc_managed; browser sign-in provisions unused verified identities instead. Revocation hooks compare effective access: promotion preserves resources the administrator can still use, while demotion revokes access not explicitly retained.
  • Disabling the caller’s own account returns 403 cannot_disable_self, even when another active administrator exists.
  • A role or active-status update that would leave no active administrator returns 409 last_active_admin. Disabled administrators do not count.
The active-administrator check and role/capability normalization use the current row inside the write transaction. Local mailbox, share-link and shared-view revocations use the same transaction. Capability audit records are also required there: successfully stored records follow committed transition order, while an audit insertion failure rejects and rolls back the user change and all dependent revocations. Rejected patches and cascade failures likewise leave the user and resources unchanged and return 500 for an internal write failure. Later promotions or regrants cannot be overtaken by older revocations. OAuth flows are invalidated only after a disable commits; rejected disables preserve them. Resource logs, external audit delivery and runtime reloads happen after commit. GET /api/admin/users includes two_factor_enabled for each row. A live local administrator session may reset another enabled user:
The factor is optional only when the acting administrator has not enabled one. The route refuses self-reset, rechecks the actor’s administrator role and live session in the writer, consumes the actor’s recovery code or authenticator step when applicable, and atomically removes the target factor, target recovery codes, and target browser sessions. It preserves the target’s API/mobile tokens and writes retained admin.user_two_factor_reset. Success returns 204. Expected failures include 401 session_required, two_factor_required, or invalid_credentials; 403 forbidden; 404 not_found; and 409 cannot_reset_self or two_factor_disabled.

Public-demo surface: /api/demo/*

Session creation and upgrade require SUCHI_DEMO_MODE=1 (see demo-instance); otherwise they return 404 {"code":"demo_disabled"}. The public mode probe returns 200 {"enabled":false} on a normal deployment.
  • mode is unauthenticated — the SPA calls it on boot to decide whether to render the demo banner. Cache-safe.
  • session is unauthenticated — mints a signed anonymous token scoped read-only. No DB write; the token verifies statelessly via HMAC-SHA256 against a per-instance secret at $DATA_DIR/.demo-anon-key. TTL defaults to 15 minutes. Browsers use the issued HttpOnly cookie; non-browser read clients can send the token in X-Suchi-Demo-Token.
  • session/upgrade requires a valid anonymous token. Provisions a scratch user visitor-<hex>@demo.local and replaces the anonymous cookie with a digest-backed browser session. Its lifetime is the configured scratch TTL, not the normal 30-day login lifetime. It retains the constrained demo-scratch identity for both API calls and direct preview/download URLs. Upgrade clients must retain response cookies; no API token is returned.
Any mutation from an anonymous demo caller returns 403 {"code":"demo_upgrade_required"} except the upgrade endpoint itself. Clients that follow the redirect-to-upgrade convention (auto-retry on that code) get transparent write access without shipping login UI.

Document capture and lifecycle

POST /api/documents/

Multipart upload. Streams the file into the CAS by SHA-256, then commits the document row and first durable job together. An unreferenced CAS object left by a failed database commit is harmless and is reclaimed by suchi gc. MIME is sniffed server-side; the multipart Content-Type header is a hint only. An unreadable or non-rewindable upload is rejected with 500 upload_read_failed before any CAS write. The target system is captured before upload or deduplication. Equal bytes uploaded by one owner to different systems create distinct document rows but may share a CAS hash. Split and email-attachment children inherit their parent’s system; version uploads have their own inheritance rules. Pre-upgrade stored receipts may omit address fields; numeric lookup returns the current address. Multipart fields: Device OCR is accepted only when the sniffed upload is a PDF. Fresh rows always record submitted confidence, language, and receipt time. At or above DEVICE_OCR_MIN_CONFIDENCE, text is provisionally indexed with content_source=device_ocr; below it, submitted text is discarded while its non-content provenance remains. Native PDF text is authoritative and changes the active source to server. For image-only PDFs with accepted nonblank device text, post-ingest skips duplicate server OCR. Empty or skipped server output never erases accepted device text. Dedupe and restore only record the ordinary acquisition source and do not attach newly submitted OCR provenance to the existing row. An optional Idempotency-Key must be one canonical lowercase UUID v4. Invalid keys return 400 bad_idempotency_key. Keys are scoped to the authenticated user and shared across document and version uploads. Suchi fingerprints the operation, predecessor, uploaded SHA-256, sanitized filename basename, source_mtime presence/value, device source, a SHA-256 of device text, normalized numeric confidence and OCR language using length-prefixed fields, then qualifies the digest with the numeric target system. Upgrade prefixes existing fingerprints with 1: without reconstructing their old inputs, preserving same-system response-loss retries. Matching retries replay the original 200 or 201, preserve the document Location, and add "idempotent_replay":true without adding a source or job. Reusing a key with different normalized input returns 409 idempotency_conflict, including reuse across systems. Scope, current membership/token authority and object authorization run on every request, including a replay. Full response records are retained for 30 days and pruned by later keyed uploads. After that window, ordinary content deduplication still prevents a document duplicate; version retries can recover their result from the version row. 201 Created { "id": ..., "sha256": "...", "size": ..., "mime_type": ..., "title": "..." } Dedup state machine (always within the captured system and document owner):
  • Hash matches an existing live doc → records the upload as another source and returns 200 OK. The document row and CAS blob are reused; extraction and classification do not run again:
    Repeating the same uploader and filename does not add another source row. A different uploader, client app, filename, mailbox, watched path, or import source does.
  • Hash matches a trashed document still inside its 30-day recovery window → clears trashed_at, returns 200 OK with "restored": true. An expired match is not restored; the upload creates a fresh row while retention cleanup removes the expired one.

GET /api/documents/

Paginated ACL-scoped document list. Every SPA list view + mobile client walks the archive through this. Scope: documents:read. Query params (all optional): Scope filters are additive. Malformed or oversized ID lists return a field-specific 400; combining correspondent IDs with correspondents__isnull returns 400 bad_correspondents; an invalid language token returns 400 bad_lang; any share_link value other than active returns 400 bad_share_link. Live discovery returns only the newest live version visible to the caller in each version family by default. Use version:all or version:older inside q to include every visible revision or only superseded revisions. version:latest is the explicit form of the default. Exact document_ids, direct detail reads, and Trash recovery remain exact-row operations. Members see owned documents and explicit grants (direct or via group). Administrators bypass document ACLs within the selected system; the filing-system request context still applies. Row shape (slimmer than GET /{id}; no content field — request detail for that):
added_at is when Suchi ingested the document and created_at its upstream date, such as an email’s Date: header; a document stored before added_at was recorded reports its created_at for both. thumb_sha non-empty means the row has a thumbnail at /api/documents/{id}/thumb (client can render immediately without a HEAD probe). split_origin_id is the immutable id of the upload that produced a split child; split_index is its one-based part number. Unlike split_parent_id, the origin survives later hard deletion of the retired parent. ASN split children also carry archive_serial_number. Poll the accepted upload until its active job is gone, then request ?split_origin_id=<accepted-id>. A trashed accepted row normally denotes a split parent; a 404 means it was later hard-deleted. A completed-origin retry returns the same child set instead of restoring or duplicating the parent. A trashed child makes the result a conflict. Report a multi-document success only after every returned live child has completed post-ingest.

GET /api/documents/{id}

Full projection of one document: title and title ownership, extracted content (post-OCR), MIME, blobs, JD category, tags (slug list), correspondents, attributed notes, typed custom-field values, selected rendered layout, and an optional archive serial number. Trashed docs are visible with trashed_at set so mobile clients can render the undelete flow. owner_id identifies the document owner; trashed documents also include deletes_at, exactly 30 days after trashed_at. Live and restored documents omit both deletion timestamps. encryption_state is encrypted when a password is still needed, or decrypted after a successful manual or automatic password unlock. It is omitted when no password unlock is recorded. The original blob is preserved. processing is true while the document’s reading or classification job is pending or running, the same rule /api/stats/ counts and is:processing matches. It is omitted once processing finishes. Authenticated owners and administrators can preview and download trashed documents until permanent deletion. Other ACL readers cannot fetch trashed bytes, and public share links never serve trashed documents. The normal sensitivity-reveal and raw-original download restrictions still apply. 200 OK
sources lists the distinct acquisition places that observed the document, oldest first. It is separate from version history and document relationships: a version supersedes, a link coexists, and a source only explains how the bytes entered the archive. A mailbox source’s label follows the configured mailbox’s current display name. Suchi retains the name captured at ingest and uses it if that mailbox is later deleted; detail remains the observed address and folder. Optional filename is the file’s own name where detail does not carry it, such as an email attachment observed through a mailbox. Optional client is companion_app on an api source uploaded with a token issued by Suchi Companion pairing; label remains the uploading person. added_at is when Suchi ingested the document; created_at and optional source_mtime carry upstream dates. title_owner is auto, rule, or human. New uploads start as auto. PATCH {"title":"..."} and an accepted title suggestion claim human ownership, so later model suggestions and title rules cannot overwrite the name. PATCH {"title_owner":"auto"} keeps the current text but lets Suchi name the document again; it may be sent with a new title. Other title_owner values fail with 400 invalid_title_owner. Changing either the title or its owner advances the title revision used to invalidate stale suggestions. custom_fields preserves each definition’s declared type: strings for text/url/select, numbers for number/monetary, Unix seconds for date, booleans for bool, string arrays for multi, and a bounded exact-document summary for documentlink. extra_data carries choice definitions and display_value gives the server’s formatted presentation. Extraction-managed values include the same managed_by owner as their definition so clients can present a readable label without changing the stable field name. A document link is omitted when its exact target is trashed, missing, or not visible to the caller. The response never substitutes a newer version and never exposes the stored target ID separately. notes always belongs to this exact revision. can_edit reflects author/admin ownership; document change permission is checked again when mutating a note. archive_serial_number is omitted when absent. A sparse PATCH accepts a positive integer no greater than 9007199254740991, or null to clear it. Assignment is allowed only on the live current version; clearing is allowed on Trash and history. Values are unique within the selected filing system across live, trashed, and historical rows. Repeating the current value is an idempotent success. Invalid input returns 400 bad_archive_serial_number; retained uniqueness returns 409 archive_serial_number_conflict; assignment to Trash or an older version returns 409 archive_serial_number_requires_current_version. rendered_layout is omitted when the document uses the default JD layout. PATCH with {"rendered_layout_id":7} assigns a same-system layout; 0 returns to the default. A layout that uses {{ asn }} returns 409 rendered_layout_requires_archive_number for a document without an ASN. Combined ASN/layout PATCHes validate the resulting state. Assignment, clearing, and other path-visible metadata changes enqueue the rendered move in the document transaction. content_source is device_ocr, server, or empty and names the authority for the active content. Device provenance remains available after authoritative server text replaces it. Pass include_content=0 for metadata-only reads; the response keeps the stable content key with an empty string and does not carry OCR text over the wire. This is the required mobile metadata/detail request. jd_category_code / jd_category_name / jd_area_name are denormalized from the JD tree so API clients can render a filing chip without a second round-trip. See GET /api/jd/categories/ for the picker enumeration. jd_area_code is deliberately omitted — the category’s own code encodes the area (e.g. 31 → area 30–39). languages is a CSV of ISO-639-1 codes detected by the LLM classifier or provided through metadata; it is empty when nothing was detected. languages_locked is true once a user has set the value via PATCH, preventing later automatic detection from overwriting it. Set languages (as CSV string or JSON array) via PATCH /api/documents/{id} to correct the detection; passing an empty value clears both the value and the lock. Successful metadata PATCH and bulk metadata/tag edits remove only the classifier-owned needs-review assignment in the same transaction. Manually assigned, imported and rule-assigned review tags remain. Explicitly adding needs-review takes human ownership before cleanup; Trash, restore and rescan alone do not resolve a review marker. Empty lists always serialize as [] (never null) so mobile clients can iterate them safely.

GET|HEAD /api/documents/{id}/download

/api/documents/{id}/download and the browser-facing /download/{id} use the same authorized byte handler. The normal response prefers the processed archive representation when one exists, then a decrypted representation, then the immutable original. ?raw=1 requests the original and is session-only; API tokens cannot use it. The document projection’s original_blob, original_size, and mime_type describe the uploaded original. They are not a digest, length, or type promise for the normal download: an uploaded image can have a PDF archive response. Consumers must use the response Content-Type, Content-Length, and retained bytes as the downloaded representation’s metadata and compute their own digest when they need local integrity. A full GET returns 200. Standard Range: bytes=… requests return 206 with Accept-Ranges: bytes and Content-Range; unsatisfiable ranges return 416. Prefix, suffix, open-ended, and multipart range handling follows Go’s http.ServeContent. HEAD returns the same selected-representation headers and status without a body. Responses use a strong content-digest ETag and Cache-Control: private, no-cache; conditional and range requests still pass current authentication, document ACL, Trash, raw-original, and sensitivity checks before any bytes are served. Scope: documents:read. The Companion currently downloads one complete response into a new protected staging file and enforces its own 64 MiB limit; range support does not weaken that client bound or make source metadata a payload receipt.

GET /api/documents/{id}/thumb

Page-1 thumbnail rendered at ingest time from the archive PDF via pdftoppm -f 1 -l 1 -r 40 -png. Omit width to preserve the stored PNG bytes and use ETag "documents.thumb_sha". Optional integer width=64..512 downscales with Catmull-Rom while preserving aspect ratio and never upscales. Its ETag is "<thumb_sha>-w<actual-width>", where actual width may be smaller than requested. Invalid, duplicate, or out-of-range width values return 400 bad_width. Both forms use Cache-Control: private, no-cache and support conditional requests with their ETag. Every reuse therefore revalidates authorization, document state, and sensitivity before a matching request can return 304 Not Modified. Scope: documents:read. ACL rule matches GET /api/documents/{id}. Returns 404 when:
  • The doc has no archive PDF (image originals, EPUB, .msg, etc.).
  • The instance ran the ingest without pdftoppm on PATH; the pipeline logged the skip.
  • The doc was ingested before thumbnails shipped and hasn’t been re-rendered.
The SPA treats a 404 as “no thumbnail” and falls back to its title-initials placeholder — no error state on the row. Sensitivity gate. Mirrors /preview/{id}. When the doc is confidential or restricted, the endpoint returns 202 with a small JSON body ({"sensitivity":"…","gated":true,"reveal_url":"?reveal=1"}) and Cache-Control: no-store unless the caller passes ?reveal=1. A validated width is preserved in the gated URL, for example ?reveal=1&width=160. The SPA passes reveal=1 on the list view (the caller is already authorized to view the doc) and blurs the returned bytes client-side; a direct URL scan without reveal=1 cannot pull the raw first page. Mobile rows request width=160.

GET /api/documents/{id}/versions/

Visible live revisions in the family containing {id}, newest first, in the standard page envelope. {id} can be any family member. The response also adds head_id, the greatest visible live document ID in the family, and can_upload, which reflects the caller’s current ability to upload a replacement for the requested row.
Each row is checked against its current document ACL. A caller must be able to view the requested anchor, and the response omits any other family member it cannot view. previous_version_id, head_id, and is_head are computed only from visible live rows, so a later ACL or Trash change cannot leak hidden family metadata. can_upload is true only when the requested row is the latest visible live revision and the caller can change it. The upload transaction still performs the authoritative family-head check without exposing hidden revision identity.

POST /api/documents/{id}/versions/

Upload a replacement with previous_version_id={id}. Uses the document upload multipart, device OCR, source timestamp and idempotency rules. The new row keeps the predecessor’s system, owner, JD category, sensitivity, explicit ACL grants, manual tags (including type: tags), correspondents, and title. The predecessor’s ASN moves to the new head and is cleared from the old row. A selected rendered layout follows only when its template uses {{ asn }}; both affected revisions receive rendered-move jobs in that transaction. The replacement gets fresh extraction/generated state and does not copy custom-field values, document links, notes, non-ASN layouts, classifier-owned tags, accepted dates, or other exact-revision metadata. A conflicting requested system fails. The success and replay response includes archive_serial_number when one moved. The predecessor must be the current family head and must itself be live. A newer family member, including one in Trash, causes 409 {"code":"version_head_changed",...}. The response contains no competing document ID. This makes concurrent replacement single-winner and prevents a restored revision from turning history into sibling branches. A matching idempotent replay is returned before this head check. A matching keyed retry replays before live-blob detection. After the 30-day response cache expires, a keyed retry of the same bytes against the same predecessor is still recovered from the resulting version row. Without a replay, bytes already owned by any live document for the predecessor’s owner in that same system return 409 {"code":"duplicate_version_blob","existing_id":<id>,...} instead of a database error. The existing row may be the predecessor, another version in the chain, or an unrelated document. existing_id is omitted unless the caller can view that row. Replays also require current permission to view the result; a trashed result returns 409 version_target_unavailable. Trashed rows do not block a new version.

POST /api/documents/bulk_edit

Batch metadata mutation across N documents in one write transaction. The SPA multi-select actions (bulk refile, bulk trash) call this instead of looping N PATCHes. Scope: documents:write. Cap: 500 documents per call. Request
Methods Response
Behavior
  • Every id is ACL-checked per the same rule as PATCH /api/documents/{id}. Refused ids appear in the array with ok: false, code: "forbidden"; the rest still run.
  • One documents.bulk_edit audit event per call (with method + total + applied), not per doc — the granular outcome lives in the response array.
  • All authorized ids run inside a single WriteTx. A per-id error inside the tx aborts the whole batch by design — partial writes would leave the archive in a half-state.
  • Successful set_correspondent, set_rendered_layout, set_jd_category, set_sensitivity, add_tag, and remove_tag calls synchronously run document_updated automations once per unique authorized document, in input order, after the bulk transaction commits. Rule failures are logged without rolling back the edit or preventing rules for later documents. A client disconnect after the commit does not skip them. Lifecycle methods do not emit this trigger.
  • restore enforces the same 30-day window as individual restore. An expired authorized document rejects the batch with 400 bad_parameters; already-live documents are unchanged.
The {documents, method, parameters} body is Suchi’s stable bulk-edit contract for the SPA and external clients.

GET /api/documents/pending-decryption

List live documents awaiting a password that the caller can view in the selected system, including ACL grants. Requires documents:write for API tokens; appearing in this list does not itself grant permission to decrypt. 200 OK

POST /api/documents/{id}/decrypt

Attempt to decrypt a live document in encryption_state='encrypted'. Requires permission to change the document and documents:write for API tokens. Body: {"password": "...", "remember": true, "label": "opt"}
  • password — required. The user password for the PDF.
  • remember — optional. When true, the password is AEAD-sealed with the server’s .decrypt-key and stored in decryption_passwords for the doc’s owner and system. Subsequent encrypted uploads by that owner in that system auto-try it.
  • label — optional operator-visible tag (“BofA 2024”) attached to the stored password.
On success: writes the decrypted bytes into the CAS as decrypted_blob, flips state to 'decrypted', re-enqueues post-ingest. Returns 204 No Content. Failure modes:
  • 400 bad_password — qpdf rejected the password
  • 400 empty_password — body missing the field
  • 404 not_found — document missing, trashed, not encrypted, or unavailable to change
  • 503 decrypt_disabled — server started without .decrypt-key

POST /api/documents/decrypt-batch

Apply one password to documents in the selected system, checking change permission per document. Requires documents:write for API tokens. Results preserve request order and report failures per item. Internal failures return db_read or decrypt_failed; details remain in server logs. Body:
200 OK
With remember=true, a successful batch stores the password once for the calling user in the selected system. It stores nothing if no document succeeds. The label defaults to the first successfully decrypted document’s title.

DELETE /api/documents/{id}

Moves a live document to Trash by setting trashed_at. The document remains recoverable for 30 days. 204 No Content on success, 404 if the document is not live. Restoring and trashing it again starts a new 30-day window.

POST /api/documents/{id}/restore

Clears trashed_at during the 30-day recovery window. Restoring a live document is idempotent and returns 200 OK with {"affected": 0}. An expired trashed document returns 404 and remains eligible for automatic purge.

GET /api/trash/

Returns a paginated envelope of trashed documents in the selected system. Members see their own documents; administrators see all owners there, never other systems. Each row includes trashed_at, deletes_at (the Unix timestamp 30 days later), retained jd_category_code, jd_category_name, and category jd_address, and archive_serial_number when retained.

DELETE /api/trash/{id}

Permanently deletes one already-trashed document. Requires documents:write and delete permission for that document. Returns 204 No Content; live, restored, and missing documents cannot be hard-deleted through this route.

DELETE /api/trash/

Permanently deletes every document in the selected system’s caller Trash scope for which the caller has delete permission. Members are owner-scoped; administrators can purge all owners only there. Returns 200 OK with {"purged": N}. Permanent deletion removes document-owned metadata and workflow state, revokes every share link containing the document, removes incoming documentlink values whose exact target is the purged row, clears other non-foreign-key references, and replaces prior document audit history with a minimal document.purge record. Rendered files are removed safely. Original and derived CAS blobs stay on disk until offline garbage collection, which reclaims bytes no document or avatar references while all archive writers are stopped.

Search and retrieval

GET /api/search/

Ranked search over document text plus filing metadata. Scope: documents:read. All callers, including admins, stay within the selected/token-bound system; ordinary document visibility applies before results, counts and snippets. Metadata filters cannot expose foreign names or a document the caller cannot view. Query params: Ranking uses title-weighted BM25 plus an optional bounded recency term. recency=off returns raw BM25 ordering. Formula, score direction, weights, snippet behavior, and filter-only ordering are documented in Search architecture: Ranked Search. Like Documents, ranked Search defaults to the newest visible live member of each version family. Rich-query version: selectors change that scope without changing authorization or ranking. Response shape — {count, next, previous, results} DRF envelope. Each hit carries {id, title, snippet, rank, created_at, mime_type, sensitivity, jd_category_code} plus archive_serial_number when assigned. snippet wraps a matching span in <mark>...</mark> for non-sensitive documents. Confidential and Restricted hits still participate in ranking but return an empty snippet; clients must render a sensitivity placeholder rather than hidden OCR text. Malformed queries return 400 bad_query; logs contain only a SHA-256 correlation value, never the raw query.

Query language

GET /api/search/ and GET /api/documents/ use the same bounded parser and matching plan. See Query language for terms, phrases, implicit AND, negation, filters, comparisons, escaping, unsupported syntax, limits, completion, and bad_query errors. GET /api/autocomplete/?q=... completes the active final query token or falls back to bounded taxonomy-prefix suggestions. See Search architecture: Query completion and autocomplete.

Saved-view query validation

POST /api/saved_views/ and PATCH /api/saved_views/{id} accept filter_json as a JSON-encoded object string up to 2 KiB. Supported keys are q, tags__id__in, correspondents__id__in, jd_category_id, sensitivity, ordering, and document_ids. Each user’s name uniqueness and 50-view limit apply within the selected system. Sharing a View does not share system membership or bypass document ACLs. document_ids must be a JSON array containing 1–100 positive integers. The server removes duplicates while preserving first occurrence. It is the stable contract for an exact snapshot View; opening or sharing the View still reapplies document authorization. Other supported filters are additive, so an exact snapshot stores document_ids alone. The web app does this instead of deriving a live q from question text. Saved-view tag and correspondent lists accept at most 100 unique positive IDs. The server removes duplicates in first-seen order during normalization. When q is present, the server normalizes its spelling and resolves referenced categories, tags, and correspondents before writing the row. Syntax errors, unknown values, and ambiguous values return 400 invalid_filter; an update that fails validation leaves the stored view unchanged. Existing flat filter objects remain readable, while newly created web-app views use q as their search constraint. See Query language: Saved Views.

GET /api/documents/{id}/similar

Returns authorized lexical neighbours from the source document’s filing system using the shared FTS5 index. Source access is checked first and the source is excluded from candidates. ?limit=N accepts 1–50 and defaults to 10. Token selection, weighting, score floor, title-only fallback and the absence of recency are in Search architecture: Similar documents.
score is higher for stronger similarity. matched_on_title_only warns that the source had no extracted body text. method is currently fts.

GET /api/languages/

Language facet — distinct codes across the caller’s visible live documents in the selected system, with per-code counts. Powers the SPA’s search-page language filter and doc-detail chip picker. Ordered by count descending, then code ascending. 200 OK
Filter the search endpoint by language via GET /api/search/?lang=de. The filter uses a comma-bracketed LIKE against documents.languages so 2-letter codes don’t false-match 3-letter neighbours (de vs deu).

Filing and metadata

GET /api/jd/categories/

Read-only listing of every category in the selected system’s JD tree, with area_code + area_name denormalized onto each row so a picker can render a grouped tree in one request. Category system_code identifies that filing system; system remains the protected-row boolean. Used by mobile pickers, the SPA’s file-under dropdown and external clients resolving jd_category_id. Query params (all optional, in addition to the shared system context):
  • ?q=<prefix> — case-insensitive prefix match on the category code OR name. ?q=22 and ?q=tax both match 22 Tax.
  • ?area=<code> — scope to one area’s 10-slot range. ?area=20 returns categories 20–29.
DRF envelope. Read-only — tree changes use the admin preset/import endpoints, not per-row insert. Generated 49 Inbox is included; 00–09 System index is not an area/category row or picker destination. 200 OK

GET /api/tags/

Read tags in the selected system with optional parent-filter. Query params: Response is the standard pagination envelope:
next/previous are omitted (or empty string) when there’s no neighbor page. count is the total matching the current filter, not the length of results. Each TagView carries parent_id (nullable) and child_count so the UI renders the shallow tree without extra round-trips.

POST /api/tags/

Admin-only. Body: {"name": "...", "slug": "...", "color": "#..."} — only name is required. Slug defaults to slug.Make(name); color defaults to #a6cee3 at the DB level. Returns 201 {"id": <int>} on success, 409 conflict when the name or slug already exists in that system.

PATCH /api/tags/{id}

Admin-only. Body: any subset of {name, slug, color}. Empty body returns 400 no_fields. Same 409 conflict on unique collision. Parent moves live on PATCH /api/tags/{id}/parent (below) — that endpoint owns the cycle-check invariant and is not duplicated here.

DELETE /api/tags/{id}

Admin browser-session only. Returns 204 No Content on success, 404 not_found otherwise. Document-tag junction rows are removed; surviving direct children are detached from the deleted parent and become roots. The handler explicitly detaches them within the deletion transaction, including on databases with an already-applied schema-3 ON DELETE CASCADE parent FK.

DELETE /api/tags/

Admin browser-session only. Body: {"ids":[1,2]} with 1–500 distinct positive tag IDs. Returns 204 No Content after one atomic selected-system transaction. Every ID must exist in that system: a missing or foreign-system ID returns 404 not_found without deleting any tags or links. Malformed JSON returns 400 bad_json, an empty set 400 no_ids, duplicate or nonpositive IDs 400 bad_ids, and more than 500 IDs 413 too_many_tags. A non-admin receives 403 forbidden; API tokens cannot use this route even with write scope. Document-tag links to deleted tags are removed. Any surviving children of deleted parents become roots, including when both parent and child are selected.

PATCH /api/tags/{id}/parent

Admin-only. Body: {"parent_id": <int-or-null>}. The parent must belong to the tag’s system. Rejects self-parent (400 self_parent) and cycles (400 cycle). Tag ancestry does not imply document-search inheritance; see Storage and transaction invariants.

GET /api/documents/{id}/correspondents/

List the correspondents attached to a document, grouped by role. Bank statement = {sender: HDFC, recipient: account holder}; invoice = {sender: vendor, recipient: customer}. Roles are sender, recipient, cc, other. Response:

POST /api/documents/{id}/correspondents/

Add a correspondent. Body: {"name": "...", "role": "sender|recipient|cc|other"}. Upserts the correspondent by name within the document’s system. role=sender also mirrors to documents.correspondent_id when no primary is set — the single- correspondent code paths (list column, existing automations) still find the sender.

DELETE /api/documents/{id}/correspondents/{cid}/{role}

Remove one junction row. 204 on success.

GET /api/documents/{id}/referenced-by/

Return live, visible documents whose documentlink value points to the exact requested row. The requested row is the authorization anchor and must itself be visible. Results use the normal page envelope (default 50, cap 200), ordered by source document ID descending and then field name:
Counts and pages exclude hidden or trashed source documents before pagination. is_latest is caller-relative. Trash on the target returns an empty envelope; permanent target deletion removes the stored incoming values.

PUT /api/documents/{id}/custom_fields/{field}

Write a typed custom-field value. {field} accepts the numeric field ID or name, resolved within the source document’s system. Values can be written and deleted; document detail returns authorized typed values, while /api/custom_fields/ lists field definitions. There is no separate raw-value read endpoint. Ordinary metadata values cannot reference another system, even when the actor enters both. An authenticated documentlink write may cross systems only when the actor can change the source and view the live target, including entry to both, checked in the source write transaction. Actorless handler/automation writes accept only same-system live targets. Body: {"value": <T>} where <T> matches the field’s data_type: url validates as http/https. Empty value (null or a blank string) ⇒ row is deleted. Returns 204 No Content on success. Validation returns 400, including document_link_self and document_link_same_family; unrelated documents may still have reciprocal or cyclic named links. A trashed, inaccessible, or missing document or missing field returns 404.

DELETE /api/documents/{id}/custom_fields/{field}

Remove the value row for one live, change-authorized document and field pair. 204 on success; trashed documents return 404 without changing values or jobs.

POST /api/documents/{id}/notes/

Create an attributed note on one exact live revision. Body: {"note":"Ask about the tariff change."}. The caller needs document change permission; API tokens need documents:write. Notes are trimmed, must be non-empty, and are limited to 16 KiB. 201 Created returns:

PATCH|DELETE /api/documents/{id}/notes/{note}

The note author or an administrator may update/delete a note while the exact document remains live and change-authorized. PATCH accepts the same {"note":"..."} body and returns its id and updated_at; DELETE returns 204 No Content. Another author receives 403 forbidden; a missing, trashed, or mismatched document/note pair returns 404. Replacement versions do not copy notes.

/api/custom_fields/ — field definitions

GET returns the selected system’s paged definitions. Pipeline-owned rows include managed_by (currently zugferd); ordinary rows omit it. POST is admin-only and accepts name, one declared data_type, and optional extra_data as a JSON string. For example:
PATCH /api/custom_fields/{id} can rename an ordinary field or update choices. Choice fields require a non-empty array of unique, trimmed, non-empty strings. A field with any stored value rejects a type change with 409 data_type_in_use; removing an in-use select/multi choice returns 409 choice_in_use. Adding choices and renaming remain allowed. DELETE removes an ordinary definition and its values. Extraction-managed definitions reject PATCH/DELETE with 409 managed_field; POST and rename reject their reserved names with 409 managed_field_name.

/api/rendered_layouts/ — folder-layout templates

The UI calls these Folder layouts. The API retains rendered_layouts as the stable wire vocabulary for named, selected-system templates of the local filesystem view. Preview returns {"path":"Bills/2026/March electricity bill.pdf","uses_asn":false}. The sample is a PDF; real documents get their linked file’s extension. Preview, create, and update therefore reject templates whose final component has a file extension. They also reject absolute, empty, unknown-expression, traversal, and reserved filing-index paths. Supported variables:
  • document: title, doc_pk, created, created_year, created_month, created_day, added, added_year, added_month, added_day;
  • Filing Tree: jd.area.code_start, jd.area.code_end, jd.area.name, jd.category.code, jd.category.name, jd.system.code, jd.system.name, jd.address;
  • metadata: correspondent, tag_list, owner;
  • metadata: asn (the archive serial number);
  • compatibility: storage_path (the selected layout name).
Changing a path template queues fresh rendered moves for every live assigned document. Deleting a layout clears assignments and queues default-layout moves in the same transaction. A document, bulk action, or automation cannot assign an asn layout to a row without an archive serial number. Replacement uploads move an ASN and an ASN-dependent layout together to the new head.

GET /api/presets/ and GET /api/filing-sets/

Session-admin-only catalogs for the Filing tree workspace. /api/presets/ returns each ready-made tree with its set_ids and complete user areas/categories; generated System/49 is omitted. /api/filing-sets/ returns:
The composer accepts at most one set per lane. System/49 is generated internally for every ready-made or composed target and is not selectable.

POST /api/admin/setup/preset/preview

Session-admin-only. Preview a ready-made preset with preset_id, or a composed tree with set_ids. API tokens cannot use it.
The response includes the complete target user_areas, state_hash, selected set IDs, and each category collision. Collision rows include existing/incoming names, live/Trash document counts, automatic or suggested replacement state, and a proposed free same-decade code when one exists. A remap value of 0 skips the incoming category.

POST /api/admin/setup/preset/apply

Uses the same target and mapping body plus the exact Preview expected_state_hash. Missing previews return 400; a stale preview or unresolved collision returns 409 without partial mutation. confirm_blank must be true for a target with no user sets. refile: true runs the explicit post-Apply sweep and returns its document/rule/render counters. Reviewed replacement updates a category name/description in place so its row ID, documents and dependent local configuration survive. Preset changes never touch automations.

POST /api/admin/taxonomy/import

Session-admin-only, with existing same-origin protections; API tokens cannot administer taxonomy. Accepts strict suchi-taxonomy/v1 TOML or HuML and previews or atomically applies it. Taxonomy content is bounded to 1 MiB; invalid input is rejected before mutation. Category keywords and seeds.automations from v0.2 exports are ignored. Request body
format is "toml" or "huml"; omitted/empty means TOML, never content sniffing. Through 0.4.1 an omitted format meant HuML, so a client that sends HuML must now pass "format": "huml". UI callers send it explicitly. apply=false returns the preview. Apply sends the same content, format and remaps plus "expected_state_hash":"<state_hash from preview>" and "apply":true. Optional body target_system must agree with a file’s top-level system. A prefixed file selects its code ahead of ordinary ?system= query context; that query is only the fallback for unprefixed input. On first prefixed import, omitted existing_system_code adopts the original archive under the file code. Supplying a distinct code (for example "existing_system_code":"A00") preserves the original separately. This choice is unavailable after introduction and is bound into preview/apply; new systems initially admit only instance admins. Send remaps during preview as well as apply. Keys are incoming user category codes, values 0 to skip or a free category code in the same permitted decade. Generated structure is neither incoming nor remappable. An unresolved collision prevents apply; changed input/options/relevant archive state returns 409 stale_preview, requiring a new preview without discarding the user’s input. The hash is a consistency check, not a permission token or signed session. 200 OK (preview and successful apply share the same fields) The UI explains 00-09 System index separately; it is not in either area array. Only safe initial setup can replace bootstrap structure: filed documents, including recoverable Trash, and dependent configuration prevent replacement. Merge preserves same-code/same-name local descriptions and never touches automations. Destination introduction/creation, membership preservation, category additions, provenance and durable jobs commit together. Apply rechecks that the authenticated actor remains an active instance admin. Validation errors retain readable messages and may include structured field issues. Unknown formats/keys, reserved author declarations, malformed parameters and unsupported behavior are errors, never partially accepted extensions.

GET /api/admin/taxonomy/export

Session-admin-only. Build and validate a consistent current snapshot in TOML or HuML: ?format=toml|huml&system=S01 (default TOML and original system). Through 0.4.1 the default was HuML; pass ?format=huml to keep HuML output. Attachment filenames are <SYS>.taxonomy.toml|huml after introduction and archive.toml|huml before it; the current system code/name accompanies the snapshot. Snapshot header is id = "archive", version = 1, separate from last-import provenance. Generated System/49 is implicit. Export contains the filing tree only. Legacy incompatible reserved structure fails without automatic repair. Automations, access/review state and document/version/link identity require complete backup or their portable owner.

Activity and access

GET /api/stats/

Dashboard counters in one round-trip. Replaces four page_size=1 list probes plus two task lists — same numbers, one query. Every count is visibility-scoped per principal via the same WHERE fragment the list endpoints use. Scope: documents:read. 200 OK
inbox_category_id echoes the selected system’s JD Inbox pointer; keep the system code in the browser’s #/documents?jd=49&system=S01 route after introduction. pending_intelligence is its visible extracted-fact review count for callers with archive_intelligence. dead_jobs is system-scoped and admin-only (members see 0); pending_approvals is scoped to the member’s assignee or all open work in that system for admins. ingested_7d counts live documents created in the last 7 days — useful for a sparkline; not a filing-date metric. processing_reading counts visible live documents whose extraction or OCR (post-ingest) is pending or running, including retries; processing_classifying counts those waiting on the optional model step (post-classify). Both drop to 0 once that work finishes or is dead-lettered. A visible browser tab polls this endpoint every 15 seconds, and every 5 seconds while either is non-zero; hidden tabs stop polling and refresh when shown again.

GET /api/events/

Cursor-based activity feed over the audit log for integrations that need a near-term change stream without polling /api/documents/ and /api/tasks/ on a timer. Scope: events:read. Browser sessions have it implicitly; API tokens need the exact scope. Query params (all optional):
  • ?since_id=<id> — return rows with id > since_id. First call omits it and receives the newest visible tail in chronological order; every subsequent call passes the previous response’s latest_id. audit_events.id is a strictly-increasing INTEGER PK.
  • ?kinds=a,b,c — comma-separated allow-list. Unknown kinds silently return nothing (no error), so a client rolling out a new kind name never breaks on an older server.
  • ?limit=<n> — 1–200, default 100. System and visibility predicates apply before cursor selection and pagination; a foreign event cannot consume the page or advance latest_id. Keep a separate cursor for each captured system.
Visibility rules (enforced server-side):
  • Every returned row belongs to the selected system, including for administrators. Document rows also require current document visibility.
  • Approval-task rows require system entry and any attached document’s visibility, plus the exact user:<id> or role:<role> assignee unless the caller is an admin.
  • Other non-document rows are visible only to their producing user/token or an administrator within that system. Truly global events such as backup.written are not part of this filing-system feed; use operator logs or an audit sink.
  • Server-lifecycle / janitor kinds (server.start, audit.pruned, jobs.reclaimed) are hidden from the feed for every caller, admin included. They still land in audit_events for the durable trail; read the table directly (or a SIEM sink) if you need them.
Summary rendering. summary is a pre-rendered one-liner (“Ingested March rent”) so clients don’t need a title lookup per row. The privacy invariant applies — summaries carry titles and kinds only, never document content or OCR text. Kinds currently emitted (non-exhaustive; new emit points land in new server versions and appear here as they arrive): 200 OK
Cursor state is a client concern: save latest_id and pass it back as since_id on the next call. The server intentionally stores no per-consumer cursor. Feed horizon. Ordinary audit rows are pruned on the backup ticker per AUDIT_RETENTION_DAYS (default 20, cap 100). Retained identity-security rows survive that pruning but are instance-wide records, not part of this filing-system feed. since_id stays valid across purges — IDs only grow — but a client that comes back after the ordinary retention window has elapsed sees a clean empty diff, not “the history it missed.” This feed is a near-term change stream, not a permanent record. Long-horizon consumers should read audit_events or use a configured audit sink instead.

GET /api/tasks/

Read durable-outbox work in the selected system: what is running, failed or queued. Documentless jobs still require that system ownership; global operational work is not this collection. Document work additionally requires document visibility. Query parameters: Malformed, non-positive, duplicated, or unsupported filter values return 400 bad_task_filter; they never silently broaden the result. Job counts use the same state, doc_id, and kind filters as results. With include=approvals, job counts are zero and only approvals_open is populated. Response shape:
Timestamps are Unix seconds. approval_tasks are scoped to the caller: an entry appears only if the task’s assignee resolves to the current user. Terminal statuses (resolved, expired) are omitted. Document-bound tasks are also omitted while their document is trashed and can reappear if it is restored while the task is still open or claimed and its run is still active. A task that expires or whose run advances while the document is trashed does not reappear. Documentless tasks retain their run’s system and need entry there; document tasks also require document visibility. Assignment alone cannot admit a nonmember. Resolve actionable work via POST /api/approvals/tasks/{task_id}/resolve. approvals_open in counts is the true open-inbox size, unaffected by limit. Document-bound tasks include the current title and filing category plus a thumbnail-presence flag so clients can identify the document without fetching its extracted text. API tokens need documents:read to list tasks and documents:write for generic approval resolution. Built-in inferred document changes additionally require a live interactive browser session; a scoped token is not human-review proof. Administrator sessions can resolve dead outbox rows directly: Both return 404 not_found unless the referenced row is dead and available within the request’s system boundary. API tokens cannot retry or dismiss jobs. Neither response returns the stored provider error. Document suggestions from archive matching, the optional LLM classifier, and classifier answers mapped through an automation also appear in approval_tasks. Their approval_name is document-change; the vars projection includes bounded proposed/current values, confidence, reason, policy version, source freshness and authorized supporting documents. Category, correspondent and tag proposals also carry proposed_id, the proposed row’s ID in the current filing system, while it still exists. Ask-derived proposals additionally expose the automation ID/name and typed custom-field label when applicable. The configured question remains part of the ordinary automation definition but is not copied into approval tasks; raw answers and answer evidence are neither returned nor retained. Raw baselines and session proofs are not exposed. Eligible high-confidence changes may use automatic mode; review-first and below-threshold changes remain human decisions. Unknown actions stay read-only. Source/ACL, destination, custom-field baseline, or human-generation changes prevent application. Resolution records and queues a decision, not a completed metadata write; the eventual effect rechecks it.

Automations surface: /api/automations/*

Automations combine triggers, conditions and actions on events within their owning system. Names, metadata references and rule forks are scoped there. These routes require a browser/OIDC session. Full guide at Automations. PATCH is sparse — send only the fields you want to change. The SPA’s Enable/Disable button posts {"enabled": false}; a full-edit form posts name + order + enabled + triggers + optional ask + actions. When triggers or actions is present, its value replaces the child rows wholesale (an empty array clears them); leave the key out to keep them as-is. ask accepts {"question":"…","answer":{"type":"yes_no"}} or a choice answer with 2–10 choices; send null to remove it. Conditional actions add "when":"<declared answer>". The schema response advertises the enabled-question limit, supported answer types, trigger type, and action kinds. Full validation and examples are in Automations: Ask the classifier.

Approvals surface: /api/approvals/*

State-machine engine for human-in-the-loop review chains. Definitions and runs, including documentless runs, belong to one system; slugs resolve there and all six endpoints retain the explicit/token system boundary: See the approvals guide for the spec shape, error codes, timeout behavior, and a worked invoice-approval example.

Permissions surface: /api/groups/* + /api/acls/*

Named user collections plus per-object grants. These routes require a browser/OIDC session. Owners and administrators can inspect or change grants; receiving a grant does not confer delegation rights. Full guide at permissions. {kind} is one of document|tag|correspondent|rendered_layout. Grant writes accept only perm_bits=1 (View), 3 (Edit: view + change), or 7 (Full control: view + change + delete). Other combinations return 400 bad_permissions.

POST /api/share_links/ — multi-doc bundles

A share link covers 1–200 documents under a single opaque token. Bundles work by design: pass an array on POST, and the recipient sees one landing page with every doc listed. API tokens need documents:write. Request
  • doc_ids: 1–200 live documents from one enterable system. Members must own them; administrators bypass ownership, not an explicit request or token system ceiling.
  • label: optional human string shown on the landing page, up to 200 bytes.
  • visibility: required, immutable network or public. Change it by revoking and creating another link.
  • password: optional, up to 1024 bytes. It is Argon2id-hashed at rest.
  • expires_in_sec: seconds from creation, up to one year. Network links may use 0 for no expiry. Public links require a non-zero expiry, and creation returns 409 public_sharing_unavailable while the share listener is disabled.
If password hashing is temporarily unavailable, creation returns 503 with Retry-After: 1; no share row is written. Response carries visibility, expires_at, the fresh token, and the absolute public_url. Network links use PUBLIC_URL; Public links use SHARE_PUBLIC_URL. shared_by and instance_host are the identity recipients see. Public paths (unauthenticated, rate-limited): The bearer is deliberate public access, not permanent authority. Every request rechecks that its creator remains active and can enter the share’s system; only still-shareable live documents from that system are exposed. Removing membership revokes that member’s shares, and re-admission does not reactivate them. Metadata, password responses, and downloads use Cache-Control: no-store so revocation, expiry, or a password change cannot be bypassed by cached content.
  • GET /s/{token} — content-negotiated on the Accept header. A browser (Accept: text/html) gets a small landing page with the bundle label, Shared by <display name> · <instance host>, expiry, a password form when required, and one download row per doc. The identity appears before the password form. If the display name is empty, the page shows only Shared from <instance host>. Programmatic callers that accept application/json get {label, shared_by, instance_host, expires_at, requires_password, docs:[{id, title, mime_type, original_size, download}]}.
  • POST /s/{token} — password-form submission from the HTML landing. Verifies the password server-side and sets a one-hour HttpOnly, SameSite=Strict unlock cookie scoped to Path=/s/{token}. The cookie contains a signed proof, not the entered password.
  • GET /s/{token}/{doc_id}/download — streaming download of one doc from the bundle. API clients can send the password in X-Suchi-Share-Password; browsers use the unlock cookie above. Passwords are not accepted in URLs, where they can leak into history and proxy logs.
List owned links in the selected system with GET /api/share_links/ (documents:read for API tokens). Optional doc_id and status=active|inactive filters apply before count and pagination. Responses include visibility, expiry and password state but no view counter. Revoke via DELETE /api/share_links/{id} (documents:write)—the share token becomes 404 immediately (no soft delete).

Initial setup

POST /setup

First-boot admin creation. Consumes the one-time setup token minted at boot when no users exist. 409 Conflict if already initialized. Request:

GET /api/admin/setup/state

Admin-only. started_at is the first administrator’s creation time. current_preset, current_set_ids and filing_tree_chosen describe the selected ?system=Snn, not the archive’s default system. The preset identifies the last imported choice, not proof that the current merged tree equals that file. current_preset_builtin is true only when the stored ID, version and content hash match the ready-made tree applied by this Suchi build; a custom import remains custom even when it reuses a built-in ID. Empty preset/set values, a false current_preset_builtin and an absent start time are omitted.

Ask your documents

Question endpoints require authentication, documents:read, and archive_chat (implicit for administrators). Public demo sessions are denied.

GET /api/chat/status

Returns live runtime availability and a safe provider label:

POST /api/chat

History contains only the newest complete user/assistant pairs fitting four messages and 12 KB. context_source_ids contains at most three positive cited IDs. Scope accepts a rich query, up to 100 exact IDs, and additive category, sensitivity, tag IDs, correspondent IDs (or correspondent_missing: true), inclusive created-time bounds, and language fields. Missing-correspondent scope cannot be combined with correspondent IDs. Every source and context ID is reauthorized with fresh ACL, trash, sensitivity, and scope checks. The response contains at most six documents, structurally validated citations, grounding state, and complete suggested-detail counts. Suchi adds missing inline markers from the provider’s structured citation list. Text sent to the provider is bounded to 300 Unicode characters per title and the active 1,600–4,800 Unicode-character Reading-depth preset per snippet, plus three approved details per document and twelve details total:
No matching document text returns the stable no-answer response without a model call. Insufficient text, including an answer with no document numbers, returns the stable insufficient answer. Malformed or out-of-range citations return 502 invalid_provider_response; model failures return 502 provider_failure; a disabled runtime returns 503 model_unavailable; the per-user rate or document-matching/model concurrency gates return 429 chat_rate_limited with Retry-After. Q&A is read-only. Saving these documents as a View or opening Approvals or Calendar is a separate browser workflow.

Extracted facts (archive_intelligence)

These endpoints require archive_intelligence, implicit for administrators. Reads also require documents:read; extraction and resolution require documents:write plus Change permission on each source document. Public demo sessions may read the schema and accepted demo-corpus date facts only, with the usual token read scope and source-document visibility checks. They cannot read pending/rejected facts, run extraction, or resolve facts. This exception does not grant archive_intelligence to demo users or other ordinary users.

GET /api/intelligence/schema

Returns registered extracted-fact types and role vocabularies.

GET /api/intelligence/

Returns the standard pagination envelope. Filters: accepted is the storage status for a date available to Calendar. New inference can reach it through guarded automatic application or interactive review. Older accepted facts remain unchanged, including those with no recorded reviewer; no reviewer is backfilled. Stats responses expose the pending extracted-fact count as pending_intelligence. Browser document-list filters use jd_category_id, tags__id__in, correspondents__id__in, created_at__gte, and created_at__lte. view_id cannot be combined with q or document_ids. The server resolves the View rather than trusting client-reconstructed filters, including legacy tag, correspondent, type, category, and sensitivity fields. A nonempty document_ids value must contain at least one valid ID; an empty CSV list returns 400 instead of widening the scope to all documents. Rows contain generic type, role, value, sort_value, evidence, confidence, review state, extractor/version and source-document presentation fields. Bounded reason, policy_version and source_current explain review eligibility. evidence_start records the matched raw date’s UTF-8 byte offset, not a case-folded string offset. Candidate IDs bind immutable proposals and are never reused after rescan or deletion. New automatic dates have status=accepted, null reviewed_by/reviewed_at, policy_version=threshold-auto-v1 and reason=confidence_threshold. Pending candidates retain review-first eligibility reasons and policy. Historical accepted/rejected rows keep their original provenance. For type=date, value contains date and precision. Exactly three precisions are supported: sort_value equals this normalized date. First-day values for month/year precision are sorting placeholders, not exact dates. sort_from and sort_to compare those values; a January range includes a year-only fact, but an August range does not. Omitting those bounds retrieves all periods in the selected document scope. Confidence is a separate model-reported score, not a measured probability. See Document dates for display and review behavior. The optional precision filter applies in SQL before the total count and pagination, together with the document scope and ACLs. Unsupported values or a precision filter without type=date return 400. Exact-day agendas use precision=day with the same date in sort_from and sort_to, so month/year placeholders are excluded even on the first day of a month or year. Omitting precision preserves the existing all-precision behavior. Legacy rows without stored precision are treated as day by this filter.

POST /api/intelligence/extract

Queues the existing post-ingest pipeline for each authorized document and returns 202 with granular per-document results. It does not add another model call to the pipeline. New dates with usable source evidence follow the current application mode and each date’s own score; automatic mode defaults on and the model/date threshold defaults to 0.70. Other usable dates remain pending. Extraction failure never permits automatic acceptance.

POST /api/intelligence/resolve

decision is accepted or rejected. Requires a live interactive browser session, the intelligence capability, current system access and document change permission. API/OIDC bearer possession is not sufficient. Acceptance rechecks the current immutable source, extraction generation and exact grounded evidence inside the write transaction. Legacy unbound pending rows require fresh extraction. Authorized rejection can dismiss a stale or unusable pending date without giving it authority. Results are granular: not_found, forbidden, stale_source, invalid_evidence, or already_resolved. A retry of the same recorded decision returns ok: true without increasing applied; an opposite decision cannot rewrite history.

LLM classifier settings

These endpoints are admin-only. The model remains optional; local archive matching and automations keep running when it is disabled.

GET /api/admin/settings/llm

Returns masked persisted and runtime state. has_api_key is the only key signal; plaintext key material is never returned. subscription_connected reports whether Suchi holds a subscription login, without exposing tokens or verifying current upstream model entitlement. subscription_model is the separately saved dropdown preference; model remains the configured inference model. mode is local, hosted, or subscription; subscription_provider identifies the account whose masked connection state and preference are returned. For endpoint modes the shipped default account provider is shown, without activating it.
research_context_mode is focused, balanced, or detailed. Missing or invalid stored values read back as balanced. auto_apply is the shared mode for new dates, model metadata and local matching: missing storage defaults to true; malformed policy fails closed. It does not activate a model, grant egress permission or change explicit user-authored rules.

POST /api/admin/settings/llm

Body: {enabled, subscription_provider, endpoint_url, model, api_key, clear_api_key, egress_ack, confidence_threshold, archive_enabled, archive_review_threshold, archive_auto_threshold?}. The model/date threshold must be between 0.50 and 0.95, default 0.70. Each date is evaluated independently of the overall classification score. The local automatic threshold defaults to 0.90 and must be 0.55 to 0.95; omitting archive_auto_threshold preserves its saved value. The local minimum review-suggestion score defaults to 0.50, must be 0.50 to 0.90, and must be strictly lower than the automatic threshold. Scores do not replace source, access, field-generation or evidence checks. auto_apply is PATCH-only; it and the old date_auto_apply are unsupported in POST. Saving configuration never promotes pending dates or metadata or starts an archive-wide backfill. Enabling requires a model and either an HTTP(S) endpoint or a registered subscription_provider. Subscription mode requires a connected account, egress_ack=true, and empty endpoint/API-key fields. Non-local endpoint models also require egress_ack=true. A blank api_key preserves any stored key; a non-blank key is AEAD-sealed before the settings transaction commits. clear_api_key=true stores an encrypted empty value for web-managed configuration. A key supplied by the config file or environment remains authoritative. It cannot be combined with a non-empty key. Disabling preserves the saved endpoint and takes effect when that endpoint is not pinned by the config file or environment. The response reports whether the saved configuration is active. The durable subscriber is present from boot, so first activation, configuration changes, key rotation, disable, and re-enable all apply immediately.

PATCH /api/admin/settings/llm

Updates one standalone global settings group. Choose application mode:
or the Ask your documents reading-depth preset:
or the saved provider dropdown selection:
or local similar-document matching settings:
Matching PATCH accepts one or more of these three fields, preserves omitted values, and validates the same threshold ranges/order as POST. It does not access account connection state or reload inference, so matching remains editable after a subscription disconnect. The body must contain one policy field, the subscription provider/model pair, or the matching fields. These groups cannot be combined. auto_apply must be boolean; research_context_mode must be focused, balanced, or detailed. subscription_model must be a nonempty model identifier (up to 128 characters). The provider ID must be registered. The response returns the saved policy field, subscription provider/model pair, or all three matching fields. Application mode is read again under the writer before automatic effects; the reading-depth preset applies to the next question. These standalone saves do not rewrite the endpoint, model, API key, egress acknowledgement, or model confidence threshold. Only matching PATCH updates local similarity thresholds. They do not enable or test a model, reload the provider, accept pending suggestions, or start a backfill. See Ask your documents: Reading-depth presets for text assembly and exact payload bounds.

POST /api/admin/settings/llm/test

Accepts the same connection fields without saving them. A blank candidate key uses the already-resolved stored or environment key. The server sends fixed synthetic text and returns success only when the endpoint responds with a model result that passes production validation.
The result is the validated synthetic classification only. Raw provider output and archive content are never returned.

GET /api/admin/settings/llm/subscriptions/{provider}/models

Administrator browser sessions only. Validates the registered provider ID and fetches the connected account’s model catalog, refreshing the server-side credential if needed, and returns {models: [{id, name}]} in upstream preference order. Empty catalogs return an empty list; unavailable/invalid catalogs return an error rather than guessed model IDs. Credentials and other upstream metadata are not returned. Catalog membership does not establish entitlement; the connection test verifies inference.

POST /api/admin/settings/llm/subscriptions/{provider}/{action}

Administrator browser sessions only; API tokens cannot use these routes. The first provider ID is openai_chatgpt; unknown IDs are rejected. Actions are start, poll, cancel, and disconnect; request bodies may be empty. start returns {user_code, verification_url, interval} and replaces that administrator’s pending login. Poll no faster than interval seconds; poll returns {pending: true} or {connected: true} after storing encrypted credentials. Pending logins expire in 15 minutes or on restart. Another administrator cannot poll the initiating administrator’s grant. cancel returns {canceled: true} and drops the pending grant. disconnect returns {connected: false}, deletes the instance-wide credential and stops an active subscription model. Connecting does not enable inference. Send subscription_provider: "openai_chatgpt" and the selected model, with endpoint_url and api_key empty. Acknowledge egress, then test/save through the existing settings routes. Subscription requests reject endpoint/API-key overrides; the registered adapter derives authentication and the fixed host. See LLM classifier.

Runtime archive settings

GET /api/admin/settings/preferences returns the effective backup interval and OCR language list. POST to the same path with {backup_interval_hours, ocr_languages} resets the backup scheduler and replaces the language snapshot used by future OCR jobs immediately. GET /api/admin/settings/scan returns the effective {scan_asn_mode,scan_asn_prefix,scan_split_dpi}. When deployment configuration pins any field, the response also includes managed with a boolean for each field. POST accepts all three fields, validates mode off|assign|split, prefix [A-Za-z][A-Za-z0-9._:-]*, and integer DPI 72–600, then replaces the unpinned marker settings used by future PDF processing jobs immediately. A request that changes a pinned value returns 409 scan_setting_managed without writing any field; submitting its effective value allows unpinned fields to change without overwriting the stored value beneath the deployment override. A prefix that collides with an enabled legacy separator token returns 422 apply_failed and restores the prior stored values. GET /api/admin/settings/ingest returns the watched directory, owner, target system, and effective fs_watch_recursive boot flag. POST with {fs_watch_dir,fs_watch_owner_email,fs_watch_system} validates the existing readable directory and that the active owner can enter the target, then replaces the running read-only reconciler before success. Directory/owner are supplied together; omitted system means original system 1. This is global producer configuration, not the browser’s mutable current system. Boot-pinned fields, including INGEST_FS_RECURSIVE, retain configuration precedence.

Email accounts (multi-mailbox intake)

Multi-mailbox intake lives under /api/email-accounts and requires a browser/OIDC session with the mailboxes capability. One row per mailbox: immutable filing system, per-account owner, provider preset, derived auth mode (password or xoauth2), sealed secret at rest, per-account folder, poll cadence, and one provider-neutral intake policy. Edits are picked up by the poller supervisor without a restart. The sealed secret (sealed_secret) is never returned in responses. Representative account row:

GET /api/email-accounts

Requires the mailboxes capability and system entry. Within the selected system, admins see every configured row; members see only rows they own. Response:
Published builds report Microsoft OAuth ready through Suchi’s shipped public client registration. ready=false means the build lacks a usable registration or the deployment override is invalid.

POST /api/email-accounts

Requires the mailboxes capability. The request captures the selected system. Admins may set owner_id to an active user able to enter it; members always create rows owned by themselves. Body: intake_policy.rules contains 1 to 20 rules. Each rule has a selection of all, files, or matching and a content of email_and_files or files_only. Rules are ORed: a message is accepted when any rule matches. When more than one matching rule selects different content, email_and_files wins. A matching rule accepts from, recipients (To or Cc), case-insensitive subject_terms, and case-insensitive attachment_names globs. Populated criteria inside that rule are ANDed; comma- or newline-separated values inside one criterion are ORed. A matching rule requires at least one criterion. all and files rules do not accept matching criteria. The API accepts only the rules shape; the former flat policy shape is not supported. Returns 201 {"id": <int>} on success.

GET /api/email-accounts/{id}

Requires the mailboxes capability. Members can read only their own rows. sealed_secret is omitted.

PATCH /api/email-accounts/{id}

Requires the mailboxes capability. Sparse update — send only the fields you want to change. Members can update only their own rows, cannot reassign ownership, and cannot set tls_ca_file. Provider and authentication method are immutable; create a new mailbox to change providers. Other fields match POST. password is sealed on write; sending an empty password leaves the stored credential untouched.

DELETE /api/email-accounts/{id}

Requires the mailboxes capability. Members can delete only their own rows. Returns 204 No Content.

POST /api/email-accounts/{id}/test

Requires the mailboxes capability. Members can test only their own rows. Dials the row’s host, attempts auth using the stored credential (password or refreshed OAuth token), then logs out. Never 500s on a connect-refused — that’s user data being wrong, not a server bug. The result clears or updates last_error while preserving last_sync_at, because a connection test is not a mailbox poll.

POST /api/email-accounts/{id}/preview

Requires the mailboxes capability. Evaluates the supplied intake_policy against at most 25 recent messages above the mailbox cursor and returns up to five matching envelope samples. Suchi fetches IMAP envelope metadata and BODYSTRUCTURE only; message bodies are not downloaded, stored, or logged.

POST /api/email-accounts/oauth/start

Requires the mailboxes capability. Kicks off a Microsoft device-code flow. Published builds use Suchi’s shipped registration; a deployment may override it through the config file or environment. The server returns 503 no_msal only when no usable registration is available. Body:
Response:
The handle remains valid until the returned expires_at time, capped at 15 minutes. Suchi polls Microsoft in the background, independently of ordinary HTTP request deadlines. The handle binds the authenticated actor and starting ?system=CODE context. Completion against another system fails, even for an administrator; browser switching discards the pending flow/handoff.

POST /api/email-accounts/oauth/complete

Requires the mailboxes capability, the starting actor/system and current entry. Members can complete only against their own account_id in that system. Body:
account_id is optional. When present, suchi seals the fresh MSAL cache into that row’s sealed_secret, confirms its xoauth2 auth method, and enables the mailbox. While Microsoft is still waiting for the user, the endpoint returns 202 Accepted:
Clients should check again after a short delay. A completed flow returns:
When account_id is omitted, Suchi returns an authenticated server-sealed creation handoff bound to {actor_id,system_id,credential}. Submit it to a fresh POST /api/email-accounts in that same actor/system context. Create validates the binding and current membership, then stores the ordinary at-rest Microsoft credential. Copying another mailbox’s at-rest ciphertext is not accepted:

POST /api/email-accounts/{id}/oauth/revoke

Requires the mailboxes capability. Members can revoke only their own rows. Clears the stored sealed_secret, sets enabled=false, and keeps auth_method=xoauth2. The poller stops touching the row immediately; sign in with Microsoft again to replace the credential and re-enable it. Also revoke Microsoft-side at mysignins.microsoft.com to invalidate the refresh token upstream.

Errors

Standard shape when a handler returns JSON:
code is programmatically stable across releases; error is not. Responses with status 500 or 502 use generic messages. Provider, database, filesystem, and cryptographic details stay in the server log with the request ID instead of crossing the API boundary.