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

# Upload assets

> Learn how to upload images and files to a Cactal website: begin an upload with a description and category, PUT the bytes to a presigned URL, finalize the asset, then find it again and use it in CMS fields and source code.

Asset uploads happen in three calls: begin, PUT to object storage, finalize. An asset is usable only after finalize succeeds.

## Prerequisites

* An API key with `content_editor` access or higher — see [Create an API key](/docs/create-an-api-key)
* A website — see the [Quickstart](/docs/quickstart)

## Kinds and limits

| Kind    | Max size                    | Accepts                                                                                                                                         |
| ------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `image` | 10,485,760 bytes (10 MiB)   | `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/avif`, `image/svg+xml`, `image/bmp`, `image/x-icon`, `image/vnd.microsoft.icon`    |
| `file`  | 104,857,600 bytes (100 MiB) | Documents (PDF, Office, OpenDocument, text, JSON), fonts (WOFF2, WOFF, TTF, OTF, TTC), audio, video, archives, raster images, `.riv`, `.lottie` |

Upload SVG as an `image`. It is checked at finalize: scripts, event handlers, `foreignObject`, DTD entities, and external references are rejected with the reason, and an accepted SVG gets a rasterized PNG `previewUrl` that agents and the favicon fallback use. See [Assets](/docs/concepts/assets#svg-previews).

`image` assets get responsive variants and dimension metadata on the CDN. `file` assets are delivered as-is. Font, Lottie, and Rive uploads can use `mimeType: "application/octet-stream"`; Cactal normalizes it from the filename extension. Legacy font MIME aliases are also stored as the canonical `font/*` type.

<Steps>
  <Step title="Begin the upload">
    `POST /v1/websiteAssets` registers the asset as `pending` and returns a presigned upload URL. Include a one-line `description` and, for images, a `category` (`logo`, `icon`, `photo`, `illustration`, `screenshot`, `graphic`, `background`, or `other`) so the asset can be found later by search instead of by filename. Both are optional and editable afterwards.

    ```bash Begin an upload theme={null}
    curl -X POST 'https://api.cactal.ai/v1/websiteAssets' \
      -H "Authorization: Bearer $CACTAL_API_KEY" \
      -H 'Content-Type: application/json' \
      -d '{
        "websiteId": "V1StGXR8_Z5jdHi6B-myT",
        "kind": "image",
        "filename": "hero.png",
        "mimeType": "image/png",
        "byteSize": 482113,
        "description": "Roastery floor at golden hour with the copper drum in front, for the homepage hero",
        "category": "photo"
      }'
    ```

    ```json Response theme={null}
    {
      "assetId": "mP4qR6sT8uV0wX2yZ3aB5",
      "upload": {
        "url": "https://storage.example.com/website-assets/mP4qR6sT8uV0wX2yZ3aB5?X-Amz-Signature=...",
        "method": "PUT",
        "headers": { "Content-Type": "image/png" },
        "expiresAt": "2026-07-08T15:21:00.000Z"
      }
    }
    ```

    `byteSize` must match the exact size of the bytes you upload. The upload URL expires 900 seconds (15 minutes) after issue; begin again for a fresh URL.
  </Step>

  <Step title="PUT the bytes to the presigned URL">
    Send the raw bytes with `PUT` to `upload.url`, including every header from `upload.headers`. The signature binds the content type and length, so different values fail.

    <CodeGroup>
      ```bash Upload the bytes theme={null}
      curl -X PUT 'https://storage.example.com/website-assets/mP4qR6sT8uV0wX2yZ3aB5?X-Amz-Signature=...' \
        -H 'Content-Type: image/png' \
        --data-binary @hero.png
      ```

      ```ts TypeScript theme={null}
      const bytes = await Bun.file('hero.png').arrayBuffer()
      const put = await fetch(upload.url, {
      	method: upload.method,
      	headers: upload.headers,
      	body: bytes
      })
      if (!put.ok) throw new Error(`Upload failed: ${put.status}`)
      ```
    </CodeGroup>

    No `Authorization` header is needed here — the signature in the URL authorizes this single upload.
  </Step>

  <Step title="Finalize the asset">
    `POST /v1/websiteAssets/{assetId}/finalize` verifies the uploaded object and flips the asset to `ready`. For images, Cactal also records the intrinsic `width` and `height`. A PNG, JPEG, WebP, GIF, AVIF, or BMP image finalized without a description gets one, plus a category, written in the background, within a monthly allowance per organization.

    ```bash Finalize theme={null}
    curl -X POST 'https://api.cactal.ai/v1/websiteAssets/mP4qR6sT8uV0wX2yZ3aB5/finalize' \
      -H "Authorization: Bearer $CACTAL_API_KEY"
    ```

    ```json Response theme={null}
    {
      "id": "mP4qR6sT8uV0wX2yZ3aB5",
      "kind": "image",
      "filename": "hero.png",
      "mimeType": "image/png",
      "description": "Roastery floor at golden hour with the copper drum in front, for the homepage hero",
      "category": "photo",
      "byteSize": 482113,
      "width": 1600,
      "height": 900,
      "url": "https://cdn.cactal.app/website-assets/mP4qR6sT8uV0wX2yZ3aB5",
      "status": "ready",
      "createdAt": "2026-07-08T15:06:00.000Z"
    }
    ```

    <Warning>
      Always finalize before you reference an asset anywhere. Pending assets are excluded from asset lists and never render in CMS reads. Pending uploads that are never finalized are cleaned up by a background sweep.
    </Warning>
  </Step>

  <Step title="Use the asset">
    Reference the asset id from CMS `image`, `gallery`, and `file` fields when you create or update items:

    ```bash Attach to a CMS item theme={null}
    curl -X PATCH 'https://api.cactal.ai/v1/cms/items/kQ9rS1tU3vW5xY7zA8bC0' \
      -H "Authorization: Bearer $CACTAL_API_KEY" \
      -H 'Content-Type: application/json' \
      -d '{
        "images": { "cover": { "assetId": "mP4qR6sT8uV0wX2yZ3aB5", "altText": "Hero image" } },
        "files": { "brochure": { "assetId": "nQ5rS7tU9vW1xY3zA4bC6" } }
      }'
    ```

    `image` takes one value, `gallery` takes an array of up to 25, `file` takes one. Image values also accept a bare asset-id string. On reads, image fields hydrate into objects with `url`, `originalUrl`, `srcSet`, and `variants` served from the CDN; file fields hydrate with a `url`, `filename`, `mimeType`, and `byteSize`.

    In source code, read those hydrated URLs from your page's CMS queries instead of hard-coding asset ids.

    To use a ready PNG, SVG, or ICO image as the website favicon, set its asset id directly (an SVG favicon also links its PNG preview for browsers that ignore SVG icons):

    ```bash Set the website favicon theme={null}
    curl -X PUT 'https://api.cactal.ai/v1/websites/V1StGXR8_Z5jdHi6B-myT/favicon' \
      -H "Authorization: Bearer $CACTAL_API_KEY" \
      -H 'Content-Type: application/json' \
      -d '{"assetId":"mP4qR6sT8uV0wX2yZ3aB5"}'
    ```

    The favicon changes immediately on every host and does not require a source lease or publish. Send `{"assetId":null}` to clear it and restore the Cactal fallback.
  </Step>

  <Step title="Find, update, and manage">
    Search the library instead of paging through it:

    ```bash Find the hero photo theme={null}
    curl 'https://api.cactal.ai/v1/websiteAssets?websiteId=V1StGXR8_Z5jdHi6B-myT&kind=image&category=photo&search=roastery%20hero' \
      -H "Authorization: Bearer $CACTAL_API_KEY"
    ```

    <Check>
      You should see your asset in `items` with its `description`, `category`, and stable CDN `url`. Add `type=image|video|audio|font|document` to narrow by the broad MIME family, for example `type=video` for every clip regardless of container; it combines with `kind`, `category`, and `search`. List rows are compact; `GET /v1/websiteAssets/{assetId}` returns the full delivery shape plus `status` (`pending` or `ready`).
    </Check>

    Fix a description, category, or filename at any time:

    ```bash Update an asset theme={null}
    curl -X PATCH 'https://api.cactal.ai/v1/websiteAssets/mP4qR6sT8uV0wX2yZ3aB5' \
      -H "Authorization: Bearer $CACTAL_API_KEY" \
      -H 'Content-Type: application/json' \
      -d '{ "description": "Copper roasting drum at golden hour, homepage hero", "category": "photo" }'
    ```

    `DELETE /v1/websiteAssets/{assetId}` archives the asset (`{ "deleted": true }`); it disappears from lists and stops resolving in CMS reads.
  </Step>
</Steps>

## Import from a public URL

When the file already lives on the public web, skip the three-step upload. `POST /v1/websiteAssets/import` fetches up to ten URLs server-side and returns ready assets in one call:

```bash theme={null}
curl -X POST 'https://api.cactal.ai/v1/websiteAssets/import' \
  -H 'Authorization: Bearer $CACTAL_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "websiteId": "V1StGXR8_Z5jdHi6B-myT",
    "items": [
      {
        "url": "https://www.example.com/images/logo.svg",
        "filename": "logo.svg",
        "description": "Roastery wordmark in copper on transparent, for the header",
        "category": "logo"
      },
      {
        "url": "https://www.example.com/fonts/Inter-Regular.woff2",
        "description": "Inter Regular body font from the current site"
      }
    ]
  }'
```

The response has `assets`, each the same shape as a finalized upload plus `sourceUrl`, and `errors` for items that failed with a `type` of `invalid_url`, `non_public_address`, `unreachable`, `too_large`, `unsupported_type`, `signature_expired` (a signed link, such as a Meta CDN URL, that has already expired; read the page again for a fresh one), or `rights_reserved` (a photo on a review platform such as Yelp, Tripadvisor, or Google Maps place photos, owned by whoever posted it). Each item takes the same optional `description` and image `category` as an upload. Import is for media only: images, fonts, PDF, audio, video, dotLottie, and Rive. Text, data, documents, and archives are refused, images, fonts, and PDFs must parse as what they claim, and hosts that resolve to private or reserved addresses are rejected. Items are fetched one at a time and the upload kind and MIME rules apply. Each download is capped at 31,457,280 bytes (30 MiB) because the file is buffered in memory before it is stored; larger files, such as most hero videos, must go through the three-step upload, which accepts up to the kind's limit. Import only files you have the right to use.

## Troubleshooting

| Error                                                 | Cause                                                      | Fix                                                      |
| ----------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------- |
| `400` `Mime type "..." is not allowed`                | The `mimeType` is outside the kind's allowlist.            | Use a listed type, or kind `file` for broader formats.   |
| `400` `byteSize ... exceeds maximum ...`              | The declared size is over the kind's cap.                  | Compress or split the file.                              |
| `400` `category applies only to image assets`         | A `category` was sent for a `file` upload or update.       | Drop `category`; describe files with `description` only. |
| `409` `Asset ... has not been uploaded yet`           | Finalize ran before the PUT completed.                     | PUT the bytes, then finalize again.                      |
| `400` `uploaded size ... does not match declared ...` | The uploaded byte count differs from `byteSize`.           | Begin a new upload with the correct size.                |
| `409` `Asset ... is already finalized`                | Finalize was called twice.                                 | Treat the asset as ready; no action needed.              |
| PUT returns `403`                                     | The presigned URL expired (15 minutes) or headers changed. | Begin a new upload and retry with the returned headers.  |

## Next steps

* [Manage CMS content](/docs/guides/manage-cms-content) to build items around your assets
* [Assets concepts](/docs/concepts/assets) for CDN delivery and responsive image variants
