Flow
ServicesDocuments Service

API

Endpoints, request and response shapes, reference filters, and error codes for the documents service.

Discovery and ops

PathMethodNotes
/GETService discovery (enveloped)
/api-docsGETSwagger UI
/openapi.yaml, /openapi.jsonGETSpec from binary
/healthzGETLiveness
/readyzGETReadiness (Postgres ping)

Versioned API

PathMethodContract
/v1/documentsGETList; pagination[page], pagination[pageSize], reference filters
/v1/documents/readableGETOne page of project and unfiled files, newest first. all=true is every such file. Otherwise project_ids (comma-separated) plus unfiled files owned by owner. A file in two of those projects is returned once. q matches filename or uploader. No SAS
/v1/documents/uploaded-countGETDistinct uploaded files. all=true counts every project and unfiled upload. Otherwise project_ids (comma-separated) plus unfiled files owned by owner. A file in two of those projects counts once
/v1/documents/sasPOSTMint a write SAS. No Idempotency-Key, no row written. Optional ttl_seconds sets the lifetime; absent uses the account default, and anything over 24 hours is shortened to 24 hours. Optional blob_name signs an existing blob again instead of a new one, so an upload that outlasts its first SAS can keep going; it must be a name this service minted and must have no document recorded against it
/v1/documentsPOSTRecord an uploaded document; requires Idempotency-Key
/v1/documents/{id}POSTUpdate filename, references, metadata; requires Idempotency-Key
/v1/documents/{id}GETGet by UUID, with a read SAS

Enveloped All /v1/* JSON uses the Response envelope: The template service wraps every JSON response in {header, payload}. A success payload is {data} (a record or a list) plus optional meta.pagination; accounting still returns bare bodies and RFC 9457 errors until the divergence is resolved.. Success payloads are { data } — a record object on create, update, and get, an array plus meta.pagination on list.

Updates are POST, not PUT: mutations are commands on this platform, and a command needs an Idempotency-Key and a why. Nothing about the resource is addressable by replacement.

Authorization is not wired yet — endpoints are open aside from Idempotency-Key on writes. Audit columns use a placeholder actor (system).

Headers

HeaderRequiredPurpose
Idempotency-KeyPOST /v1/documents and POST /v1/documents/{id}Stable across retries
Content-TypePOSTapplication/json

For update, the document id is part of the hashed request, so the same key replayed against a different document is a fresh command rather than a stored response.

Mint a write SAS

POST /v1/documents/sas is not a command — it writes nothing — so its body is bare, with no data or why wrapper:

{
  "filename": "lease.pdf",
  "visibility": "private",
  "storage_account": "default-private",
  "container": "documents-private"
}

filename must carry an extension, because the extension is what makes the stored blob openable.

container names which of the account's containers to upload into. An account can be configured with several, and the request may only name one of those — anything else is a 422. It can be left out when the account has exactly one container, and must be given when the account has several.

The response carries the write URL and the blob name the service chose:

{
  "sas_url": "https://…/documents-private/lease_019…c1.pdf?sv=…&sig=…",
  "sas_expires_at": "2026-08-27T14:15:00Z",
  "storage_account": "default-private",
  "visibility": "private",
  "container": "documents-private",
  "blob_name": "lease_019…c1.pdf",
  "filename": "lease.pdf"
}

blob_name is {stem}_{uuidv7}.{ext}. The service picks it, not the caller, so two uploads of lease.pdf cannot overwrite each other. Send it back on create.

A long upload — a recording still being made, say — can outlast its SAS. Sending the earlier blob_name back on POST /v1/documents/sas signs that same blob again rather than minting a new one, so blocks already written stay where they are. The name must look like one this service minted, with the uuid in it, and must not already have a document recorded against it; anything else is a 422 or a 409. The uuid is what makes a name unguessable, so only a caller that already has the name can renew it. Which caller may renew which blob is decided by the service that owns the upload — documents has no caller identity of its own.

Record the document

POST /v1/documents takes a command body:

{
  "data": {
    "filename": "lease.pdf",
    "visibility": "private",
    "storage_account": "default-private",
    "container": "documents-private",
    "blob_name": "lease_019…c1.pdf",
    "content_type": "application/pdf",
    "content_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    "references": [
      { "ref_type": "lease", "ref_id": "L-1", "ref_category": "signed-copy", "is_external": false }
    ],
    "metadata": [{ "meta_name": "pages", "meta_value": "12" }]
  },
  "why": { "code": "lease.signed_copy_returned", "detail": "tenant returned the signed lease" }
}

Send back the same container the SAS response returned. The handler looks for the blob in the container the request names, so naming a different one is a 422: the blob is not there.

Before opening a transaction, the handler asks storage about the blob. So a create that skipped the upload stores nothing, and an upload bigger than the cap is refused with 413 instead of recorded.

The cap has to be checked here, because a SAS URL cannot carry a size limit. The first moment we can act on how many bytes were actually written is when the caller comes back to register them. The blob we refused stays in the container until the orphan reaper removes it.

content_hash is optional. It is the uploader's SHA-256 of the bytes, in lowercase hex. We store it as something the uploader told us, not as something we checked — nothing on the server ever reads the blob. That makes it useful for spotting duplicates, and for a consumer checking that what it downloaded matches what was registered. It is no defence against an uploader who lies.

byte_size in the response is different. That number comes from the storage account, so it is something we observed rather than something we were told. There is no request field for it.

Update

POST /v1/documents/{id} accepts filename?, references, and metadata. The two collections are the full replacement set, not a delta: what you send is what the document has afterwards, and sending [] clears them. Omitting filename leaves it as it was.

storage_account, visibility, container, and blob_name are immutable and are not accepted here. Pointing a record at different bytes would break the one-blob-one-document guarantee; upload a new document instead.

Get

GET /v1/documents/{id} returns the document plus a freshly signed read SAS:

{ "data": { "id": "019…", "filename": "lease.pdf", "sas_url": "https://…", "sas_expires_at": "…" } }

Fetch the bytes from sas_url directly. It expires on the storage account's TTL and is not stored anywhere, so treat it as valid for this response only and call again when it lapses.

List and filter

GET /v1/documents is page mode (ADR 0014):

QueryMeaning
pagination[page], pagination[pageSize]Page mode, platform defaults and caps
ref_typeOwning domain, e.g. lease
ref_idId within that domain
ref_categoryLabel for the kind of link
is_externaltrue or false; absent means either

The filters that are set must all match one reference, and a document matches if any of its references does. is_external is a string enum rather than a boolean because an absent parameter and false are different questions.

Every returned document carries all of its references and metadata, not only the reference that matched. No SAS is minted — use GET /v1/documents/{id} when you need bytes.

Errors

StatusWhen
404No document with that id
409Idempotency-Key replayed with a different body, or the blob is already registered to another document
413The uploaded blob is larger than DOCUMENTS_MAX_UPLOAD_BYTES. Carries code: "upload_too_large"
422Unknown storage account; visibility that disagrees with the catalog; filename without an extension or with an unsafe name; a blob_name that is not in storage; duplicate references or metadata in one request body
503This process has no storage configured, or a stored document names an account it cannot reach

The account-related 422s are caller errors; the 503 is not. A document whose account has been removed from configuration is an operational problem, so it reads as unavailable rather than as a bad request.

OpenAPI

Generated from the Go handler types and served at runtime. The merged platform spec excludes documents today (DEPLOYED_SERVICES in the Makefile).

Source: services/documents/openapi.yaml.

On this page