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 tokenAuthorization: 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/cmd/suchi/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 at the assembly boundary.

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.13.147 returns {id,system_code,jd_address} only for the authorized live document’s current system/category. Invalid address syntax or ID overflow is 400 bad_address; unknown, stale or inaccessible locations are 404. .ID is the existing positive installation-global document ID, not an authored/category-local counter. Document projections and upload receipts expose system_code and jd_address after introduction; category system_code is distinct from its protected system boolean. Numeric API IDs do not change. 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,"min_app_version":"0.1.0","pairing_device_name":true} Clients must require product to equal suchi, require an API version they support, and compare min_app_version as numeric semantic-version components. Redirects are not compatibility responses and must not receive credentials. pairing_device_name: true advertises support for naming a connection during code exchange. Clients omit device_name unless this flag is true; older servers may reject unknown request fields.

Authentication and accounts

POST /api/login

Local-password browser login. Send a JSON body; success plants a session cookie and returns 204. API clients should exchange the same credentials 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 fields and redirects after setting the same kind of session cookie. 401 on bad credentials. One error path covers every failure mode. 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

OIDC login flow. Only registered when OIDC is configured. State token is a signed cookie; the callback plants a session cookie via local-auth’s IssueSession (one code path mints all cookies).

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. scopes copies the authenticated API token’s granted scopes. Cookie/OIDC sessions return []; clients must not infer token grants from user role or capabilities.

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 mobile app accepts HTTPS, or HTTP only for localhost, loopback literal IPs, RFC1918 IPv4, and unique-local IPv6. 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 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.

PATCH /api/users/me

Update the caller’s profile. Body accepts display_name only in the current pass:
  • display_name — trimmed, 1–120 chars.
  • email — refused with 400 {"code": "email_change_unsupported"}. Email is the local-auth identifier; changing it needs a password challenge + a dedicated flow (not yet wired).
  • Empty body → 400 {"code": "no_fields"}.
Successful writes emit an audit event (user.display_name_changed, old→new) and return the fresh UserSelf shape.

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. 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 writes also occur there: successfully stored records follow committed transition order. Audit persistence remains best-effort; failed audit writes are logged without rejecting an otherwise valid update. Rejected patches and cascade failures leave the user and resources unchanged; cascade failures return 500. 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, audit failure reports, external audit delivery and runtime reloads happen after commit.

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, 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; an invalid language token returns 400 bad_lang. 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):
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 QR-split child; split_index is its one-based part number. Unlike split_parent_id, the origin survives later hard deletion of the retired parent. 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. 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, extracted content (post-OCR), MIME, blobs, JD category, tags (slug list), correspondents. 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. 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. added_at is when Suchi ingested the document; created_at and optional source_mtime carry upstream dates. 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 — future automatic detection won’t overwrite 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. Empty lists always serialize as [] (never null) so mobile clients can iterate them safely.

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 immutable 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, max-age=31536000, immutable. 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 versions in the history containing {id}, ordered by creation time then ID, oldest first. {id} can be any node: root, middle or head. A history can branch. is_head=true means the node has no visible, live direct child; multiple nodes can be heads, and a trashed node can still be marked as a head.
Each version node is checked against its current document ACL. A caller must be able to view the requested node, and the response omits any other chain node it cannot view. Relationship ids and head state are computed only from the visible subset, so a later ACL change cannot leak hidden version metadata.

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

Upload a new version with previous_version_id={id}. Uses the document upload multipart, device OCR, source timestamp and idempotency rules. The new row keeps its predecessor’s system, owner, JD category, sensitivity and explicit document ACL grants, plus its title if the uploader gives a bland filename. A conflicting requested system fails. 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.
  • 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. Future 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 and deletes_at, the Unix timestamp 30 days later.

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, clears 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. Response shape{count, next, previous, results} DRF envelope. Each hit carries {id, title, snippet, rank, created_at, mime_type, sensitivity}. 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, document_type__id, 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, correspondents, and document types 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 setup/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-only. Returns 204 No Content on success, 404 not_found otherwise. document_tags rows cascade via ON DELETE CASCADE; children of the deleted tag re-root via ON DELETE SET NULL (the nested-tag migration set that up so a deleted parent doesn’t orphan its subtree).

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 Architecture.

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.

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 and /api/custom_fields/ do not return them, and there is no dedicated value read endpoint. /api/custom_fields/ lists field definitions. 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. Future value reads must recheck target visibility and redact inaccessible links; that read capability is not implemented. Body: {"value": <T>} where <T> matches the field’s data_type: url validates as http/https. Empty value ⇒ row is deleted. Returns 204 No Content on success, 400 with a code on validation failure, and 404 if the document is trashed, inaccessible, or missing, or the field is missing. A successful write enqueues a storage-path re-render if the field is referenced in the template.

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.

GET /api/presets/

Admin-only preset catalog for the setup wizard. Returns one row per built-in preset defined in core/jd/presets.go; the wizard renders the area list as a tree preview so an operator can see what a preset actually contains before applying. No envelope — the population is bounded (five presets today). 200 OK
blank: true marks the empty preset (no areas), which the wizard gates behind an explicit confirm checkbox. Categories aren’t included per row — they’d bloat the payload, and the wizard doesn’t render them in the picker step. Once a preset is applied via POST /api/admin/setup/preset, the tree becomes visible through /api/jd/categories/.

POST /api/admin/taxonomy/import

Session-admin-only, with existing same-origin protections; API tokens cannot administer taxonomy. Accepts strict suchi-taxonomy/v1 HuML or TOML and previews or atomically applies it. Taxonomy content is bounded to 1 MiB; invalid input is rejected before mutation, including when skip_seeds is true. Request body
format is "huml" or "toml"; omitted/empty means HuML, never content sniffing. UI callers send it explicitly. apply=false returns the preview. Apply sends the same content, format, seed choice 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 existing rule names, disabled originals and user-owned forks. A skipped category skips the entire dependent starter. Destination introduction/creation, membership preservation, symbol resolution, supported 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 HuML or TOML: ?format=huml|toml&system=S01 (default HuML and original system). Add skip_seeds=true for tree-only output, omitting keywords/starters. Attachment filenames are <SYS>.taxonomy.huml|toml after introduction and archive.huml|toml 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. Default export preserves only representable preset-owned starters; disabled/unrepresentable rules fail rather than being enabled or silently lost. Legacy incompatible reserved structure also fails without automatic repair. User-owned rules/forks, 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 docs created in the last 7 days — useful for a sparkline; not a filing-date metric.

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. Audit rows are pruned on the backup ticker per AUDIT_RETENTION_DAYS (default 20, cap 100). since_id stays valid across purges — IDs only grow — but a client that comes back after the 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 via a SIEM 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 to resolve an approval; browser sessions continue to use the signed-in user’s access. 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 and the optional LLM classifier also appear in approval_tasks. Their approval_name is document-change; the vars object carries the suggested field, value, confidence, source, and supporting document IDs. Suggestions are field-specific: a document can be filed under a category while a lower-confidence tag, correspondent, title, or document-type suggestion still waits for review.

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 + 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.

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|document_type|storage_path. 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.
  • password: optional, up to 1024 bytes. It is Argon2id-hashed at rest.
  • expires_in_sec: seconds from creation, from 0 (no expiry) through one year.
Response carries the fresh token, the absolute public_url resolved from PUBLIC_URL, and the shared_by + instance_host 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.
  • 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>, 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, 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). 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. Onboarding timestamps and intent are archive-wide; intent is stored locally and only recommends a filing tree. current_preset 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. recommended_preset derives from the archive-wide intent. Absent timestamps and empty strings are omitted.

POST /api/admin/setup/complete

Admin-only. Requires an explicitly chosen filing tree in the selected ?system=Snn; an untouched Inbox-only bootstrap returns 409 filing_tree_required. Explicitly choosing Blank counts as a choice. Success stamps the archive-wide onboarding completion timestamp and returns 204.

POST /api/admin/setup/intent

Admin-only. Body: {"intent":"personal|household|freelance|small_business|custom"}. The response returns the selected intent and mapped preset (solo, household, freelance, smb_billing, or blank for custom). It neither applies a tree nor hides setup capabilities.

Archive research

Chat 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, document type, tag IDs, correspondent IDs, inclusive created-time bounds, and language fields. Every source and context ID is reauthorized with fresh ACL, trash, sensitivity, and scope checks. The response contains at most six sources, structurally validated citations, grounding state, and complete extracted-fact counts. Suchi adds missing inline markers from the provider’s structured citation list. Provider evidence is bounded to 300 Unicode characters per title and the active 1,600–4,800 Unicode-character Research context preset per snippet, plus three approved facts per source and twelve facts total:
No evidence returns the stable no-evidence answer without a provider call. Insufficient evidence, including an answer with no source numbers, returns the stable insufficient answer. Malformed or out-of-range citations return 502 invalid_provider_response; provider failures return 502 provider_failure; a disabled runtime returns 503 model_unavailable; the per-user rate or retrieval/provider concurrency gates return 429 chat_rate_limited with Retry-After. Q&A is read-only. Saving the retrieved 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. It may be set automatically by the configured model threshold or by a person’s decision in Approvals; reviewed_by is present only for the latter. Stats responses expose the pending extracted-fact count as pending_intelligence. Browser document-list filters use jd_category_id, document_type__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. 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. The current confidence threshold and date_auto_apply setting decide whether each extracted date enters Calendar or stays pending.

POST /api/intelligence/resolve

decision is accepted or rejected. Only pending, authorized candidates change. The bounded transaction returns granular not_found, forbidden, and already_resolved outcomes.

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.
research_context_mode is focused, balanced, or detailed. Missing or invalid stored values read back as balanced.

POST /api/admin/settings/llm

Body: {enabled, endpoint_url, model, api_key, clear_api_key, egress_ack, confidence_threshold, date_auto_apply, archive_enabled, archive_auto_threshold, archive_review_threshold}. The model threshold must be between 0.50 and 0.95. When date_auto_apply is true, extracted dates at or above that threshold enter Calendar; other dates remain pending for Approvals. Saving an enabled automatic-date configuration also promotes matching pending dates without another model call. Turning it off affects new extraction and does not remove existing Calendar dates. The archive review threshold must be 0.50 to 0.90; the archive auto-apply threshold must be 0.55 to 0.95 and greater than review. Enabling requires an HTTP(S) endpoint and model. Non-local endpoints 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 only the global Archive research source-text preset:
The body must contain exactly research_context_mode, with a value of focused, balanced, or detailed. The response returns the saved value:
This standalone save applies to the next question. It does not rewrite the endpoint, model, API key, egress acknowledgement, confidence thresholds, or date settings; enable a model; auto-apply pending dates; or reload the provider. See Archive research: Research context presets for passage 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.

Runtime setup 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/ingest returns the watched directory, owner and target system. POST with {fs_watch_dir,fs_watch_owner_email,fs_watch_system} validates the active owner can enter the target, stops the old watcher, and starts the replacement 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 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 pre-v0.1 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.