# Booster file storage

> Upload, list, link and delete the files of a booster panel from your own server with an API key.

A booster panel can keep files: documents its users upload, invoices and exports that a
workflow generates, product images. The endpoints on this page let your server work with
those files with an API key: upload a generated report, list what visitors sent, get a
download link, clean up.

The endpoints are under `/api/v3/booster/storage/private/`. Panels themselves reach their
files through a separate access path that is not covered here.

## Concepts

### Folders and paths

Files are addressed by a **path**: a top-level **folder**, optional sub-folders and the
file name, separated by `/`, for example `invoices/2026/INV-0042.pdf`.

- The folder name may contain letters, digits, `_` and `-`.
- Sub-folder and file names may contain any characters except control characters and
  `/ \ : * ? " < > | # % '`; they cannot be `.` or `..` or start or end with a space.
- A path has at least two parts (`<folder>/<name>`), at most 8 levels and 512 characters.
- Folders and sub-folders are not created separately: they exist as long as they hold a
  file. A path is unique within a panel.

### File storage settings

The panel owner turns the file storage on and defines folders in the panel's module
settings in the app. For each folder (or `*` for all others) the settings say who may
upload, read, list, change and delete files, which file types it accepts, how large a
file may be, how many files it may hold and whether files are deleted after a number of
days. A folder may give **permanent public links** (for images shown on a public page).

### Who can access a panel's files

Requests act as the user who owns the API key. That user must own the panel (be its
creator, or a member of the group that owns it) or have the panel shared with them,
directly or through a group. The API key must carry the matching
[scope](authentication.md#scopes):

| Scope | Endpoints |
|---|---|
| `ayeto.booster.storage` | `list`, `count`, `get`, `url`, `usage` |
| `ayeto.booster.storage.write` | `upload`, `upload-b64`, `update`, `delete`, `delete-many` |

The write scope does not include the read scope; a key that reads and writes needs both.
The endpoints work also while the panel's file storage is switched off, so you can still
read and clean up stored files.

For requests on this page the folder settings apply like this:

- An operation set to **none** for a folder is disabled for everyone, including this API (`403`).
- Every other level allows API requests, on all files of the folder.
- A folder without settings (and without a `*` entry) is accessible only to the API and the owner.
- The accepted file types, the file size limit and the limits of files and data per folder
  **do** apply to uploads through this API.

### Files

| Field | Type | Description |
|---|---|---|
| `id` | UUID | Identifier of the file. |
| `path` | string | Full path, e.g. `invoices/2026/INV-0042.pdf`. |
| `folder` | string | Top-level folder. |
| `name` | string | File name (last part of the path). |
| `size` | integer | Size in bytes. |
| `content_type` | string | Type of the content. Known formats (PNG, JPEG, GIF, WebP, AVIF, PDF, common audio and video) are recognised from the content and win over the declared type; HTML, SVG and XML are recognised too. |
| `sha256` | string | SHA-256 of the content (hex). |
| `metadata` | object | Your data about the file (JSON object, at most 4 KB, keys cannot start with `$` or contain `.`). |
| `record` | object or `null` | The [booster database](booster-database.md) record the file belongs to: `{collection, key}`. |
| `owner` | UUID or `null` | The panel user who uploaded the file; `null` for files uploaded through the API. |
| `created`, `updated` | integer | Upload and last change time in milliseconds since the epoch. |
| `expires_at` | string or `null` | When the file is deleted, in folders that keep files for a number of days. |
| `url` | string or `null` | Link that shows or downloads the file (see [Download links](#download-links)). |
| `download_url` | string or `null` | Link that always downloads the file. |

### Files and database records

A file can belong to a record of the panel's [database](booster-database.md): send the
record's collection and key with the upload or set it later with `update`. The record must
exist in the panel's database. By default a file is deleted when its record is deleted; the
folder settings can keep it. Store the file's `id` (or `path`) in the record value when
your panel needs to find the file from the record.

### Download links

Links are absolute URLs under `/api/v3/booster/storage/file/` and need no headers, so they
work in `<img src>`, `<a href>` and from other servers.

- A link is **signed** and valid for 60 minutes. Ask for a fresh one with
  [Get download links](#get-download-links) when you need it, rather than storing links.
- In folders with permanent public links, `url` never expires. It stops working when the
  folder no longer gives public links or the file is deleted.
- Images, audio, video and PDF are shown in the browser; other types (and every
  `download_url`) are downloaded. HTML, SVG and XML files are always downloaded.
- Image links accept `?w=` and `?h=` (pixels) for a resized copy, e.g. thumbnails.
- Links support `Range` requests (seeking in audio and video).

### Limits

| Limit | Value |
|---|---|
| File size | the folder's limit, at most 100 MB |
| Data per panel | the panel's limit, at most 1 GB by default |
| Metadata | 4 KB of JSON |
| Files returned by one `list` | 100 without paging, up to 1000 with `limit_from` / `limit_to` |
| `ids` in `url` and `delete-many` | 100 |
| Base64 upload | use `upload` (multipart) for files larger than a few MB, [direct upload](#upload-a-large-file-directly) for large ones |

Files also count against the storage quota of the panel owner or, for panels of an
organization, of the organization.

## Common request headers and errors

| Header | Description |
|---|---|
| `uni-api-key` | Your API key. See [Authentication](authentication.md). |
| `panel-id` | ID of the panel whose files you access. |
| `Content-Type` | `application/json`, or `multipart/form-data` for `upload`. |

Errors are JSON of the form `{"detail": "..."}` (see [Errors](conventions.md#errors)).
These can come from every endpoint:

| Status | `detail` | Cause |
|---|---|---|
| `401` | `API key not provided` / `API key is invalid` | The key is empty, unknown, disabled or lacks the endpoint's scope. |
| `403` | `permission denied` | You neither own the panel nor have it shared with you. |
| `403` | `operation '<operation>' is disabled for folder '<folder>'` | The folder settings set this operation to **none**. The operation is `upload`, `read`, `list`, `update` or `delete`. |
| `404` | `panel not found` | No panel with this ID, or the panel is disabled. |
| `404` | `file not found` | No file with this ID or path in the panel. |
| `422` | validation error | The `panel-id` header is missing or not a UUID, or the body does not match the request model. |
| `429` | rate limit message | Too many requests; see [Rate limits](conventions.md#rate-limits). |

## Upload a file

Stores a file. Send it as `multipart/form-data`.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/storage/private/upload` |
| Scope | `ayeto.booster.storage.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Form field | Type | Required | Description |
|---|---|---|---|
| `file` | file | yes | The content. |
| `path` | string | yes | Target path. A path ending with `/` is a directory: the uploaded file name is appended. |
| `metadata` | string | no | JSON object with your data about the file. |
| `record_collection`, `record_key` | string, UUID | no | The database record the file belongs to (both or neither). |
| `overwrite` | boolean | no | Replace a file at the same path (needs the delete right in its folder). Default `false`. |
| `rename` | boolean | no | When the path is taken, store as `name (1).ext`, `name (2).ext`, … Default `false`. |

### Response

The stored [file](#files) with links.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `409` | `a file '<path>' already exists` | The path is taken and neither `overwrite` nor `rename` was sent. |
| `422` | `path must start with a folder: '<folder>/<name>'` | The path has only one part. |
| `422` | `folder name can only contain letters, digits, '_' and '-'` | Invalid folder name. |
| `422` | `path segments cannot …` | Invalid sub-folder or file name (see [Folders and paths](#folders-and-paths)). |
| `422` | `folder '<folder>' does not accept <type> files` | The folder does not accept this file type. |
| `422` | `the file is larger than <n> MB` | The file exceeds the folder's or the general size limit. |
| `422` | `the file content is <type>, not <declared>` / `the file content is not <declared>` | The content does not match the declared image or PDF type. |
| `422` | `files in this folder must belong to a database record` | The folder requires a record and none was sent. |
| `422` | `record '<key>' is not in collection '<collection>'` | The record exists in another collection. |
| `404` | `Database record not found` | No such record in the panel's database. |
| `403` | `database access is not allowed for this panel` | A record was sent but the panel has no database. |
| `422` | `metadata must be a JSON object` / `metadata is larger than 4096 bytes` | Invalid metadata. |
| `403` | `folder '<folder>' has reached the maximum of <n> files` / `folder '<folder>' is full` | Folder limit reached. |
| `403` | `the file storage of this panel is full` | Panel limit reached. |
| `422` | `storage quota exceeded` / `organization storage quota exceeded` | The owner's (organization's) storage quota is full. |

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/storage/private/upload" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -F "file=@INV-0042.pdf;type=application/pdf" \
  -F "path=invoices/2026/" \
  -F 'metadata={"customer": "ACME", "total": 1250}' \
  -F "record_collection=orders" \
  -F "record_key=8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70"
```

```json
{
  "id": "5b0a1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d",
  "path": "invoices/2026/INV-0042.pdf",
  "folder": "invoices",
  "name": "INV-0042.pdf",
  "size": 48213,
  "content_type": "application/pdf",
  "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "metadata": {"customer": "ACME", "total": 1250},
  "record": {"collection": "orders", "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70"},
  "owner": null,
  "created": 1791374100000,
  "updated": 1791374100000,
  "expires_at": null,
  "url": "https://ayeto.ai/api/v3/booster/storage/file/3d2c5a8e-…/5b0a1c2d-…/1791377700i.Xk2…/INV-0042.pdf",
  "download_url": "https://ayeto.ai/api/v3/booster/storage/file/3d2c5a8e-…/5b0a1c2d-…/1791377700a.p9Q…/INV-0042.pdf"
}
```

## Upload a file as base64

The same as [Upload a file](#upload-a-file) with a JSON body, for small files.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/storage/private/upload-b64` |
| Scope | `ayeto.booster.storage.write` |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `path` | string | yes | Target path; its last part is the file name. |
| `data` | string | yes | Base64 content or a data URL (`data:text/csv;base64,…`). |
| `content_type` | string | no | Declared type (otherwise from the data URL or the file extension). |
| `metadata`, `record`, `overwrite`, `rename` | | no | As in [Upload a file](#upload-a-file); `record` is `{collection, key}`. |

### Response and errors

As in [Upload a file](#upload-a-file), plus `422 data is not valid base64`.

### Example

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/storage/private/upload-b64" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"path": "exports/orders-2026-10.csv", "data": "data:text/csv;base64,aWQsdG90YWwKMSwxMjUw"}'
```

## Upload a large file directly

Large files (tens to hundreds of MB) can go straight to the storage service instead of
through the API, in three steps: announce the file, POST it to the returned URL, complete
the upload. Whether direct uploads are available depends on the AYETO installation; when
they are not, `upload/init` answers `409 direct upload is not available, use the upload route`
and you use [Upload a file](#upload-a-file).

| | |
|---|---|
| Endpoints | `POST /api/v3/booster/storage/private/upload/init`, `POST /api/v3/booster/storage/private/upload/complete` |
| Scope | `ayeto.booster.storage.write` |

1. `upload/init` with `path`, `filename` (when the path ends with `/`), `content_type`, `size`
   (exact size in bytes) and optionally `metadata`, `record`, `overwrite`, `rename`. The folder
   rules, type and limits are checked as for an upload. The response is
   `{upload_id, url, fields, expires_at}`.
2. `POST` to `url` as `multipart/form-data`: every entry of `fields` as a form field, then the
   file as the field `file` (it must be the last part). The file must have exactly the announced
   size and type. This request goes to the storage service, without your API key.
3. `upload/complete` with `{"upload_id": "…"}` before `expires_at` (one hour). The uploaded
   content is checked again (size, type from the content) and the [file](#files) is returned.
   An upload that is never completed is deleted.

| Status | `detail` | Cause |
|---|---|---|
| `409` | `direct upload is not available, use the upload route` | Use [Upload a file](#upload-a-file). |
| `404` | `upload not found` | Unknown or expired `upload_id`, or already completed. |
| `422` | `the file was not uploaded` / `the uploaded file has another size than announced` | Step 2 did not store the announced file. |

Other errors as in [Upload a file](#upload-a-file).

## List files

Returns the files directly in a directory and its sub-folders. Without `folder` it returns
the panel's top-level folders.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/storage/private/list` |
| Scope | `ayeto.booster.storage` |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `folder` | string | no | Top-level folder. Without it only `folders` is returned. |
| `prefix` | string | no | Sub-folder inside the folder, e.g. `2026` or `2026/q1`. |
| `recursive` | boolean | no | Also files in sub-folders at any depth. Default `false`. |
| `params` | object | no | `filters`, `sort_key`, `sort_order`, `limit_from`, `limit_to` as in the [booster database](booster-database.md#querying-records). Fields: `name`, `path`, `size`, `content_type`, `owner`, `created`, `updated`, `record.collection`, `record.key`, `metadata.<path>`. Newest first by default. |
| `links` | boolean | no | Include `url` and `download_url`. Default `true`. |

### Response

`{"files": [...], "folders": [...]}`: [files](#files) of the page and the names of the
sub-folders directly in the directory.

### Example

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/storage/private/list" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"folder": "invoices", "prefix": "2026", "params": {"filters": [["metadata.customer", "==", "ACME"]]}}'
```

## Count files

The number of files a [list](#list-files) with the same `folder`, `prefix`, `recursive`
(default `true` here) and `params.filters` would return across all pages. Without `folder`,
all files of the panel.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/storage/private/count` |
| Scope | `ayeto.booster.storage` |

## Get a file

One file with fresh links, by `id` or `path`.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/storage/private/get` |
| Scope | `ayeto.booster.storage` |

```json
{"path": "invoices/2026/INV-0042.pdf"}
```

## Get download links

Fresh links of up to 100 files.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/storage/private/url` |
| Scope | `ayeto.booster.storage` |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `ids` | array of UUID | yes | Files (1–100). |
| `ttl_minutes` | integer | no | Shorter lifetime than the default 60 minutes. |
| `download` | boolean | no | Links that always download. Default `false`. |

### Response

An array of `{id, url, expires_at}`; `expires_at` (milliseconds) is `null` for permanent
public links.

## Update a file

Renames or moves a file, replaces its metadata or its record. A move to another folder
needs the upload right there and the delete right in the current folder, and the file must
fit the new folder's type and size rules. The content never changes; upload with
`overwrite` to replace it.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/storage/private/update` |
| Scope | `ayeto.booster.storage.write` |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | UUID | yes | The file. |
| `path` | string | no | New path. |
| `metadata` | object | no | New metadata (replaces the old). |
| `record` | object | no | New record `{collection, key}`. |
| `unlink_record` | boolean | no | Remove the record link. |

### Response and errors

The updated [file](#files). Errors as in [Upload a file](#upload-a-file); `409` when the new
path is taken.

## Delete files

| | |
|---|---|
| Endpoints | `POST /api/v3/booster/storage/private/delete` (`{"id": "…"}`), `POST /api/v3/booster/storage/private/delete-many` (`{"ids": [...]}`, up to 100) |
| Scope | `ayeto.booster.storage.write` |

Returns `{"accepted": true}`. Links of a deleted file stop working at once. `delete-many`
deletes the files in order and stops at the first one that fails.

## Storage usage

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/storage/private/usage` (empty body) |
| Scope | `ayeto.booster.storage` |

```json
{"files": 214, "size": 73400320, "limit": 1073741824, "folders": {"invoices": 52428800, "products": 20971520}}
```

`limit` is the panel's limit in bytes (`0` = only the owner's storage quota applies).

## Events

Files stored, changed (moved, new metadata or record) and deleted through this API are
events of the panel like any other: they start [workflows](workflows.md) with a booster file
trigger on the panel, and the panel's webhooks that subscribe to file events receive them.
Deletions by retention or together with a record are events too.
