Skip to content

HTTP API

The endpoints below exist in docbank daemon run and back the CLI's data commands — the CLI is an HTTP client of exactly this surface, with no other path into the vault.

Design test: an agent must be able to do everything the CLI can, through the API alone. Agents are not a secondary interface bolted onto a human tool; browsing, retrieving, filing, and reorganizing the tree must work for a client that only speaks HTTP — and the CLI itself takes no shortcut, so this is enforced by construction rather than by discipline.

Shape

Served by docbank daemon run: Huma v2 with the humago (stdlib net/http) adapter, a typed OpenAPI contract (docbank openapi, or GET /openapi.json / /openapi.yaml / /docs on a running daemon), and X-Api-Key / Authorization: Bearer auth. Endpoints are filesystem-shaped, under /api/v1:

Endpoint Purpose Status
GET /nodes/{id} stat by id (live or trashed) Implemented
GET /path?path=/a/b stat by virtual path Implemented
GET /nodes/{id}/children list a directory, paginated (limit/offset) Implemented
GET /nodes/{id}/content stream document bytes with catalog identity and a computed digest trailer Implemented
PUT /nodes/{id}/content replace raw content under revision, size, and digest preconditions — see addendum Implemented
POST /nodes/{id}/revert create a new head from a prior version of the same file Implemented
GET /nodes/{id}/versions list immutable content versions newest-first, paginated (limit/offset) Implemented
GET /versions/{version_id} · GET /versions/{version_id}/content inspect or stream one immutable version by stable UUID Implemented
GET /content-references?sha256=&limit=&offset= find every stable node/version pair retaining a content hash Implemented
GET\|POST /tags · GET /tags/by-name · GET\|PATCH\|DELETE /tags/{tag_id} list, resolve, create, rename, or delete stable tag definitions Implemented
GET /nodes/{id}/tags · GET /tags/{tag_id}/nodes · PUT\|DELETE /nodes/{id}/tags/{tag_id} · PUT\|DELETE /path/tags/{tag_id} inspect and change tag assignments Implemented
POST /audit/preview · POST /audit/enable · GET /audit/status review permanent first-scope retention, enable the exact reviewed plan, and inspect authority or membership Implemented
GET /audit/history?path=&node_id=&limit=&cursor= read one audited node's canonical newest-first event timeline with a stable continuation cursor Implemented
POST /audit/verify independently replay audit authority, optionally prove recorded evidence is an exact prefix, and re-hash every protected blob Implemented
POST /nodes/{id}/verify re-hash one file, bound to an inspected node revision Implemented
GET /search?q=&limit= bounded name and extracted-content search (FTS5), with match source and explicit truncated status Implemented
POST /nodes create a directory (kind: "dir") Implemented
POST /ingest · POST /ingest/stream · POST /ingest/preflight import with JSON or streamed progress / inventory server-side paths — see addendum Implemented
POST /uploads?parent_id=&name= stream one digest-checked remote file — see addendum Implemented
PATCH /nodes/{id} move and/or rename Implemented
POST /path/move · POST /path/trash move / trash by virtual path, resolved and mutated in one store transaction Implemented
POST /nodes/{id}/trash · POST /nodes/{id}/restore soft delete / recover Implemented
GET /trash · POST /trash/empty {run, older_than} list / report or hard-delete trash roots Implemented
POST /gc {run} · POST /verify reclaim unreachable blobs / validate metadata and re-hash all blobs Implemented
GET /storage · POST /storage/pack · POST /storage/repack inspect usage / pack loose blobs / compact sparse packs Implemented
GET /jobs inspect daemon-owned background tasks and terminal failures Implemented
POST /backup/init · POST /backup/snapshots · POST /backup/snapshots/stream · GET /backup/snapshots initialize a repository / create with JSON or streamed progress / list snapshots Implemented

Root-level, outside /api/v1 and auth-exempt: GET /health, GET /api/ping (daemon discovery), GET /docs and the OpenAPI documents, and / (the placeholder web UI, when [web] enabled). A hidden POST /api/daemon/shutdown (not in the OpenAPI document) backs docbank daemon stop; it isn't auth-exempt, so it requires both the API key and its own shutdown token.

IDs are canonical everywhere: every response carries them, and mutating endpoints address nodes by ID so a rename can't strand a concurrent client's reference.

Backup repository endpoints

POST /backup/init accepts {"repo": "/absolute/server/path"} and returns the repository identity and canonical path. repo may be omitted when [backup] repo is configured. POST /backup/snapshots accepts the same optional repository plus tag, jobs, and force_unlock; it returns a stable logical summary rather than exposing Kit's physical manifest layout. The /backup/snapshots/stream variant accepts the same body and returns application/x-ndjson: zero or more progress events followed by exactly one terminal result or error event. Progress data carries stage, item counts, byte counts, and a final-stage marker, so clients can render bars without parsing human text. Because response headers commit when streaming begins, an HTTP 200 means only that the stream started; clients must read through EOF and require the terminal event. The CLI uses this variant for human output and the single-JSON endpoint for --json. GET /backup/snapshots?repo=... returns {items: [...]} so pagination can be added later without changing a top-level array contract.

Explicit repository paths are server filesystem paths and must be absolute. The CLI resolves a relative --repo against its own working directory before sending it. API clients over an SSH tunnel must reason about the daemon host's filesystem, not the caller's. Every endpoint requires the daemon API key.

Audit expected-evidence verification

POST /audit/verify accepts an empty body for a fresh proof. To prove ancestry, send the evidence object from a previously successful report:

{"expected":{"vault_id":"...","lineage_id":"...","operation_sequence_high_water":12,"allocation_entry_count":12,"allocation_head":"...","scopes":[{"id":"...","entry_count":9,"chain_head":"..."}]}}

The current vault is independently replayed before comparison. A successful prefix proof returns evidence_check: {"extends":true}. Evidence disagreement remains an HTTP 200 verification report so clients can inspect current terminal evidence and protected-byte problems together; evidence_check.problems uses the stable codes audit_not_enabled, vault_mismatch, lineage_mismatch, allocation_shorter, allocation_diverged, scope_missing, scope_shorter, and scope_diverged. Malformed expected evidence is a 422 validation request error.

Background-job status

GET /jobs returns {items: [...]} in stable job-name order. Each item carries name, status (running, completed, failed, or cancelled), and a UTC started_at; terminal jobs add finished_at, and failures add a bounded error. Records describe this daemon run only and disappear when it restarts. The endpoint is observation, not control: stopping a task requires stopping or reconfiguring the daemon feature that owns it.

Path resolution: a query parameter, not a URL segment

GET /path takes the virtual path as ?path=/inbox/doc.pdf, not a catch-all URL segment (/path/{path...}). Stdlib-mux decoding of a wildcard segment makes a route ambiguous for names containing /-adjacent percent-encoding; a query parameter has one well-defined encoding instead. The path must be absolute (leading /); ?path=/ resolves the root. The server applies the store's existing NFC name normalization and validation and returns 422 for an invalid path.

Concurrency: resource revisions and If-Match

Every node carries a revision that bumps on each mutation (directories bump when their contents change). The granularity is deliberate: a global tree ETag would invalidate every agent's in-flight work whenever anything anywhere changed, while per-node revisions scope conflicts to actual contention. SQLite already serializes the writes — preconditions exist to catch lost updates across an agent's read-modify-write turns, not to lock.

Every tag definition likewise carries a revision. It advances when its name or assignment set changes, so a client cannot rename over a concurrent rename or delete assignments it did not inspect. Single-tag responses carry an ETag matching this revision.

If-Match is required where a mutation targets one existing node that the caller read in an earlier request; path mutations, bulk operations, and maintenance are explicit exceptions:

Endpoint Precondition
PATCH /nodes/{id} required — target node's revision
PUT /nodes/{id}/content required — prevents a replacement from overwriting a head the caller did not inspect
POST /nodes/{id}/revert required — binds the selected source to the current head the caller inspected
POST /nodes/{id}/trash required — target node's revision
POST /nodes/{id}/restore required — target node's revision
POST /nodes/{id}/verify required — binds the evidence to the exact node state the caller inspected
PATCH /tags/{tag_id}, DELETE /tags/{tag_id} required — tag definition/assignment-set revision
PUT\|DELETE /nodes/{id}/tags/{tag_id} required — target node revision; the tag revision also advances on a real assignment change
POST /path/move, POST /path/trash none — the path is resolved and mutated inside one store transaction, so there is no separate read for a revision to guard
POST /nodes (create dir) none — creation has no prior revision; a name collision is 409
POST /ingest · POST /ingest/stream none — long-running bulk operations with per-path partial success; the destination directory may legitimately change while they run
POST /uploads none — creates or idempotently resolves one file under the stable parent_id; name/content collision policy is transactional
POST /trash/empty, POST /gc, POST /verify none — vault-wide maintenance, serialized by the maintenance gate
POST /backup/snapshots, POST /backup/snapshots/stream none — mutations pause only while pinning one logical snapshot; a preservation lease queues maintenance for the full capture, and the repository has its own exclusive lock

A stale revision gets 412 Precondition Failed, telling the caller to re-read and retry. A required If-Match that's missing gets 428 Precondition Required. Both carry the problem-JSON error envelope below explaining the rule.

Content identity and verification evidence

Every file-node representation includes a stable current_version_id plus blob_hash, docbank's canonical lowercase SHA-256 content identity, and raw size. Directories omit content identity. Node and version IDs are stable across moves and renames. Content replacement retains the node ID, creates an immutable version, and changes its current pointer, hash, media type, and revision.

GET /nodes/{id}/content exposes the catalog identity before streaming in X-Docbank-Content-Version, X-Docbank-Blob-Hash, and X-Docbank-Blob-Size. It then hashes the bytes while they pass through the response and emits the result as the RFC 9530 Content-Digest trailer. The response deliberately omits standard Content-Length: HTTP/1.1 cannot carry a trailer on a fixed-length message, and pre-reading a large loose or packed blob solely to populate a header would double physical I/O. Clients that need independent transfer proof hash the body themselves and compare both their digest and the trailer with the node's blob_hash; the version header must equal current_version_id.

GET /nodes/{id}/versions returns a bounded, newest-first page with items, total, limit, and offset. GET /versions/{version_id} resolves immutable metadata globally, and its /content child streams that version with the same identity headers and digest contract. A path rename cannot strand a retained version reference.

GET /content-references is the inverse identity lookup. It accepts one canonical lowercase SHA-256 and returns only logical content_versions references backed by blob-catalog authority; it never infers a match from a loose file or pack entry alone. Each item contains the complete immutable version, its current node projection, whether that version is the node's current head, and a path only while the node is live. Results are bounded and deterministic: live current references, live history, then trashed references.

POST /nodes/{id}/verify is the bounded server-side proof. It requires If-Match from a prior node response, reopens the blob through the same mixed loose/packed store used for downloads, and returns the recorded and computed version ID, hashes, and sizes. Missing, corrupt, and unreadable content are successful reports with verified: false and a problem; transport, validation, and stale-node failures remain non-2xx responses. The route checks the revision again after reading, so a concurrent rename, trash, or content replacement yields 412 instead of ambiguous evidence.

The single-node route is exempt from the ordinary request timeout. It is bounded in scope, not necessarily short in duration: hashing one very large blob may legitimately take longer than a minute.

These are integrity receipts from the authenticated daemon, not non-repudiable attestations against a malicious server. Signed receipts or a transparency log are outside docbank's current trust model.

Addendum: POST /ingest, POST /ingest/stream, and POST /ingest/preflight

POST /ingest/preflight takes {paths: [...], exclude: [...]} and performs a metadata-only source inventory. It opens no regular-file content and writes no vault metadata or blobs. Its report includes file/directory/logical-byte totals, pack-eligible, loose-only, and rejected size classes, exclusion/skip/error counts, bounded findings, and extension summaries. The route uses the same absolute-path validation, explicit root-directory-symlink behavior, exclusion rules, loopback fence, and timeout exemption as the real ingest. Findings are observations rather than a snapshot lock: sources can still change before ingest, and metadata-only scanning cannot prove later content readability.

POST /ingest takes server-side local paths{paths: [...], dest: "/inbox", exclude: [...]} — and returns an IngestReport (added, skipped, excluded, per-path failed entries), backing docbank add. Paths must be absolute: the long-lived daemon's working directory is meaningless, so a relative path is rejected with 422. The CLI resolves docbank add's arguments to absolute paths before calling, so the command-line UX still accepts relative and cwd-relative sources. Collisions resolve by the same suffixing rules as other imports.

POST /ingest/stream accepts the same body and returns application/x-ndjson. A metadata-only scan stage establishes advisory file and byte totals, followed by ingest progress for bytes read and file outcomes. Exactly one result carrying IngestReport or error terminates the stream; HTTP 200 alone is not success. A write failure or client disconnect cancels the request context used by traversal, blob writing, and metadata transactions. Already completed files remain valid and converge on retry, while an incomplete blob never receives node authority.

Because they grant "read any daemon-readable local path," POST /ingest and POST /ingest/stream are checked per-request against RemoteAddr and restricted to loopback callers regardless of bind address or API key — a non-loopback client gets 403 (loopback_only). There is no remote file-upload capability on this route: remote bytes use POST /uploads, while remote access to the loopback-bound daemon still terminates through the configured SSH/VPN tunnel.

Each exclusion is either a bare entry name, matched at any depth, or a relative path containing /, matched within every supplied source. Matching a directory prunes its subtree. The preflight and ingest implementations share this matcher so reviewed selection and actual selection cannot drift.

Addendum: POST /uploads

POST /uploads?parent_id=<id>&name=<filename> accepts exactly one multipart/form-data file field named file. The query uses a stable destination directory ID rather than a mutable path. The multipart filename must equal the normalized name query value, preventing the envelope and requested tree entry from describing different files.

Two request headers declare the expected identity of the file part, not the multipart envelope:

  • X-Docbank-Blob-Hash: canonical lowercase hexadecimal SHA-256;
  • X-Docbank-Blob-Size: raw byte length, bounded by docbank's explicit 4 GiB format-v1 backup ceiling.

The server streams the file once through Kit's durable writer and independently computes both values. Only after they match, the closing multipart boundary has been validated, and no extra parts remain does one metadata transaction grant blob authority and create the node. 201 with status: "added" identifies a new node and its initial content_create version. Repeating the same name, hash, and parent converges to that stable node with 200 and status: "skipped". The receipt always includes the server's computed_hash, computed_size, and the node's ID and revision; clients compare the values themselves.

Uploads are intentionally file-granular. A caller sending many files issues independent requests (concurrently when useful), so one failure never makes the success of another file ambiguous and each item can be retried on its own. Different content under the same requested name follows normal ingest suffixing rather than overwriting an existing document.

Objects through 64 MiB are eligible for packing. Larger accepted objects remain loose, but use the same content-hash authority, verified streaming, backup, and restore contracts. The 4 GiB admission limit therefore does not imply that a single object will be moved into a pack.

Digest or size disagreement returns 422 digest_mismatch or 422 size_mismatch and grants no new blobs row or node authority. Because physical bytes are published before metadata by design, a rejected stream may leave an authority-free loose object; the normal GC untracked-file scan removes it. Malformed envelopes and extra parts are also rejected before authority. Request bodies are capped at the declared size plus bounded multipart overhead, and the route is exempt from the ordinary timeout so a legitimate large upload is governed by client cancellation rather than a one-minute deadline.

Addendum: PUT /nodes/{id}/content

Content replacement accepts raw bytes rather than multipart. A caller first reads the file node and sends its revision in If-Match, then declares the raw body's canonical SHA-256 and byte count in X-Docbank-Blob-Hash and X-Docbank-Blob-Size. Content-Type is normalized and stored on the new version; an omitted value becomes application/octet-stream.

The daemon streams the body into durable authority-free storage and computes the identity independently. Only exact agreement permits one metadata transaction to create a content_replace version, advance the node's current pointer, and bump its revision. The old head remains an immutable history and GC root until the operator explicitly prunes that version. A successful response includes the resulting ETag plus a receipt containing the node, new version, computed_hash, and computed_size; clients compare every field with the request before accepting success.

Clients should send Expect: 100-continue for large writes. The daemon checks the target kind and revision before its first body read, then repeats those checks in the committing metadata transaction. The early check avoids wasting bandwidth; only the transactional check grants authority.

Stale revisions return 412 stale_revision; missing preconditions return 428 precondition_required; identity disagreements return 422 digest_mismatch or 422 size_mismatch. A failed operation grants no new catalog authority, although a completely written loose object can remain authority-free until GC. Because the body may be binary, this route is an explicit exception to the raw JSON text validator; its byte count and digest are the lossless boundary instead. Cancellation propagates through physical writing and prevents the metadata transaction.

Addendum: POST /nodes/{id}/revert

Reversion accepts JSON {"source_version_id":"<uuid>"} and requires the target node's revision in If-Match. The source must be an immutable version of that same node and cannot be its current version. A successful transaction creates a distinct content_revert row, copies the source's blob hash, size, and media type into it, records source_version_id, advances the current pointer, and bumps the node revision.

This operation is metadata-only: it neither streams nor copies the source blob, whether loose or packed. Every existing version remains a reachability root until explicitly selected by version pruning. The receipt contains node, the new version, and source_version, while the ETag carries the resulting revision. Clients cross-check all four authorities; HTTP 200 alone is not sufficient evidence.

A stale target returns 412 stale_revision. A source from another node returns 422 version_node_mismatch, selecting the current head returns 422 version_already_current, and an unknown source returns 404 not_found.

Addendum: POST /nodes/{id}/versions/prune

Version pruning releases selected non-current history without changing current content. Every request requires the inspected node revision in If-Match and chooses exactly one selector: version_ids (at most 1,000 canonical UUIDs), keep_newest, older_than, or all_prior. The default is a dry run; "run":true performs the reported class of operation under the same node revision precondition.

Ordinary selectors retain revert-source dependencies and report them separately. all_prior may first install a same-byte, source-free checkpoint when the current head is a revert, allowing the complete older graph to be removed safely. A successful run advances the node revision once when it deletes history and does not advance it for an empty selection. Deleted version IDs stop resolving.

An older_than selector computes and returns its cutoff for each request. The node ETag protects content-graph changes, but wall-clock aging does not advance the revision; a later run can therefore include versions that crossed the age boundary after a preview. Callers needing an exact replay execute the preview's candidate IDs through version_ids.

The receipt separates logical history bytes from physical consequences. Shared blobs remain reachable, authority-free loose blobs await GC, and dead packed payload awaits GC followed by repack. Pruning itself does not claim physical space reclamation.

Addendum: tags

Tag definitions use stable server-generated UUIDv4 identities, mutable, unique NFC-normalized names, and a revision covering both the definition and its assignment set. POST /tags, GET /tags, GET /tags/by-name, and GET|PATCH|DELETE /tags/{tag_id} expose definition lifecycle. GET /nodes/{id}/tags and GET /tags/{tag_id}/nodes provide bounded forward and reverse listings; reverse results include a path only for live nodes.

PUT|DELETE /nodes/{id}/tags/{tag_id} assign and unassign under the required node If-Match revision. Their receipt contains the resulting node and tag, changed, and the resulting node ETag. Repeating the requested state returns changed: false without advancing either revision. A real assignment change advances the node and tag once. Single-tag definition responses carry the tag ETag; PATCH|DELETE /tags/{tag_id} require that ETag in If-Match. Renaming advances the tag and every assigned node; deleting checks the current tag revision before removing the complete assignment set and advancing each assigned node. Deletion never removes nodes or document bytes.

Path-oriented clients use PUT|DELETE /path/tags/{tag_id} with {"path":"/..."}. The store resolves that live path and changes the assignment in one SQLite transaction. This is stronger than a separate path lookup followed by the ID-addressed endpoint: moving an ancestor changes a descendant's path without changing that descendant's revision.

Ingest provenance is currently filesystem-shaped: each import records the source's original path and mtime in the store's provenance table. The path is a record, never node identity. Non-file origin fields and lookup by content hash are not part of the current API.

Maintenance gate

gc --run, trash empty, and verify need the vault quiescent while they run — the same reachability-then-delete race described in Ownership & Concurrency. Rather than the daemon's exclusive vault lock (held for the daemon's whole lifetime, not per-request), an in-process sync.RWMutex-shaped gate serializes them against regular mutations: ordinary mutating handlers (PATCH, trash, restore, create, ingest) take the read side and may run concurrently with each other; maintenance handlers take the write side. Requests queue rather than fail, and maintenance routes are exempt from the per-request timeout since gc/verify can legitimately run long on a large vault.

Backup creation uses the mutation-exclusive side only for Kit's freeze window. Once Docbank's deferred read transaction is pinned, ordinary mutations continue into SQLite's WAL while verified metadata and blob streams are captured. The backup retains a separate shared preservation lease until capture ends; maintenance takes that lease exclusively, so GC cannot delete a loose blob or catalog mapping still referenced by the pinned snapshot. The create route is timeout-exempt; cancellation still propagates through Kit and prevents publication of a snapshot manifest.

Auth

X-Api-Key or Authorization: Bearer <key>, constant-time compared against the daemon's effective key. The daemon always has one: with [server] api_key unset it generates a fresh key at startup and publishes it, inside the owner-private $DOCBANK_HOME, through the same runtime record the CLI already uses for discovery — readable only by the vault's owner, never sent over the network unencrypted, never logged. Binds are loopback-only: the API is plain HTTP, so a non-loopback bind would expose the key and vault contents in cleartext, and docbank daemon run refuses to start on one — remote access goes through an SSH tunnel or VPN (see Configuration). /health, /api/ping, /docs, the OpenAPI documents, and the web placeholder at / are auth-exempt; everything else, including the shutdown route, requires the key.

Error mapping

Errors are RFC 7807 problem-JSON with one extension member, code, a machine-readable string clients branch on instead of parsing detail:

{
  "title": "Conflict",
  "status": 409,
  "detail": "node \"report.pdf\" already exists",
  "code": "exists"
}
code HTTP Source
not_found 404 store.ErrNotFound
exists 409 store.ErrExists (name collision)
cycle 409 store.ErrCycle (move under own descendant)
audit_mutation_unsupported 409 the audited vault does not yet record this logical mutation class
audit_already_enabled 409 this vault already has its first permanent audit scope
audit_preview_stale 409 the one-use enrollment preview expired, was consumed, came from another daemon, or no longer matches the vault
audit_acknowledgment_required 422 enrollment execution omitted the explicit permanent-retention acknowledgment
audit_not_enrolled 422 the selected node exists but is outside every permanent audit scope
invalid_audit_cursor 422 the history cursor is malformed or belongs to another stable node
stale_revision 412 store.ErrStaleRevisionIf-Match didn't match the current revision
not_dir / not_file / invalid_name / invalid_tag / not_trashed / is_root 422 store.ErrNotDir / ErrNotFile / ErrInvalidName / ErrInvalidTag / ErrNotTrashed / ErrIsRoot
validation 400, 415, or 422 malformed request (bad If-Match, paths, media type, multipart envelope, or generated validation)
precondition_required 428 required If-Match header missing
loopback_only 403 server-path ingest or preflight called by a non-loopback peer
digest_mismatch / size_mismatch 422 uploaded file bytes disagree with the required declaration; no node/blob authority committed
too_large 413 upload exceeded its declared size plus bounded multipart overhead
pack_retirement_deferred 503 repack authority committed but an old source pack remains physically locked; release the lock, then run storage pack reconciliation
unauthorized 401 missing or invalid API key; bad shutdown token
internal 500 unmapped error (still surfaced with a message — this is a single-user local daemon, not a hardened multi-tenant service)

Non-goals

  • No server-side rendering. The current root is a static placeholder; the planned kit-ui portal remains an ordinary authenticated API client and gains no privileged data path.
  • No multi-user model: one vault, one key. Sharing is out of scope for v1.
  • No MCP server.
  • No remote-daemon mode or [remote] configuration.