# Assets

> The library of everything your organisation has generated or uploaded — list and search it, upload files, organise them into folders, delete them.

Every file the API makes for you — an image, a clip, a skybox, stored speech, a transcript — lands in your organisation's **asset library**, and so does every file you upload. Each one is an asset with an `ast_…` id: the id you pass as a reference image, a video frame, or audio to transcribe.

```bash title="curl"
curl "https://api.lumavox.ai/v1/assets?media_type=image&limit=20" \
  -H "Authorization: Bearer $API_KEY"
```

```json title="Response"
{
  "data": [
    {
      "id": "ast_a1B2c3D4e5F6g7H8",
      "name": "A weathered brass diving helmet",
      "kind": "output",
      "media_type": "image",
      "content_type": "image/jpeg",
      "size_bytes": 412377,
      "duration_seconds": null,
      "width": 1328,
      "height": 752,
      "peaks": null,
      "folder_id": null,
      "capability": "image",
      "origin": "api",
      "thumbnail_url": "https://…",
      "status": "ready",
      "error": null,
      "created_at": "2026-10-24T09:14:21.904Z"
    }
  ],
  "next_cursor": "eyJrIjoiYXNzZXRzOmNyZWF0ZWRfYXQi…"
}
```

## Scopes

| Scope | Allows |
|---|---|
| `assets` | Listing and reading assets and folders, and **uploading** files. |
| `assets:write` | Renaming, moving and deleting assets, and creating, changing and deleting folders. |

They are separate on purpose: a key that reads your generations back cannot empty the library. See [Authentication](https://docs.lumavox.ai/authentication.md).

## List and search

`GET /v1/assets` returns the library newest first, a page at a time.

| Query | What it does |
|---|---|
| `limit` | Page size, 1–200. Default 50. |
| `cursor` | The `next_cursor` from the previous page. |
| `kind` | `input` (you uploaded it) or `output` (a service made it). |
| `media_type` | `image`, `video`, `audio` or `data`. |
| `capability` | The capability that produced it, such as `image`, `video` or `tts`. Uploads have none. |
| `request_id` | `req_…` — everything one API call produced. |
| `search` | Matches anywhere in the name, case-insensitively, up to 120 characters. Taken literally — `%` and `_` are ordinary characters. |
| `sort` | `created_at` (default), `name`, `size_bytes` or `duration_seconds`. |
| `order` | `desc` (default) or `asc`. |

The answer is a page: keep passing `next_cursor` back as `cursor`, with the same `sort`, until it comes back `null` ([Conventions](https://docs.lumavox.ai/conventions.md#lists-and-pages)). A cursor belongs to the sort it was made with: one from another sort, or one that cannot be read, is a `422` — never the first page again.

Each item carries a `thumbnail_url` — a short-lived link to a 512-pixel square preview — for images, video and skyboxes. Audio has none; it has `peaks` instead, a waveform of magnitudes from 0 to 100 for drawing (an empty list for silence, `null` when never measured). `duration_seconds` is how long audio or video runs, `null` for a still.

## Read one asset

`GET /v1/assets/{asset_id}` returns everything in the listing plus links to the file itself.

```bash title="curl"
curl https://api.lumavox.ai/v1/assets/ast_a1B2c3D4e5F6g7H8 \
  -H "Authorization: Bearer $API_KEY"
```

```json title="Response"
{
  "id": "ast_a1B2c3D4e5F6g7H8",
  "name": "A weathered brass diving helmet",
  "kind": "output",
  "media_type": "image",
  "content_type": "image/jpeg",
  "size_bytes": 412377,
  "duration_seconds": null,
  "width": 1328,
  "height": 752,
  "peaks": null,
  "folder_id": null,
  "capability": "image",
  "origin": "api",
  "thumbnail_url": "https://…",
  "status": "ready",
  "error": null,
  "created_at": "2026-10-24T09:14:21.904Z",
  "request_id": "req_k3J9mQ2vX7pL4nRt",
  "end_user_id": null,
  "meta": null,
  "url": "https://…",
  "url_expires_at": "2026-10-24T09:45:12.000Z",
  "preview_url": null,
  "variants": [
    { "name": "50", "width": 664, "height": 376, "size_bytes": 118204, "url": "https://…" },
    { "name": "25", "width": 332, "height": 188, "size_bytes": 34110, "url": "https://…" },
    { "name": "12.5", "width": 166, "height": 94, "size_bytes": 9873, "url": "https://…" }
  ]
}
```

| Field | What it is |
|---|---|
| `url` | A time-limited link to the file; `null` when file storage is unavailable. |
| `url_expires_at` | When `url`, `preview_url` and the variants' links stop working — fifteen minutes after the response. `null` when there is no `url`. |
| `preview_url` | A displayable copy when `url` is not one. Set for skyboxes, whose `url` is a Radiance `.hdr`; `null` otherwise. |
| `variants` | Smaller copies of an image — `50`, `25` and `12.5` per cent of the original, largest first — each with its own `url`. Empty for anything that has none. |
| `request_id` | The `req_…` of the call that produced it. |
| `end_user_id` | The end user it was made for, if any. |
| `meta` | Anything else recorded about it. |

**Links are made fresh on every response and never stored.** Read the asset again for a new one whenever an old link has expired; the asset itself stays until you delete it.

An id that does not exist and an id that belongs to another organisation are the same 404.

## Upload a file

Uploads go in three steps, and **the bytes never pass through the API**: you ask for an upload URL, `PUT` the file straight to storage, then confirm.

**1. Ask where to upload** — name the type you are about to send:

```bash title="curl"
curl https://api.lumavox.ai/v1/assets/upload-url \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "content_type": "image/png" }'
```

```json title="Response"
{
  "asset_id": "ast_a1B2c3D4e5F6g7H8",
  "url": "https://…",
  "expires_at": "2026-10-24T09:45:00.000Z"
}
```

Nothing is created yet. The URL accepts the upload until `expires_at`, fifteen minutes after you asked; ask for another if it runs out.

**2. Upload the bytes** to that URL with a `PUT`, unmodified, with exactly the `Content-Type` you named — it is signed into the URL — and **no** `Authorization` header:

```bash title="curl"
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  --data-binary @photo.png
```

**3. Confirm** once the `PUT` has succeeded:

```bash title="curl"
curl https://api.lumavox.ai/v1/assets/uploaded \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "asset_id": "ast_a1B2c3D4e5F6g7H8",
    "content_type": "image/png",
    "filename": "photo.png",
    "folder_id": "fld_a1B2c3D4e5F6g7H8"
  }'
```

The answer is a **202** with the asset, `status: "pending"`. The stored file's type and size are checked, then it is converted in the background. Poll `GET /v1/assets/{asset_id}` until `status` is `ready` — or `failed`, with the reason in `error`. A pending asset cannot yet be used as a reference or a frame.

`name` and `folder_id` are optional on the confirm. Without a `name`, the asset is named after `filename` without its extension.

### What you can upload

| Kind | Types | Largest |
|---|---|---|
| Images | `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `image/heic`, `image/heif`, `image/tiff`, `image/bmp`, `image/avif` | `limits.uploads.image_bytes` |
| Audio | `audio/mpeg`, `audio/mp3`, `audio/mp4`, `audio/x-m4a`, `audio/aac`, `audio/wav`, `audio/x-wav`, `audio/wave`, `audio/vnd.wave`, `audio/webm`, `audio/ogg`, `audio/opus`, `audio/flac`, `audio/x-flac` | `limits.uploads.audio_bytes` |

The largest file of each kind is the platform's, listed on [Limits](https://docs.lumavox.ai/limits.md) with what it is now.

Audio uploads are what [transcription](https://docs.lumavox.ai/speech/transcription.md) reads. An upload that is empty, over the limit, or stored as a different type from the one confirmed is deleted and refused with a 422.

Your organisation can hold a limited number of uploaded files (`limits.org.asset_uploads` on [Limits](https://docs.lumavox.ai/limits.md)). At the limit, asking for an upload URL — or confirming one — is a 409 that names the number; delete some uploads to make room.

### Upload errors

| Status | When |
|---|---|
| 404 | (Confirm) `folder_id` is not one of your folders. |
| 409 | The `PUT` has not landed yet, the upload was already confirmed, or your organisation is at its upload limit. |
| 422 | A type that is not accepted, or a stored file that is empty, too large, or not the type confirmed. `issues` names `content_type` or `asset_id`. |
| 503 | File storage is unavailable. |

## Rename and move

`PATCH /v1/assets/{asset_id}` needs `assets:write`. Send `name` (1–200 characters), `folder_id`, or both:

```bash title="curl"
curl -X PATCH https://api.lumavox.ai/v1/assets/ast_a1B2c3D4e5F6g7H8 \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Helmet, final", "folder_id": null }'
```

`folder_id` has three states: **leave it out** to keep the asset where it is, send **`null`** to move it to the root, or send an `fld_…` id to move it there.

To move many at once, `POST /v1/assets/move` with up to 500 ids and a `folder_id` (or `null` for the root). It answers `{"affected": n}` — ids that are not yours are skipped and simply not counted.

## Delete

`DELETE /v1/assets/{asset_id}` needs `assets:write`. The asset leaves the library at once, and its stored bytes — every copy and the thumbnail — are removed shortly after. It answers **204**, with no body.

To delete many, `POST /v1/assets/delete` with up to 500 ids:

```bash title="curl"
curl https://api.lumavox.ai/v1/assets/delete \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "asset_ids": ["ast_a1B2c3D4e5F6g7H8", "ast_Z9y8X7w6V5u4T3s2"] }'
```

It answers `{"affected": n}`: how many were deleted. Ids that are not yours, or already deleted, are skipped.

## Folders

Folders organise the library into a tree. Image, video, skybox, stored-audio and template requests take a `folder_id` to file their result straight into one.

| Call | Scope | What it does |
|---|---|---|
| `GET /v1/asset-folders` | `assets` | One level of the tree, alphabetically: the top level, or the children of `parent_id`. Each folder has `asset_count` and `folder_count`. |
| `GET /v1/asset-folders/{folder_id}` | `assets` | One folder, with its `ancestors` from the top down — a breadcrumb trail. |
| `POST /v1/asset-folders` | `assets:write` | Create a folder: `name` (1–120 characters) and an optional `parent_id`. Answers 201. |
| `PATCH /v1/asset-folders/{folder_id}` | `assets:write` | Rename it, or move it: `parent_id` left out keeps it, `null` moves it to the top level. |
| `DELETE /v1/asset-folders/{folder_id}` | `assets:write` | Remove the folder. **Its contents move up to its parent** — nothing else is deleted. Answers how many moved: `{"moved_assets": 12, "moved_folders": 1}`. |

```bash title="curl"
curl https://api.lumavox.ai/v1/asset-folders \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Campaign — spring" }'
```

```json title="Response — 201"
{
  "id": "fld_a1B2c3D4e5F6g7H8",
  "name": "Campaign — spring",
  "parent_id": null,
  "created_at": "2026-10-24T09:50:00.000Z",
  "asset_count": null,
  "folder_count": null
}
```

`asset_count` and `folder_count` are counted only when you read folders; a create or an edit answers them `null`.

The rules of the tree:

- Two folders side by side cannot share a name (409).
- Folders nest at most 40 deep, and one level holds at most 500 folders — so a level's listing is always one page, with `next_cursor` `null`.
- A folder cannot be moved inside itself.

## See also

- [Generations](https://docs.lumavox.ai/library/generations.md) — the requests behind the outputs.
- [Images](https://docs.lumavox.ai/media/images.md) and [Video](https://docs.lumavox.ai/media/videos.md) — using assets as references and frames.
- [The assets reference](https://docs.lumavox.ai/reference/assets.md) — every field.
