Skip to main content
Roughly 135 routes across documents, taxonomy, search, share links, agents, automations, approvals, groups + ACLs, MCP, and mobile compat. The prose below covers everything with non-obvious semantics; the authoritative machine-readable list lives at:
  • GET /api/schema/ — OpenAPI 3.1 (unauthenticated; documents the surface but every operation still enforces its own auth).
All endpoints under /api/ return JSON. Everything else is either a UI page (HTML), a static asset, or a blob stream. Every request gets an X-Request-Id header — carry it into bug reports.

Endpoint index (compact)

Auth

Two credential shapes, both routed through the same auth chain:
  • Cookie — set by POST /login (form-encoded) or the OIDC callback. HttpOnly + SameSite=Lax + Secure when TLS is in use.
  • TokenAuthorization: Token <hex> header. suchi’s canonical scheme for third-party mobile clients.
  • BearerAuthorization: Bearer <hex> also accepted for the same API token, because many HTTP clients default to Bearer. Ambiguity vs OIDC bearer tokens is resolved by shape: suchi tokens are exactly 64 lowercase hex chars; anything else with Bearer scheme falls through to the OIDC authenticator.

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

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. Includes:
  • 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.

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:

POST /api/login

Local-password login. Content-Type decides the response shape:
  • application/json → returns {"token": "<hex>"} (an API token). This is the mobile-compat path.
  • application/x-www-form-urlencoded → plants a session cookie and returns 204 (the browser path — served under POST /login).
401 on bad credentials. One error path for every failure mode — no username/password oracle.

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).
display_name + avatar_url are omitted when unset. 401 if anonymous.

PATCH /api/users/me

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

POST /api/users/me/avatar

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

GET /api/users/{id}/avatar

Serves any user’s avatar as image/png. Immutable ETag (users.avatar_sha) + Cache-Control: max-age=31536000, immutable — a new upload rotates the sha and invalidates every cached client automatically. Returns 404 if unset.

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): Non-admin callers get the same ACL fragment the detail + patch endpoints use — a member sees only owned docs plus explicit grants (direct or via group). Admins bypass. Row shape (slimmer than GET /{id}; no content field — request detail for that):
thumb_sha non-empty means the row has a thumbnail at /api/documents/{id}/thumb (client can render immediately without a HEAD probe).

POST /api/documents/

Multipart upload. Streams the file into the CAS by SHA-256; the document row lands in one write transaction alongside the blob write. MIME is sniffed server-side — the multipart Content-Type header is a hint only. 201 Created { "id": ..., "sha256": "...", "size": ..., "mime_type": ..., "title": "..." } Dedup state machine:
  • Hash matches an existing live doc → 409 Conflict with the existing id in the body.
  • Hash matches only a trashed doc → clears trashed_at, returns 200 OK with "restored": true.
  • Fresh hash → new row, 201 Created.

GET /api/documents/{id}

Full projection of one document: title, extracted content (post-OCR), MIME, blobs, JD category, tags (slug list), correspondents. Trashed docs are visible with trashed_at set so mobile clients can render the undelete flow. 200 OK
jd_category_code / jd_category_name / jd_area_name are denormalized from the JD tree so mobile clients + agents 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). Empty lists always serialize as [] (never null) so mobile clients can iterate them safely.

GET /api/jd/categories/

Read-only listing of every category in the current JD tree, with area_code + area_name denormalized onto each row so a picker can render a grouped tree in one request. Used by mobile pickers, the SPA’s file-under dropdown, MCP tools that need to resolve jd_category_id, and any agent enumerating the taxonomy. Query params (both optional):
  • ?q=<prefix> — case-insensitive prefix match on the category code OR name. ?q=22 and ?q=tax both match 22 Tax.
  • ?area=<code> — scope to one area’s 10-slot range. ?area=20 returns categories 20–29.
DRF envelope. Read-only — creation happens via preset swap (POST /api/admin/setup/jd-preset), not per-row insert. 200 OK

GET /api/events/

Cursor-based activity feed over the audit log. The SPA’s notification drawer, and any agent that wants a change stream, reads this instead of polling /api/documents/ and /api/tasks/ on a timer. Scope: events:read (new; browser sessions have it implicitly, legacy read tokens inherit it via the wildcard rule). Query params (all optional):
  • ?since_id=<id> — return rows with id > since_id. First call omits it; every subsequent call passes the previous response’s latest_id. audit_events.id is a strictly-increasing INTEGER PK.
  • ?kinds=a,b,c — comma-separated allow-list. Unknown kinds silently return nothing (no error), so a client rolling out a new kind name never breaks on an older server.
  • ?limit=<n> — 1–200, default 100. The server always reads at most 2× this before visibility filtering, so a very restrictive ACL setup with limit=100 still returns quickly.
Visibility rules (enforced server-side, not documentation-only):
  • Rows with object_kind="document" are filtered through the same ACL fragment the list endpoints apply. A member never sees an event about a doc they can’t read.
  • Operational kinds (job.dead, backup.written) require admin. A non-admin who asks for ?kinds=job.dead gets an empty result set (no 403 — the drawer keeps working).
  • Every other kind (approval.task_created, share_link.*, etc.) is visible to any authed caller with events:read.
Summary rendering. summary is a pre-rendered one-liner (“Ingested March rent”) so clients don’t need a title lookup per row. The privacy invariant applies — summaries carry titles and kinds only, never document content or OCR text. Kinds currently emitted (non-exhaustive; new emit points land in new server versions and appear here as they arrive): 200 OK
Read/unread state is a client concern — save latest_id locally (the SPA uses PUT /api/ui_settings/ with {events: {last_seen_id}} so it follows across devices), pass it back as since_id on the next call. Feed horizon. Audit rows are pruned on the backup ticker per AUDIT_RETENTION_DAYS (default 20, cap 100). since_id stays valid across purges — IDs only grow — but a client that comes back after the retention window has elapsed sees a clean empty diff, not “the history it missed.” This feed is a near-term change stream, not a permanent record. Long-horizon consumers should read audit_events via a SIEM sink instead.

GET /api/jd/presets/

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

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 current JD inbox pointer so a client can navigate to #/documents?jd=49 without name-matching. dead_jobs is admin-only (members see 0); pending_approvals scopes to the caller’s own assignee for members and to the full open queue for admins. ingested_7d counts live docs created in the last 7 days — useful for a sparkline; not a filing-date metric.

POST /api/documents/bulk_edit

Batch metadata mutation across N documents in one write transaction. The SPA multi-select actions (bulk refile, bulk trash) call this instead of looping N PATCHes. Scope: documents:write. Cap: 500 documents per call. Request
Methods Response
Behavior
  • Every id is ACL-checked per the same rule as PATCH /api/documents/{id}. Refused ids appear in the array with ok: false, code: "forbidden"; the rest still run.
  • One documents.bulk_edit audit event per call (with method + total + applied), not per doc — the granular outcome lives in the response array.
  • All authorized ids run inside a single WriteTx. A per-id error inside the tx aborts the whole batch by design — partial writes would leave the archive in a half-state.
The {documents, method, parameters} shape matches the mobile-compat DMS wire vocabulary suchi already speaks — existing clients hitting this URL with a familiar body work unchanged.

GET /api/documents/{id}/similar

“Documents like this” — pure-Go FTS5 more-like-this backed by the existing full-text index. Extracts the top-10 tokens from the source doc’s title + first ~4 KiB of content, runs them as an FTS5 MATCH query, ranks by BM25, and returns the top N. Scope: documents:read. Visibility-scoped identically to the list surface; the source doc is always excluded from its own results.
Score is -bm25(documents_fts) so higher = more similar (matches the wire convention other suchi score fields already use). ?limit=N takes 1–50, default 10. method is "fts" today. When sqlite-vec lands (see the wishlist plan) semantic recall takes over and this field flips to "vec"; the FTS5 path stays as the always-on fallback for instances without the vec extension loaded. Quality note: this is coarse vocabulary overlap, not semantic similarity. A receipt matches other receipts by shared words (“total”, “amount”, “date”) — good enough as a starter affordance, better once vec0 stacks on top.

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. PNG bytes; immutable ETag equal to documents.thumb_sha. Serve with Cache-Control: private, max-age=31536000, immutable so the SPA’s list view can request one URL per doc and every subsequent scroll is a 304. Scope: documents:read. ACL rule matches GET /api/documents/{id}. Returns 404 when:
  • The doc has no archive PDF (image originals, EPUB, .msg, etc.).
  • The instance ran the ingest without pdftoppm on PATH (slim Docker image; the pipeline logs the skip at info).
  • The doc predates migration 0025_document_thumbnails and hasn’t been re-ingested.
The SPA treats a 404 as “no thumbnail” and falls back to its title-initials placeholder — no error state on the row.

DELETE /api/documents/{id}

Sets trashed_at. The blob stays in the CAS — suchi gc reclaims it later. 204 No Content on success, 404 if the doc isn’t live.

POST /api/documents/{id}/restore

Clears trashed_at. Idempotent — restoring a live doc returns 200 OK with {"affected": 0}.

GET /api/tasks/

Read the durable-outbox queue: what suchi is doing right now, what crashed, what’s queued. The same table the Phase-4 mobile-compat tasks endpoint reads. Query parameters: Response shape:
Timestamps are unix seconds. The Phase-4 mobile-compat shim converts to ISO8601 there — this endpoint keeps the suchi convention. 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. Resolve one via POST /api/approvals/tasks/{task_id}/resolve. workflow_open in counts is the true open-inbox size, unaffected by limit. Older mobile clients that ignore unknown JSON fields see the classic counts + results shape unchanged. suchi-native clients read both arrays.

Agent surface: POST /api/tasks/, .../claim, .../complete, .../release

External processes claim + complete agent:* jobs via task-claim/act. See the agents guide for the full contract, the reference Python agent, and the failure-mode table.

Automations surface: /api/automations/*

Trigger→conditions→actions rules that fire on document events. Full guide at automations.

Approvals surface: /api/approvals/*

State-machine engine for human-in-the-loop review chains. Six endpoints: See the approvals guide for the spec shape, error codes, timeout behavior, and a worked invoice-approval example.

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

Phase 6. Named user collections plus polymorphic per-object grants. Full guide at permissions. {kind} is one of document|tag|correspondent|document_type|storage_path. Perm bits are view=1, change=2, delete=4 — OR them for combinations.

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. Request
  • doc_ids: 1–200; every id must be visible to the caller (owner or ACL-granted).
  • label: short human string shown on the landing page.
  • password: optional. Argon2id-hashed at rest; verified via ?password= on the public paths (rate-limited).
  • expires_at: unix seconds; omit for no expiry.
Response carries the fresh token + the resolved public_url (/s/<token>). Public paths (unauthenticated, rate-limited):
  • GET /s/{token} — bundle metadata: {label, requires_password?, docs:[{id, title, mime, size, download}]}.
  • GET /s/{token}/{doc_id}/download — streaming download of one doc from the bundle. Both paths verify the token and the optional ?password= server-side.
Revoke via DELETE /api/share_links/{id} — the token becomes 404 immediately (no soft delete).

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. role=sender also mirrors to documents.correspondent_id when no primary is set — the single- correspondent code paths (list column, existing rules) still find the sender.

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

Remove one junction row. 204 on success.

POST /api/documents/{id}/decrypt

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

POST /api/documents/decrypt-batch

Apply one password to N docs. Useful for monthly bank statements where every account shares a password. Body:
200 OK
remember=true fires ONCE per batch and only when at least one doc succeeded — no point sealing a password that unlocked nothing.

GET /api/documents/pending-decryption

List of docs awaiting a password. Owner-scoped for non-admins. 200 OK

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

Write a typed custom-field value. {field} accepts either the numeric field id OR the field name. Body: {"value": <T>} where <T> matches the field’s data_type: url validates as http/https. Empty value ⇒ row is deleted. Returns 204 No Content on success, 400 with a code on validation failure, 403 if the doc isn’t yours, 404 if either the doc or the field doesn’t exist. A successful write enqueues a storage-path re-render if the field is referenced in the template.

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

Remove the value row for one (doc, field) pair. 204 on success.

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

Every version in the chain that belongs to, oldest → newest. can be any node — root, middle, head. is_head=true marks the latest non-trashed version.

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

Upload a new version of . Multipart, same shape as POST /api/documents/. Result row’s previous_version_id = . Default carry-forward: title (if uploader gave a bland filename) and JD category (a filed doc’s version stays filed).

GET /api/tags/

Read tags 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 slugFromName(name); color defaults to #a6cee3 at the DB level. Returns 201 {"id": <int>} on success, 409 conflict when the name or slug already exists.

PATCH /api/tags/{id}

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

DELETE /api/tags/{id}

Admin-only. Returns 204 No Content on success, 404 not_found otherwise. document_tags rows cascade via ON DELETE CASCADE; children of the deleted tag re-root via ON DELETE SET NULL (the nested-tag migration set that up so a deleted parent doesn’t orphan its subtree).

PATCH /api/tags/{id}/parent

Admin-only. Body: {"parent_id": <int-or-null>}. Rejects self-parent (400 self_parent) and cycles (400 cycle). See architecture doc for the “no inheritance in search” call.

GET /api/admin/settings/mail

Admin-only. Read the persisted mail-intake config the emailwatch poller uses. Response shape:
Password is write-only — it never appears in the GET response. A zero-value response (empty strings + defaults) means “not configured yet”; the SPA renders the form pre-filled with those defaults.

PUT /api/admin/settings/mail

Admin-only. Persist mail-intake settings. Body accepts every field GET returns, plus password. Leaving password blank keeps the stored one — the SPA relies on this so edits don’t require re-typing the credential. Response:
Live reload isn’t wired — the emailwatch goroutine is captured at boot with a URL. The SPA surfaces the restart hint.

POST /api/admin/settings/mail/test

Admin-only. Dial the configured IMAP host and attempt LOGIN. Body is optional; when empty, tests the stored credentials. Never 500s on a connect-refused — that’s user data being wrong, not a server bug. Response:

GET /api/rules/

The deterministic classifier’s rulebook. See config for env-vars that seed rules at import time; rules can also be authored via this API. Query params: page, page_size (default 100, max 500), ordering=[-]priority|name|created_at. Response is the standard pagination envelope (see /api/tags/ for the shape).
  • Body shape: {if_kind, if_value, then_kind, then_value, priority, enabled}
  • if_kindtag | correspondent | document_type | title_contains | content_contains
  • then_kindadd_tag | set_correspondent | set_document_type | set_jd_category
POST /api/rules/, PATCH /api/rules/{id}, DELETE /api/rules/{id} — all admin-only.

GET /oidc/login / GET /oidc/callback

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

Mobile-client compat (Phase 4)

The endpoints below are planned for Phase 4. suchi ships a stable mobile wire surface so existing third-party DMS mobile clients can drive it without a bespoke app.
  • POST /api/token/
  • GET/POST /api/documents/, GET /api/documents/{id}/
  • GET /api/documents/{id}/thumb/, preview/, download/?original=
  • GET/POST /api/tags/, correspondents/, document_types/, storage_paths/, custom_fields/, saved_views/
  • GET /api/tasks/, share_links/, trash/
  • GET /api/search/, autocomplete/
  • GET /api/schema/ (OpenAPI)

Errors

Standard shape when a handler returns JSON:
code is programmatically stable across releases; error is not.