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

# Work with the documents library

> Upload, classify, list, search, download, update, and delete the user-curated documents in your workspace.

The HQ documents library is a curated, longer-lived file store: PDFs, CSVs, images, and other files your workspace keeps around and wants to find again. Each document has a stable id, a content-addressed hash, a download URL of the shape `/v1/api/documents/{id}/download`, and a set of classification fields that a worker fills in automatically after upload. This guide covers uploading (both the inline JSON and the multipart variants), how classification works and how to re-trigger it, listing and searching, reading and updating metadata, downloading the raw bytes, and deleting.

All requests go to `https://api.hq.zone` and authenticate with a personal access token:

```bash theme={null}
export HQ_TOKEN="hq_pat_..."
```

<Note>Read operations (list, search, get, download) require the `documents:read` scope. Write operations (upload, update, reclassify, delete) require `documents:write`.</Note>

## Visibility

Every read enforces a visibility filter, so you only ever see documents you are allowed to see. There are three scopes:

* `private` — only the owner sees it.
* `channel` — members of the document's `channel_id` see it.
* `team` — everyone in the workspace sees it.

Scope is set at upload time (defaulting to `private`) and can be changed later with an update.

## Upload a document

There are two upload paths. They share the same dedup, storage, and classification pipeline; they differ only in how the bytes reach the server.

* **Inline JSON + base64** — `POST /v1/api/documents`. Bytes are base64-encoded in the JSON body. The JSON body is capped at 16 MiB, which lands the raw file ceiling at roughly 12 MiB after the base64 tax.
* **Multipart form-data** — `POST /v1/api/documents/upload`. Bytes ride a `multipart/form-data` file part, so they don't pay the base64 tax. This path accepts larger files (up to 64 MiB) and is what browser drag-and-drop uses.

Both require the `documents:write` scope and both return the same response shape.

### Variant A — inline JSON (base64)

<Steps>
  <Step title="Base64-encode the file">
    ```bash theme={null}
    B64=$(base64 -w0 report.pdf)
    ```
  </Step>

  <Step title="POST it as JSON">
    Send [Upload a document](/api-reference/documents/upload-document). The required fields are `filename`, `content_type`, and `body_b64`; `scope` defaults to `private`, and `channel_id`, `category`, `tags`, and `caption` are optional. `channel_id` is required when `scope` is `channel`, and rejected otherwise.

    ```bash theme={null}
    curl -X POST https://api.hq.zone/v1/api/documents \
      -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
      -d "{
        \"filename\": \"report.pdf\",
        \"content_type\": \"application/pdf\",
        \"scope\": \"team\",
        \"tags\": [\"finance\", \"q3\"],
        \"caption\": \"Q3 board report\",
        \"body_b64\": \"$B64\"
      }"
    ```

    A 200 returns the document id, download URL, hash, and size:

    ```js theme={null}
    // → 200
    // {
    //   "document_id": "<DOCUMENT_ID>",
    //   "download_url": "/v1/api/documents/<DOCUMENT_ID>/download",
    //   "sha256": "<HEX_SHA256>",
    //   "size_bytes": 81234,
    //   "deduplicated": false,
    //   "enriched": false
    // }
    ```
  </Step>
</Steps>

### Variant B — multipart form-data

<Steps>
  <Step title="POST the file as multipart/form-data">
    Send [Upload a document (multipart)](/api-reference/documents/upload-multipart). The file bytes go in a part named `file` (the part name `body` is also accepted); the filename and content type are read from that part. Optional text fields are `scope`, `channel_id`, `category`, `tags` (comma-separated), and `caption`.

    ```bash theme={null}
    curl -X POST https://api.hq.zone/v1/api/documents/upload \
      -H "Authorization: Bearer $HQ_TOKEN" \
      -F "file=@report.pdf;type=application/pdf" \
      -F "scope=team" \
      -F "tags=finance,q3" \
      -F "caption=Q3 board report"
    ```

    The response shape is identical to the inline path: `document_id`, `download_url`, `sha256`, `size_bytes`, `deduplicated`, and `enriched`.
  </Step>
</Steps>

<Note>The file part must carry a filename (via `Content-Disposition: filename=...`). If the part has no `Content-Type`, the server falls back to `application/octet-stream` rather than rejecting the upload. The `tags` field here is a single comma-separated string, whereas the inline JSON path takes a real JSON array.</Note>

<Tip>Uploads are content-addressed and idempotent per owner and scope. Re-uploading identical bytes under the same owner and scope returns the **existing** document instead of creating a duplicate — the response sets `deduplicated: true`. If you also supply new `tags`, `caption`, or `category` on that re-upload, they are merged onto the existing row (tags are unioned; caption and category fill in only when previously empty) and `enriched: true` is returned.</Tip>

## How classification works

When a document is saved, a background classifier worker analyzes it and writes back auto-derived metadata. You don't trigger it — it picks up newly created documents on its own. Its progress and output show up as fields on the document row:

* `classification_status` — one of `pending`, `queued`, `processing`, `completed`, `failed`, or `skipped`. A UI typically shows a pill until this reaches `completed`.
* `auto_categorized` — `true` once the worker has written back an auto-derived category, summary, and tags. (This is distinct from `category` being set, because users can also set a category manually.)
* `category` — the document category (nullable; may be set by the worker or by you).
* `summary` — an auto-generated summary (nullable).
* `document_date` — the date the document *refers to* (issue / publication / statement date), not when it was ingested (nullable).
* `language` — ISO 639-1 code such as `en` or `sv` (nullable).
* `reference_numbers` — extracted document-specific IDs (invoice / case / order numbers).
* `entities` — an LLM-extracted JSON blob of entities (e.g. person names, company names, identifiers, amounts, dates).
* `analysis_strategy` — which classifier pipeline produced the row (nullable on older rows).
* `profile_version` — the classifier schema version that wrote the row.
* `classification_confidence` — the model's self-reported certainty in the range 0–1 (nullable). Confidence below `0.6` is typically flagged as "needs review".

### Re-trigger classification

If the model returned a poor category, the document was uploaded before the worker was available, or it terminally failed, you can force another pass with [Reclassify a document](/api-reference/documents/reclassify). This resets `classification_status` to `queued` and re-queues the document for the worker. Owner-only.

```bash theme={null}
curl -X POST https://api.hq.zone/v1/api/documents/<DOCUMENT_ID>/reclassify \
  -H "Authorization: Bearer $HQ_TOKEN"
```

```js theme={null}
// → 200
// { "status": "queued" }
```

A `403` means you are not the owner; a `404` means there is no such document in your workspace.

## List and search

<Steps>
  <Step title="List documents">
    [List documents](/api-reference/documents/list-documents) returns the documents visible to you, newest first. Optional query parameters narrow the set: `scope`, `category`, `channel` (a channel id, useful with `scope=channel`), and `tags` (comma-separated, matching documents that carry **all** listed tags). `limit` defaults to 50 and is capped at 200.

    ```bash theme={null}
    curl "https://api.hq.zone/v1/api/documents?scope=team&category=finance&limit=20" \
      -H "Authorization: Bearer $HQ_TOKEN"
    ```

    The response carries the `documents` array plus rollup `categories` and `tags` arrays — the distinct categories and tags across your whole visible set, for building filter sidebars:

    ```js theme={null}
    // → 200
    // {
    //   "documents": [ { "id": "<DOCUMENT_ID>", "filename": "report.pdf", ... } ],
    //   "categories": ["finance", "legal"],
    //   "tags": ["q3", "board"]
    // }
    ```
  </Step>

  <Step title="Search documents">
    [Search documents](/api-reference/documents/search-documents) runs a case-insensitive substring search over filename, caption, summary, and tags. The `q` parameter is required; `limit` defaults to 50 and is capped at 200.

    ```bash theme={null}
    curl "https://api.hq.zone/v1/api/documents/search?q=revenue" \
      -H "Authorization: Bearer $HQ_TOKEN"
    ```

    The response has the same shape as the list endpoint, but the `categories` and `tags` rollup arrays come back **empty** for search.
  </Step>
</Steps>

## Get and update metadata

<Steps>
  <Step title="Get one document">
    [Get a document](/api-reference/documents/get-document) returns the full metadata for a single document by id — `filename`, `content_type`, `size_bytes`, `sha256`, `scope`, owner fields, `category`, `tags`, `caption`, `summary`, the classification fields described above, `download_url`, and `is_owner` (whether you own it, which drives edit/delete affordances). A document not visible to you returns `404`.

    ```bash theme={null}
    curl https://api.hq.zone/v1/api/documents/<DOCUMENT_ID> \
      -H "Authorization: Bearer $HQ_TOKEN"
    ```
  </Step>

  <Step title="Update metadata">
    [Update a document](/api-reference/documents/update-document) edits the mutable metadata: `scope`, `channel_id`, `category`, `tags`, and `caption`. Only fields present in the body change. For `channel_id`, `category`, and `caption`, an explicit `null` **clears** the value while omitting the field leaves it untouched. Only the owner may update (others get `403`); a missing document returns `404`.

    ```bash theme={null}
    curl -X PATCH https://api.hq.zone/v1/api/documents/<DOCUMENT_ID> \
      -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
      -d '{"category": "finance", "tags": ["q3", "board"], "caption": null}'
    ```

    The response is the updated document row.
  </Step>
</Steps>

<Tip>To move a document into a channel, set both `scope` and `channel_id` in the same update. A valid `scope` is one of `private`, `channel`, or `team`.</Tip>

## Download the bytes

A document's metadata gives you `download_url`. Resolve it against the base URL and call [Download a document](/api-reference/documents/download-document). The response is **raw binary** — the document bytes streamed back as an attachment with the original `Content-Type`, the original filename in `Content-Disposition`, and the byte count in `Content-Length` — not JSON. Save it to a file rather than printing it. Visibility is enforced the same way as on the read endpoints; a document you can't see returns `404`.

<Steps>
  <Step title="Save the bytes with curl">
    Use `-o` to write the response body straight to a file:

    ```bash theme={null}
    curl https://api.hq.zone/v1/api/documents/<DOCUMENT_ID>/download \
      -H "Authorization: Bearer $HQ_TOKEN" \
      -o report.pdf
    ```
  </Step>

  <Step title="Save the bytes from JavaScript">
    Read the response as an `ArrayBuffer` (the body is binary, not JSON) and write it to disk:

    ```js theme={null}
    import { writeFile } from "node:fs/promises";

    const base = "https://api.hq.zone";
    const headers = { Authorization: `Bearer ${process.env.HQ_TOKEN}` };
    const documentId = "<DOCUMENT_ID>";

    const res = await fetch(`${base}/v1/api/documents/${documentId}/download`, { headers });
    if (!res.ok) throw new Error(`download failed: ${res.status}`);

    const bytes = Buffer.from(await res.arrayBuffer());
    await writeFile("report.pdf", bytes);
    ```
  </Step>
</Steps>

<Note>The download is streamed, so large documents don't have to fit in memory on the server. The response carries `Cache-Control: private, max-age=300`.</Note>

## Delete a document

[Delete a document](/api-reference/documents/delete-document) removes a document. Only the owner may delete it (others get `403`); an unknown document returns `404`.

```bash theme={null}
curl -X DELETE https://api.hq.zone/v1/api/documents/<DOCUMENT_ID> \
  -H "Authorization: Bearer $HQ_TOKEN"
```

A successful delete returns `204 No Content` with an empty body.

<Note>The stored bytes are removed only when no other document in the workspace still references the same content. Deduplicated copies are therefore preserved — deleting your copy of a shared file won't pull the bytes out from under someone else's row.</Note>
