GET /api/schema/— OpenAPI 3.1 (unauthenticated; documents the surface but every operation still enforces its own auth).
/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. - Token —
Authorization: Token <hex>header. suchi’s canonical scheme for third-party mobile clients. - Bearer —
Authorization: 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 withBearerscheme 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 underPOST /login).
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 with400 {"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"}.
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/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. 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 Conflictwith the existing id in the body. - Hash matches only a trashed doc → clears
trashed_at, returns200 OKwith"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=22and?q=taxboth match22 Tax.?area=<code>— scope to one area’s 10-slot range.?area=20returns categories 20–29.
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 withid > since_id. First call omits it; 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. The server always reads at most 2× this before visibility filtering, so a very restrictive ACL setup withlimit=100still returns quickly.
- 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.deadgets 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 withevents:read.
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 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
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.
{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.
-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
pdftoppmon PATH (slim Docker image; the pipeline logs the skip at info). - The doc predates migration
0025_document_thumbnailsand hasn’t been re-ingested.
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:
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.
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.
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. Whentrue, the password is AEAD-sealed with the server’s.decrypt-keyand stored indecryption_passwordsfor 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.
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— no encrypted doc with that id, or not yours503 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:
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:
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:
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_kind∈tag | correspondent | document_type | title_contains | content_containsthen_kind∈add_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.