/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 /loginform, 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 whenPUBLIC_URLuses 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.
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-v1while 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-changetitle, 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. Updatecore/api/handshake.go, the exact response assertion inhandshake_test.go, and the fixture registry inmobile_contract_fixtures_test.go; check them withgo test ./core/api -run 'TestGetHandshake|TestMobileContractFixturesMatchWireTypes'. - Release a Companion that understands the new profile before withdrawing v1.
Remove v1 from
mobile_contractsonly 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.
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 usePOST /api/users/me/email;oidc— the configured provider owns the email and the account may usePOST /oidc/email-change; anddisabled— tokens, demo identities, and the fixed development account have no email mutation path.
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, rejectAuthorization 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:
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:
{"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:
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/pngandimage/jpegaccepted (a.pngfilename over plain text is refused). - Size cap 2 MiB; dimensions cap 2048×2048 (checked via
image.DecodeConfigbefore 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_shagets the fresh SHA-256.
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.
GET /api/admin/users includes two_factor_enabled for each row. A live local
administrator session may reset another enabled user:
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.
modeis unauthenticated — the SPA calls it on boot to decide whether to render the demo banner. Cache-safe.sessionis 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 inX-Suchi-Demo-Token.session/upgraderequires a valid anonymous token. Provisions a scratch uservisitor-<hex>@demo.localand 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 constraineddemo-scratchidentity for both API calls and direct preview/download URLs. Upgrade clients must retain response cookies; no API token is returned.
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, returns200 OKwith"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
pdftoppmon PATH; the pipeline logged the skip. - The doc was ingested before thumbnails shipped and hasn’t been re-rendered.
/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.
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
Response
- Every id is ACL-checked per the same rule as
PATCH /api/documents/{id}. Refused ids appear in the array withok: false, code: "forbidden"; the rest still run. - One
documents.bulk_editaudit event per call (withmethod+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, andremove_tagcalls synchronously rundocument_updatedautomations 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. restoreenforces the same 30-day window as individual restore. An expired authorized document rejects the batch with400 bad_parameters; already-live documents are unchanged.
{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. Whentrue, the password is AEAD-sealed with the server’s.decrypt-keyand stored indecryption_passwordsfor 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.
decrypted_blob, flips state to 'decrypted', re-enqueues post-ingest.
Returns 204 No Content.
Failure modes:
400 bad_password— qpdf rejected the password400 empty_password— body missing the field404 not_found— document missing, trashed, not encrypted, or unavailable to change503 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:
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
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=22and?q=taxboth match22 Tax.?area=<code>— scope to one area’s 10-slot range.?area=20returns categories 20–29.
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:
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:
/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).
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:
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.
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 withid > since_id. First call omits it and receives the newest visible tail in chronological order; every subsequent call passes the previous response’slatest_id.audit_events.idis 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 advancelatest_id. Keep a separate cursor for each captured system.
- 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>orrole:<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.writtenare 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 inaudit_eventsfor the durable trail; read the table directly (or a SIEM sink) if you need them.
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
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:
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, immutablenetworkorpublic. 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 use0for no expiry. Public links require a non-zero expiry, and creation returns409 public_sharing_unavailablewhile the share listener is disabled.
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 theAcceptheader. 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 onlyShared from <instance host>. Programmatic callers that acceptapplication/jsonget{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 toPath=/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 inX-Suchi-Share-Password; browsers use the unlock cookie above. Passwords are not accepted in URLs, where they can leak into history and proxy logs.
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
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:
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
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:
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.
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:
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:
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:
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.