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

# Overview

> Manage files and folders for entities and the company via signed URLs

The Company Files API manages files and their folder organization. File bytes
never travel through the API itself — uploads and downloads both go straight
to cloud storage via short-lived **signed URLs**, while the API handles
metadata, placement, and permissions as plain JSON.

<Note>
  This API was rebuilt on 2026-06-10 with **breaking changes**: the multipart
  upload and the binary `/content` download were replaced by the signed-URL
  workflow below, and every response shape changed. See the
  [changelog](/api-reference/changelog) for the full diff.
</Note>

## Key Concepts

* **Owner** — every file and folder belongs to an owner: a configured Field Model V1 entity (`entityType` + `entityId`, e.g. `entityType: "exposure"`) or the whole company (`entityType: "company"`, no `entityId`). `entityType` is the lowercase kebab-case wire slug — PascalCase like `Exposure` is rejected with a `400`.
* **File** — a display identity (`displayName`, renameable) over an immutable uploaded version (original `fileName`, `contentType`, `byteSize`). A file's `status` is `pending` from upload intent until finalize, then `ready` — unless malware scanning holds it `quarantined` (scan verdict outstanding) or marks it `infected` (downloads permanently refused).
* **Version** — every upload is an immutable version; a file keeps its full version history. Re-uploading (a new-version intent → PUT → the same finalize) adds a version and repoints the file at it, so the file keeps its id, placements, categories, and history — the download URL always serves the current version. List a file's versions to see its history, with the current one flagged.
* **Placement** — one appearance of a file under an owner. A file can be placed on several entities at once: sharing adds a placement, never a copy — every placement sees the same current version and history. Folder location and `category` are per placement; `displayName` is per file. Removing a placement detaches the file from that owner; the file itself is deleted only when its last placement is removed.
* **Category** — a label on a placement, stored as free text. Each entity type has its own **configured** category list, which company admins manage in **Company Settings → File Categories** — Policy categories and Event categories are independent lists, and a company that has configured none simply has an empty list. `GET /v1/files/categories?entityType=<slug>` returns that type's configured categories in their configured order plus the values in use on its placements that the list does not cover, each flagged so you can tell them apart; without the param it returns the distinct categories across the company's live placements. **Writes are never validated against the configured list** — setting a `category` accepts any label up to 255 characters — so draw from the returned vocabulary to keep it consistent. See **File categories** below.
* **Folder** — a named node in the owner's folder tree (`parentFolderId` null = top level). A folder only ever holds files and subfolders of its own owner.
* **Signed URLs** — uploads `PUT` to a signed `uploadUrl` pinned to the declared content type and byte size; downloads `GET` a signed `url` minted per request. Both expire after 15 minutes — request fresh ones, never cache them.
* **Soft delete** — file and folder deletes are soft; folder deletes cascade recursively through the subtree and its files.

## API Endpoints

### Files

| Method | Endpoint                                          | Description                                                                       |
| ------ | ------------------------------------------------- | --------------------------------------------------------------------------------- |
| POST   | `/v1/files`                                       | Create an upload intent (returns the signed `uploadUrl`)                          |
| POST   | `/v1/files/{fileId}/finalize`                     | Finalize an upload (makes the file `ready`)                                       |
| GET    | `/v1/files`                                       | List an owner's files (paginated, optional folder filter)                         |
| GET    | `/v1/files/{fileId}`                              | Get file metadata                                                                 |
| GET    | `/v1/files/{fileId}/download-url`                 | Mint a signed download URL                                                        |
| POST   | `/v1/files/download-urls`                         | Mint signed download URLs for a batch of files (all-or-nothing)                   |
| GET    | `/v1/files/{fileId}/versions`                     | List a file's version history                                                     |
| POST   | `/v1/files/{fileId}/versions`                     | Create a re-upload intent (new version)                                           |
| POST   | `/v1/files/{fileId}/versions/{versionId}/restore` | Restore a previous version (make it current)                                      |
| PATCH  | `/v1/files/{fileId}`                              | Rename and/or move a file                                                         |
| POST   | `/v1/files/bulk-move`                             | Move a batch of an owner's files into one folder (all-or-nothing)                 |
| DELETE | `/v1/files/{fileId}`                              | Soft-delete a file (every placement)                                              |
| GET    | `/v1/files/{fileId}/placements`                   | List everywhere a file appears                                                    |
| POST   | `/v1/files/{fileId}/placements`                   | Share a file to another entity                                                    |
| PATCH  | `/v1/files/{fileId}/placements/{placementId}`     | Move/categorize a file under one entity                                           |
| DELETE | `/v1/files/{fileId}/placements/{placementId}`     | Remove a file from one entity                                                     |
| GET    | `/v1/files/categories`                            | List the category vocabulary (company-wide, or one entity type's configured list) |

### Folders

| Method | Endpoint                 | Description                                      |
| ------ | ------------------------ | ------------------------------------------------ |
| POST   | `/v1/folders`            | Create a folder                                  |
| GET    | `/v1/folders`            | Get an owner's folder tree (flat adjacency list) |
| GET    | `/v1/folders/{folderId}` | List folder contents (subfolders + files)        |
| PATCH  | `/v1/folders/{folderId}` | Rename and/or move a folder                      |
| DELETE | `/v1/folders/{folderId}` | Soft-delete a folder subtree (recursive)         |

## Permissions

| Operation                                                                                                         | Permission              |
| ----------------------------------------------------------------------------------------------------------------- | ----------------------- |
| List files, Get file, List file versions, List placements, Get folder tree, List folder contents                  | `company.file:read`     |
| Create upload intent, Finalize upload, Create folder, Add placement                                               | `company.file:create`   |
| Rename/move file, Bulk move files, Re-upload (new version), Restore version, Update placement, Rename/move folder | `company.file:update`   |
| Delete file, Remove placement, Delete folder                                                                      | `company.file:delete`   |
| Get download URL                                                                                                  | `company.file:download` |

## Upload Workflow

Uploading is a three-step handshake; only steps 1 and 3 touch this API.

**1. Declare the upload (intent)**

```bash theme={null}
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "entityType": "company",
    "fileName": "master-agreement.pdf",
    "contentType": "application/pdf",
    "byteSize": 482133
  }'
# → {"fileId": "...", "versionId": "...", "uploadUrl": "https://storage.googleapis.com/..."}
```

**2. PUT the bytes to the signed URL** (direct to storage). Two headers are
signed into the URL and must be sent exactly: the `Content-Type` you declared,
and `x-goog-content-length-range: <byteSize>,<byteSize>`:

```bash theme={null}
curl -X PUT "UPLOAD-URL-FROM-STEP-1" \
  -H "Content-Type: application/pdf" \
  -H "x-goog-content-length-range: 482133,482133" \
  --data-binary @master-agreement.pdf
```

**3. Finalize** (flips the file from `pending` to `ready`):

```bash theme={null}
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/finalize \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"versionId": "VERSION-ID-FROM-STEP-1"}'
# → {"fileId": "...", "versionId": "..."}
```

If you abandon an upload after step 1, the pending file stays invisible and
is reclaimed automatically — finalize is what publishes it.

## Download Workflow

Mint a signed URL, then fetch the bytes from storage:

```bash theme={null}
curl https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/download-url \
  -H "Authorization: YOUR-API-KEY"
# → {"url": "https://storage.googleapis.com/...", "expiresAt": "...", "fileName": "master-agreement.pdf", ...}

curl -o master-agreement.pdf "URL-FROM-ABOVE"
```

**Batch — an entity's whole document set in one call.** List the entity's files
(each row carries its `id`), then mint every URL in a single `POST` instead of
one round trip per file. Send up to 100 `fileIds`; add `"disposition": "inline"`
to preview rather than download. The response pairs each `url` with its `fileId`,
using the same per-file fields as the single-file endpoint:

```bash theme={null}
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files/download-urls \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"fileIds": ["FILE-ID-1", "FILE-ID-2"]}'
# → {"downloadUrls": [
#      {"fileId": "FILE-ID-1", "url": "https://storage.googleapis.com/...", "expiresAt": "...", "fileName": "master-agreement.pdf", "contentType": "application/pdf", "byteSize": 482133},
#      {"fileId": "FILE-ID-2", "url": "https://storage.googleapis.com/...", "expiresAt": "...", "fileName": "loss-runs.pdf",         "contentType": "application/pdf", "byteSize": 118220}
#    ]}
```

The batch is **all-or-nothing**: if any id is unknown, belongs to another
company, or points at a file that is not `ready`, the whole request is rejected
(`404` or `409`) and no URLs are returned — a mixed batch can never partially
leak. An empty `fileIds` array or more than 100 ids returns `400`.

## Re-upload Workflow (new version)

To replace a file's contents without losing its id, placements, categories, or
history, upload a **new version**. It is the same two-phase handshake as a first
upload, but the intent targets the existing file — and it reuses the **same**
finalize endpoint. Re-upload is gated by `company.file:update` (it changes an
existing file), not `company.file:create`.

**1. Declare the new version (re-upload intent)**

```bash theme={null}
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/versions \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fileName": "loss-run-revised.pdf",
    "contentType": "application/pdf",
    "byteSize": 118220
  }'
# → {"fileId": "...", "versionId": "NEW-VERSION-ID", "uploadUrl": "https://storage.googleapis.com/..."}
```

**2. PUT the bytes to the signed URL** exactly as in the upload workflow (the
two signed headers `Content-Type` and `x-goog-content-length-range` are
required).

**3. Finalize with the SAME endpoint** — there is no separate re-upload
finalize. Pass the new `versionId`; finalize flips the version `ready` and
repoints the file at it, so the new bytes silently become current:

```bash theme={null}
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/finalize \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"versionId": "NEW-VERSION-ID"}'
# → {"fileId": "...", "versionId": "NEW-VERSION-ID"}
```

**Inspect the history.** List the file's versions — newest first, the current
one flagged:

```bash theme={null}
curl https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/versions \
  -H "Authorization: YOUR-API-KEY"
# → {"versions": [
#      {"versionId": "NEW-VERSION-ID", "fileName": "loss-run-revised.pdf", "state": "ready", "isCurrent": true,  "createdAt": "..."},
#      {"versionId": "OLD-VERSION-ID", "fileName": "loss-run.pdf",         "state": "ready", "isCurrent": false, "createdAt": "..."}
#    ]}
```

A subsequent download URL serves the new current version. The file's placements
and categories are untouched.

**Roll back to a previous version.** If a re-upload was a mistake, restore an
earlier `ready` version to make it current again — pure metadata, so no bytes
move, nothing is deleted, and the version you were on stays in history. Take a
`versionId` from the version listing and restore it (gated by the same
`company.file:update`):

```bash theme={null}
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/versions/OLD-VERSION-ID/restore \
  -H "Authorization: YOUR-API-KEY"
# → {"fileId": "...", "currentVersionId": "OLD-VERSION-ID"}
```

The download URL now serves the restored version's bytes. Only a `ready`
version can be restored; restoring a `pending` version or the one that is
already current returns `409`, and an unknown version returns `404`.

## Files on entities

Files rarely live at the company level — most belong on an entity. Name the
owner with `entityType` + `entityId`, where `entityType` is the entity's
lowercase kebab-case wire slug (`event`, `exposure`, `quote`, `submission`,
`person`, `organization`, or `policy`). PascalCase like `Quote` is rejected
with a `400` — always send the slug.

**Upload a file onto a quote.** The handshake is the same three steps as the
**Upload Workflow** above; only the intent changes — it names the owning
entity instead of `"company"`:

```bash theme={null}
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "entityType": "quote",
    "entityId": "QUOTE-ID",
    "fileName": "signed-quote.pdf",
    "contentType": "application/pdf",
    "byteSize": 204813
  }'
# → {"fileId": "...", "versionId": "...", "uploadUrl": "https://storage.googleapis.com/..."}
```

PUT the bytes to the signed URL and finalize exactly as in the upload workflow.

**List the quote's files.** The owner is selected by the `entityType` +
`entityId` query pair (only `ready` files are returned):

```bash theme={null}
curl "https://go.aiinsurance.io/api/v1/companies/{companyId}/files?entityType=quote&entityId=QUOTE-ID" \
  -H "Authorization: YOUR-API-KEY"
# → {"items": [{"id": "...", "entityType": "quote", "entityId": "QUOTE-ID", "displayName": "signed-quote.pdf", ...}], "totalCount": 1}
```

**Share the file onto the policy.** Add a placement rather than uploading the
document twice — the bytes are stored once and both the quote and the policy
see the same version:

```bash theme={null}
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/placements \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"entityType": "policy", "entityId": "POLICY-ID"}'
# → {"placementId": "...", "fileId": "...", "entityType": "policy", "entityId": "POLICY-ID", "folderId": null}
```

See **Sharing a File Across Entities** below for listing every placement,
categorizing per placement, and detaching a file from one owner.

## File categories

A category is a label on a **placement** (so a shared file can be categorized
differently under each owner), stored as free text. Company admins configure a
category list **per entity type** in **Company Settings → File Categories**;
those lists give the vocabulary its membership and its order.

**Read one entity type's vocabulary.** Pass `entityType` — the same lowercase
kebab-case owner slug the rest of this API uses (`company`, `quote`, `policy`,
`exposure`, `event`, `submission`, `person`, `organization`). PascalCase like
`Quote` is rejected with a `400`:

```bash theme={null}
curl "https://go.aiinsurance.io/api/v1/companies/{companyId}/files/categories?entityType=quote" \
  -H "Authorization: YOUR-API-KEY"
# → {"categories": ["Applications", "Loss Runs", "Legacy Import"],
#    "entityType": "quote",
#    "entityTypeCategories": [
#      {"name": "Applications",  "configured": true},
#      {"name": "Loss Runs",     "configured": true},
#      {"name": "Legacy Import", "configured": false}
#    ]}
```

`entityTypeCategories` lists the **configured** categories first, in the order
the admin arranged them, followed by every label actually in use on that entity
type's live placements that no configured category covers —
`"configured": false`. Those unconfigured values are real: an import, an API
write, or a category an admin has since removed. They stay visible and
filterable rather than disappearing. `categories` repeats the same names in the
same order without the flags, for consumers that only need the list. Each list
is scoped to one entity type, so a Policy category never appears under Event.

**Read the whole company's in-use vocabulary.** Omit `entityType`:

```bash theme={null}
curl https://go.aiinsurance.io/api/v1/companies/{companyId}/files/categories \
  -H "Authorization: YOUR-API-KEY"
# → {"categories": ["Applications", "Endorsements", "Loss Runs"]}
```

This is the distinct set of non-null categories across **every** owner's live
placements, each value once, sorted case-insensitively — a category that
survives only on a soft-deleted placement (or a soft-deleted file) is excluded.
It does not consult the configured lists, so a configured category no file uses
yet does not appear here.

**Writes accept any label.** Setting a `category` via **Update File** or
**Update File Placement** is never validated against the configured list — any
non-blank string up to 255 characters is accepted (blank clears the category).
Read the vocabulary and reuse its values to keep it consistent; nothing rejects
a new label.

## Organizing with Folders

```bash theme={null}
# Create a folder for an exposure
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/folders \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"entityType": "exposure", "entityId": "EXPOSURE-ID", "name": "Loss Runs"}'
# → {"id": "folder-uuid"}

# Read the exposure's folder tree
curl "https://go.aiinsurance.io/api/v1/companies/{companyId}/folders?entityType=exposure&entityId=EXPOSURE-ID" \
  -H "Authorization: YOUR-API-KEY"
# → {"folders": [{"id": "folder-uuid", "parentFolderId": null, "name": "Loss Runs"}]}

# Move a file into the folder
curl -X PATCH https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId} \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"folderId": "folder-uuid"}'
# → {"id": "file-uuid"}

# Move a whole batch of the exposure's files into the folder in ONE request.
# All-or-nothing: if any id isn't placed under this owner the whole request is
# rejected and nothing moves. folderId: null moves them to the owner's top level.
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files/bulk-move \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"entityType": "exposure", "entityId": "EXPOSURE-ID", "fileIds": ["file-uuid-1", "file-uuid-2"], "folderId": "folder-uuid"}'
# → {"ids": ["file-uuid-1", "file-uuid-2"]}
```

Only the requesting owner's placement of each file moves — a file shared onto
other entities keeps its placements there. The batch is capped at 100 file ids.

## Sharing a File Across Entities

The same document often belongs on more than one entity — a loss run on both
an Event and its Submission. Add a placement instead of uploading twice; the
bytes are stored once and every placement stays in sync:

```bash theme={null}
# Share an Event's file to a Submission
curl -X POST https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/placements \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"entityType": "submission", "entityId": "SUBMISSION-ID"}'
# → {"placementId": "...", "fileId": "...", "entityType": "submission", ...}

# Where does this file appear?
curl https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/placements \
  -H "Authorization: YOUR-API-KEY"
# → {"placements": [{"placementId": "...", "entityType": "event", "entityDisplayName": "Warehouse Fire", ...}, ...]}

# File it under one of the Submission's folders with its own category
# (the Event's placement is untouched)
curl -X PATCH https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/placements/{placementId} \
  -H "Authorization: YOUR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"folderId": "SUBMISSION-FOLDER-ID", "category": "Loss Runs"}'
# → {"placementId": "...", "fileId": "...", "entityType": "submission", "folderId": "...", "category": "Loss Runs"}

# Remove it from the Submission only (the Event keeps it)
curl -X DELETE https://go.aiinsurance.io/api/v1/companies/{companyId}/files/{fileId}/placements/{placementId} \
  -H "Authorization: YOUR-API-KEY"
# → {"id": "...", "fileId": "...", "fileDeleted": false}
```

Sharing is same-company only, and a duplicate share to an owner the file is
already placed on returns `409 Conflict`. Removing the file's **last**
placement deletes the file itself and reclaims its stored content
(`"fileDeleted": true`).

Folder location and `category` are **per placement**, so once a file is
shared the owner-less `PATCH /v1/files/{fileId}` can no longer address them —
it returns `409 Conflict` pointing at the per-placement update; `displayName`
stays on the file (one name everywhere) and remains renameable there.
