> ## Documentation Index
> Fetch the complete documentation index at: https://docs.suchi.page/llms.txt
> Use this file to discover all available pages before exploring further.

# HTTP API

> JSON endpoints under /api/ — the surface mobile clients + agents + integrations drive.

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)

| Group                   | Routes                                                                                                                                                                                |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Health / metrics        | `/healthz`, `/readyz`, `/metrics`                                                                                                                                                     |
| Auth (browser)          | `/login`, `/bootstrap`, `/setup`, `/logout`, OIDC `/oidc/login` `/oidc/callback` `/oidc/debug`                                                                                        |
| Auth (JSON)             | `POST /api/login`, `POST /api/token/`, `GET/POST/DELETE /api/tokens/`, `GET /api/whoami`                                                                                              |
| Documents               | `POST /api/documents/`, `GET/PATCH/DELETE /api/documents/{id}`, `POST /api/documents/{id}/restore`, `GET /api/trash/`                                                                 |
| Doc extras              | `/correspondents/`, `/versions/`, `/custom_fields/{field}`, `/decrypt`, `/decrypt-batch`, `/pending-decryption`                                                                       |
| Taxonomy                | `/api/correspondents/`, `/api/document_types/`, `/api/storage_paths/`, `/api/tags/`, `/api/tags/{id}/parent`, `/api/custom_fields/`, `/api/jd/categories/`                            |
| Classifier rules        | `/api/rules/` CRUD                                                                                                                                                                    |
| Search + autocomplete   | `/api/search/`, `/api/autocomplete/`                                                                                                                                                  |
| Saved views + UI prefs  | `/api/saved_views/` (POST body `{name, filter_json, display, position, shared}`; GET `?include=shared` also returns other users' shared views), `/api/ui_settings/`                   |
| Activity feed           | `GET /api/events/` (cursor over audit\_events; `events:read` scope)                                                                                                                   |
| Dashboard counters      | `GET /api/stats/` (visibility-scoped one-shot)                                                                                                                                        |
| Blob mirrors            | `GET /api/documents/{id}/preview`, `GET /api/documents/{id}/download` — same handlers as `/preview/{id}` and `/download/{id}`, mounted on `/api` so headless deploys keep blob access |
| Mobile compat handshake | `/api/remote_version/`, `/api/next_asn/`, `/api/tasks/`                                                                                                                               |
| Agents                  | `POST /api/tasks/`, `.../claim`, `.../complete`, `.../release`; `/api/agent/webhooks`                                                                                                 |
| Automations             | `/api/automations/` CRUD ([guide](/automations))                                                                                                                                      |
| Approvals               | `/api/approvals/…` ([guide](/approvals))                                                                                                                                              |
| Groups + ACLs           | `/api/groups/…`, `/api/acls/{kind}/{id}` ([guide](/permissions))                                                                                                                      |
| Share links             | `/api/share_links/…`, public `/s/{token}` + `/s/{token}/{doc_id}/download`. Multi-doc bundles: pass 1–200 `doc_ids` on POST, one token covers all of them.                            |
| Admin                   | `/api/admin/users`, `/api/admin/setup/*`, `/api/admin/mail-setup`, `/api/admin/settings/{llm,preferences,ingest}`                                                                     |
| Schema                  | `/api/schema/` (OpenAPI 3.1)                                                                                                                                                          |

## 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 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:

```json theme={null}
{ "token": "<hex>", "email": "you@example.com",
  "display_name": "You (Admin)", "password": "..." }
```

### `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).

```json theme={null}
{
  "kind": "user|token",
  "user_id": 1,
  "email": "you@example.com",
  "display_name": "You (Admin)",
  "role": "admin|member",
  "authn_by": "local-auth|oidc",
  "avatar_url": "/api/users/1/avatar"
}
```

`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:

```json theme={null}
{ "display_name": "Ritesh S." }
```

* `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):

| Param                                | Effect                                                                                                                      |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `page`, `page_size`                  | Standard DRF paging (default 50, cap 200).                                                                                  |
| `ordering`                           | `-created_at` (default), `created_at`, `-updated_at`, `updated_at`, `-title`, `title`. Unknown values fall back to default. |
| `jd_category_id`                     | Exact filing-chip match.                                                                                                    |
| `sensitivity`                        | One of `public`, `internal`, `confidential`, `restricted`.                                                                  |
| `document_type__id`                  | Exact document type.                                                                                                        |
| `tags__id__in`                       | CSV of tag ids; result must carry **every** id (AND).                                                                       |
| `correspondents__id__in`             | CSV of correspondent ids; result matches **any** (OR).                                                                      |
| `q`                                  | Full-text search via FTS5 MATCH over title + content.                                                                       |
| `created_at__gte`, `created_at__lte` | Unix-seconds date range (inclusive).                                                                                        |
| `trashed`                            | `1` or `true` returns only trashed rows; default returns live.                                                              |

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):

```json theme={null}
{
  "count": 42,
  "next": null,
  "previous": null,
  "results": [
    {
      "id":              91,
      "title":           "March electricity bill",
      "mime_type":       "application/pdf",
      "original_size":   16841,
      "jd_category_id":  31,
      "jd_category_code": 31,
      "jd_category_name":"Utilities",
      "jd_area_name":    "Home",
      "sensitivity":     "",
      "thumb_sha":       "ab12...",
      "created_at":      1746662400,
      "updated_at":      1746662400,
      "tags":            ["utilities", "bescom"],
      "correspondents":  ["BESCOM"]
    }
  ]
}
```

`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**

```json theme={null}
{
  "id": 1,
  "title": "Electricity bill March 2026",
  "content": "Electricity bill for March 2026...",
  "original_blob": "sha256hex...",
  "original_size": 16841,
  "archive_blob": "sha256hex...",
  "archive_size": 62114,
  "mime_type": "application/pdf",
  "jd_category_id": 17,
  "jd_category_code": 31,
  "jd_category_name": "Utilities",
  "jd_area_name": "Home",
  "created_at": 1793491200,
  "updated_at": 1793491200,
  "tags": ["utilities", "electricity"],
  "correspondents": [{"id": 3, "name": "BESCOM", "role": "sender"}]
}
```

`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/`](#get-apijdcategories)
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**

```json theme={null}
{
  "count": 4,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 4,
      "code": 22,
      "name": "Tax",
      "description": "annual filings",
      "area_code": 20,
      "area_name": "Money",
      "system": false
    }
  ]
}
```

### `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):

| Kind                                      | Fires when                                        |
| ----------------------------------------- | ------------------------------------------------- |
| `document.create`                         | upload API landed a new doc row                   |
| `document.ingested`                       | post-ingest pipeline finished (OCR + render done) |
| `document.upload.conflict`                | dedup hit on API upload or fs-watch drop          |
| `document.ingest.skipped`                 | ingest producer refused the file (oversize etc.)  |
| `document.update` / `.trash` / `.restore` | metadata mutations                                |
| `document.correspondent.add` / `.remove`  | correspondent link changed                        |
| `document.custom_field.set`               | custom field value set                            |
| `document.decrypt`                        | password-protected PDF unlocked                   |
| `document.version.create`                 | new version of an existing doc uploaded           |
| `approval.task_created`                   | workflow spawned a human-in-the-loop task         |
| `job.dead` (admin only)                   | outbox job exhausted retries                      |
| `backup.written` (admin only)             | VACUUM INTO snapshot landed                       |

**200 OK**

```json theme={null}
{
  "results": [
    {
      "id": 4182,
      "kind": "document.ingested",
      "created_at": 1754441523,
      "doc_id": 91,
      "summary": "Ingested March rent"
    },
    {
      "id": 4183,
      "kind": "job.dead",
      "created_at": 1754441599,
      "summary": "A background job gave up after retries"
    }
  ],
  "latest_id": 4183
}
```

**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**

```json theme={null}
[
  {
    "id": "solo",
    "name": "Solo",
    "description": "Personal filing, no business.",
    "areas": [
      { "code": 10, "name": "Life",  "category_count": 6 },
      { "code": 20, "name": "Money", "category_count": 4 }
    ]
  },
  ...
]
```

`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**

```json theme={null}
{
  "documents_total":   4182,
  "trash_count":       12,
  "inbox_count":       3,
  "inbox_category_id": 49,
  "pending_approvals": 2,
  "dead_jobs":         1,
  "ingested_7d":       57
}
```

`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**

```json theme={null}
{
  "documents": [1, 42, 91],
  "method":    "set_jd_category",
  "parameters": { "jd_category_id": 22 }
}
```

**Methods**

| method                   | parameters                                                                        |
| ------------------------ | --------------------------------------------------------------------------------- |
| `set_correspondent`      | `{correspondent_id}`                                                              |
| `set_document_type`      | `{document_type_id}`                                                              |
| `set_storage_path`       | `{storage_path_id}`                                                               |
| `set_jd_category`        | `{jd_category_id}`                                                                |
| `set_sensitivity`        | `{sensitivity}` — one of `""`, `public`, `internal`, `confidential`, `restricted` |
| `add_tag`                | `{tag_id}` — idempotent (INSERT OR IGNORE)                                        |
| `remove_tag`             | `{tag_id}`                                                                        |
| `trash` (alias `delete`) | `{}`                                                                              |
| `restore`                | `{}`                                                                              |

**Response**

```json theme={null}
{
  "method": "set_jd_category",
  "total":   3,
  "applied": 2,
  "results": [
    {"id": 1,  "ok": true},
    {"id": 42, "ok": true},
    {"id": 91, "ok": false, "code": "forbidden"}
  ]
}
```

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

```json theme={null}
{
  "results": [
    { "id": 91,  "title": "April electricity bill",  "mime_type": "application/pdf",
      "jd_category_id": 31, "created_at": 1746662400, "score": 8.42 },
    { "id": 108, "title": "March electricity bill",  "mime_type": "application/pdf",
      "jd_category_id": 31, "created_at": 1744070400, "score": 7.11 }
  ],
  "method": "fts"
}
```

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](/wishlist/similar-documents)) 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:

| Param     | Default | Purpose                                                                                                                                        |
| --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `state`   | (unset) | Filter to one state: `pending`, `running`, `done`, `dead`. Default hides `done` because a healthy instance drowns the response in completions. |
| `limit`   | `50`    | Max rows to return. Capped at `200`.                                                                                                           |
| `doc_id`  | (unset) | Filter to the jobs for one document.                                                                                                           |
| `kind`    | (unset) | Prefix filter on `kind`. `agent:` returns everything under the agent surface; specific kinds match exact-prefix.                               |
| `include` | (both)  | `jobs` returns only the durable outbox; `workflow` returns only human approvals. Default returns both.                                         |

Response shape:

```json theme={null}
{
  "counts": { "pending": 0, "running": 0, "done": 12, "dead": 1, "workflow_open": 2 },
  "results": [
    { "id": 42, "kind": "post-ingest", "state": "dead",
      "attempts": 5, "doc_id": 17,
      "last_error": "ocrmypdf: page 3 timeout",
      "created_at": 1785779486, "updated_at": 1785779523,
      "next_run_at": 1785779486 }
  ],
  "workflow_tasks": [
    { "id": 9, "run_id": 3, "workflow_id": 1, "workflow_name": "invoice-approval",
      "doc_id": 42, "state_key": "review",
      "assignee": "user:5", "prompt": "Approve invoice?", "title": "Approve invoice?",
      "choices": ["approve","reject"], "status": "open",
      "deadline_at": 1785782400, "created_at": 1785779486 }
  ]
}
```

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](/agents) 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](/automations).

| Method | Path                    | Auth   |
| ------ | ----------------------- | ------ |
| GET    | `/api/automations/`     | authed |
| POST   | `/api/automations/`     | admin  |
| GET    | `/api/automations/{id}` | authed |
| PATCH  | `/api/automations/{id}` | admin  |
| DELETE | `/api/automations/{id}` | admin  |

### Approvals surface: `/api/approvals/*`

State-machine engine for human-in-the-loop review chains. Six
endpoints:

| Method | Path                                     | Auth              |
| ------ | ---------------------------------------- | ----------------- |
| POST   | `/api/approvals`                         | admin             |
| GET    | `/api/approvals/{slug}`                  | authed            |
| POST   | `/api/approvals/{slug}/start`            | authed            |
| GET    | `/api/approvals/runs/{id}`               | authed            |
| POST   | `/api/approvals/tasks/{task_id}/resolve` | assignee or admin |
| POST   | `/api/approvals/runs/{id}/cancel`        | admin             |

See the [approvals guide](/approvals) 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](/permissions).

| Method | Path                                                | Auth                               |
| ------ | --------------------------------------------------- | ---------------------------------- |
| GET    | `/api/groups/`                                      | authed                             |
| POST   | `/api/groups/`                                      | admin                              |
| GET    | `/api/groups/{id}`                                  | authed                             |
| PATCH  | `/api/groups/{id}`                                  | admin                              |
| DELETE | `/api/groups/{id}`                                  | admin (refuses while grants exist) |
| GET    | `/api/groups/{id}/members`                          | authed                             |
| POST   | `/api/groups/{id}/members`                          | admin                              |
| DELETE | `/api/groups/{id}/members/{uid}`                    | admin                              |
| GET    | `/api/acls/{kind}/{id}`                             | authed                             |
| PUT    | `/api/acls/{kind}/{id}`                             | admin                              |
| DELETE | `/api/acls/{kind}/{id}?principal_kind&principal_id` | admin                              |

`{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**

```json theme={null}
{
  "doc_ids":    [42, 91, 108],
  "label":      "Everything for the accountant",
  "password":   "optional",
  "expires_at": 1785782400
}
```

* `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:

```json theme={null}
{
  "results": [
    { "id": 42, "name": "HDFC Bank",    "role": "sender" },
    { "id": 87, "name": "Alice Sharma", "role": "recipient" }
  ]
}
```

### `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:**

```json theme={null}
{
  "doc_ids": [42, 43, 44],
  "password": "hunter2",
  "remember": true,
  "label": "Bank X 2026"
}
```

**200 OK**

```json theme={null}
{
  "results": [
    {"doc_id": 42, "ok": true},
    {"doc_id": 43, "ok": true},
    {"doc_id": 44, "ok": false, "reason": "bad_password"}
  ]
}
```

`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**

```json theme={null}
{
  "results": [
    { "id": 42, "title": "statement.pdf",
      "original_blob": "sha256hex...", "original_size": 65432,
      "created_at": 1785783054, "mime_type": "application/pdf" }
  ]
}
```

### `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`:

| data\_type           | Accepted `value` shape                                                 |
| -------------------- | ---------------------------------------------------------------------- |
| `text`, `url`        | string                                                                 |
| `number`, `monetary` | number OR numeric string                                               |
| `date`               | ISO string `"YYYY-MM-DD"` OR unix seconds                              |
| `bool`               | boolean OR any of `"yes"`/`"no"`/`"1"`/`"0"`                           |
| `select`             | string; must appear in the field's `extra_data.choices`                |
| `multi`              | `["a","b"]` OR comma-separated string; each element must be in choices |
| `documentlink`       | integer document id; refuses trashed/missing targets                   |

`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 {id} belongs to, oldest → newest.
{id} can be any node — root, middle, head. `is_head=true` marks the
latest non-trashed version.

```json theme={null}
{
  "results": [
    { "id": 5, "title": "Lease 2024",
      "sha256": "...", "size": 12345, "mime_type": "application/pdf",
      "created_at": 1785783054, "is_head": false },
    { "id": 8, "title": "Lease 2026",
      "sha256": "...", "size": 12890, "mime_type": "application/pdf",
      "created_at": 1785783100,
      "previous_version_id": 5, "is_head": true }
  ]
}
```

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

Upload a new version of {id}. Multipart, same shape as
`POST /api/documents/`. Result row's `previous_version_id` = {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:

| Param                          | Purpose                                    |
| ------------------------------ | ------------------------------------------ |
| `parent_id=<int>`              | Only children of this tag                  |
| `parent_id=null`               | Only roots (tags with no parent)           |
| `page=<n>`, `page_size=<n>`    | Standard pagination (default 100, max 500) |
| `ordering=[-]name\|created_at` | Sort key; `-` prefix for descending        |
| (none)                         | Every tag                                  |

Response is the standard **pagination envelope**:

```json theme={null}
{
  "count": 12,
  "next": "/api/tags/?page=2&ordering=-name",
  "previous": "",
  "results": [ /* TagView objects */ ]
}
```

`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:

```json theme={null}
{
  "imap_host": "imap.fastmail.com",
  "imap_port": 993,
  "username": "you@fastmail.com",
  "folder": "INBOX",
  "poll_interval_min": 10,
  "owner_email": "you@fastmail.com"
}
```

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:

```json theme={null}
{
  "saved": true,
  "restart_required": true,
  "restart_hint": "settings saved; restart suchi to pick up the new mail intake config"
}
```

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:

```json theme={null}
{ "ok": true,  "message": "Connected — mailbox reachable." }
{ "ok": false, "message": "login: 535 Authentication failed" }
```

### `GET /api/rules/`

The deterministic classifier's rulebook. See [config](/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_contains`
* `then_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:

```json theme={null}
{ "error": "human message", "code": "snake_case_kind" }
```

`code` is programmatically stable across releases; `error` is not.
