---
title: "Asset Import API"
url: "https://glokki.com/docs/asset-import-api"
locale: "en"
updated: "2026-10-14T21:05:53.245Z"
---

# Asset Import API

Upload files into your tenant's media library over HTTP — and organize them into folders that are created on the fly. Built for scripts, CI jobs, and migration tooling that need to push assets without a browser session.

This guide covers `POST /api/v1/admin/import/asset`. For how those uploaded files are later *served* to a published site, see the [Asset CDN Proxy Guide](/docs/asset-cdn-proxy). For the SDK delivery client, see the [Integration Guide](/docs/integration).

---

## Table of Contents

1. [What it does](#what-it-does)
2. [Prerequisites](#prerequisites)
3. [Endpoint](#endpoint)
4. [Folder paths](#folder-paths)
5. [Examples](#examples)
6. [Supported file types and limits](#supported-file-types-and-limits)
7. [Error handling](#error-handling)
8. [FAQ](#faq)

---

## What it does

`POST /api/v1/admin/import/asset` accepts a single file as `multipart/form-data` and stores it in your tenant's media library — the same library you see at `/media` in the dashboard. It is authenticated with a **Bearer API key** (scope `admin:assets:write`) rather than a browser session, so it can run headless.

The endpoint optionally takes a **`folderPath`** field. When you provide one, the API resolves it to a folder, **creating any missing folders along the way**, and places the uploaded asset in the leaf folder. This lets a single request both build folder structure and upload into it.

---

## Prerequisites

1. **An API key with the `admin:assets:write` scope.** Create one in the CMS Dashboard → **Settings → API Keys**. Keys look like `ek_live_...`.
2. **Your tenant slug**, sent as the `X-Tenant-Slug` header (optional but recommended; it is validated against the key's tenant).
3. The base URL of your CMS instance (e.g. `https://cms.example.com` or `http://localhost:3000`).

Store these as environment variables, mirroring the rest of the platform's tooling:

```bash
CMS_BASE_URL=https://cms.example.com
CMS_API_KEY=ek_live_...
CMS_TENANT=acme
```

---

## Endpoint

```
POST /api/v1/admin/import/asset
```

### Headers

| Header | Required | Description |
|---|---|---|
| `Authorization` | Yes | `Bearer ek_live_...` — an API key with the `admin:assets:write` scope. |
| `X-Tenant-Slug` | Optional | Your tenant slug. Validated against the key's tenant when present. |

### Body (`multipart/form-data`)

| Field | Type | Required | Description |
|---|---|---|---|
| `file` | File | Yes | The asset binary. |
| `folderPath` | string | No | A `/`-separated folder path, e.g. `Marketing/2026/Launch`. Missing folders are auto-created; the asset is placed in the leaf folder. Omit (or leave empty) to upload to the library root. |
| `alt` | string | No | Alt text for the asset. |
| `width` | number | No | Image width hint in pixels. |
| `height` | number | No | Image height hint in pixels. |

### Response

`201 Created`:

```json
{
  "success": true,
  "data": {
    "id": "a3f1…",
    "cdnUrl": "/assets/<contentHash>/<filename>",
    "contentHash": "<sha256>",
    "folderId": "f9c2…"
  }
}
```

- `id` — the new asset's UUID.
- `cdnUrl` — the content-addressed URL used when referencing the asset on a page. See the [Asset CDN Proxy Guide](/docs/asset-cdn-proxy) for how this is served.
- `contentHash` — SHA-256 of the uploaded bytes.
- `folderId` — the resolved leaf folder's UUID, or `null` when the asset was uploaded to the root.

---

## Folder paths

`folderPath` is split on `/`; each segment becomes one nesting level. Resolution is **find-or-create**:

- Each segment is matched within its parent by its **slug** (a normalized, lowercased form of the name). So `Marketing/2026` and `marketing/2026` resolve to the same folders, and a folder created here matches one created with the same name in the dashboard UI.
- Missing folders are created; existing ones are reused. Re-running the same import is **idempotent** — it will not create duplicate folders.
- Leading, trailing, and doubled slashes are ignored: `/Marketing//2026/` resolves to `Marketing` → `2026`.
- The maximum depth is **10** segments.

### Concurrency safety

Concurrent imports targeting the same path are safe: folder uniqueness is enforced at the database level by a `(tenant, parent, slug)` unique constraint with `NULLS NOT DISTINCT`, so two parallel requests creating the same folder (including a **root-level** folder) converge on one row rather than producing duplicates.

---

## Examples

### `curl`

Upload into an auto-created folder:

```bash
curl -X POST "$CMS_BASE_URL/api/v1/admin/import/asset" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "X-Tenant-Slug: $CMS_TENANT" \
  -F "file=@./hero.jpg" \
  -F "folderPath=Marketing/2026/Launch" \
  -F "alt=Spring launch hero"
```

Upload to the library root (no `folderPath`):

```bash
curl -X POST "$CMS_BASE_URL/api/v1/admin/import/asset" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "X-Tenant-Slug: $CMS_TENANT" \
  -F "file=@./logo.png"
```

### Node.js / TypeScript (`fetch`)

```ts
import { readFile } from "node:fs/promises"

async function importAsset(filePath: string, folderPath?: string) {
  const form = new FormData()
  const bytes = await readFile(filePath)
  form.append("file", new Blob([bytes]), filePath.split("/").pop())
  if (folderPath) form.append("folderPath", folderPath)

  const res = await fetch(`${process.env.CMS_BASE_URL}/api/v1/admin/import/asset`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CMS_API_KEY}`,
      "X-Tenant-Slug": process.env.CMS_TENANT!,
    },
    body: form,
  })

  if (!res.ok) {
    const error = await res.json()
    throw new Error(`Upload failed: ${error.code} — ${error.error}`)
  }

  return (await res.json()).data as {
    id: string
    cdnUrl: string
    contentHash: string
    folderId: string | null
  }
}
```

### Bulk import

`folderPath` makes bulk imports a simple loop — no need to pre-create folders or track folder IDs. The first file that needs a folder creates it; later files reuse it:

```ts
const files = [
  { path: "./img/hero.jpg", folder: "Marketing/2026/Launch" },
  { path: "./img/banner.jpg", folder: "Marketing/2026/Launch" },
  { path: "./docs/spec.pdf", folder: "Marketing/2026/Docs" },
]

for (const f of files) {
  const asset = await importAsset(f.path, f.folder)
  console.log(`${f.path} → ${asset.cdnUrl} (folder ${asset.folderId})`)
}
```

---

## Supported file types and limits

- **Maximum file size:** 50 MB per file.
- **Allowed MIME types:**
  - **Images:** `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/avif`, `image/svg+xml`, `image/bmp`, `image/tiff`
  - **Video:** `video/mp4`, `video/webm`, `video/ogg`, `video/quicktime`
  - **Documents:** `application/pdf`, `application/msword`, `.docx`, `application/vnd.ms-excel`, `.xlsx`, `text/plain`, `text/csv`

Uploading an unsupported type returns `UNSUPPORTED_FILE_TYPE`. Images are queued for background processing (responsive variants, AI alt-text suggestions) after upload — the `201` response returns immediately and does not wait for that work.

---

## Error handling

All errors return `success: false` with a machine-readable `code`. HTTP status reflects the failure class.

| HTTP | `code` | Cause |
|---|---|---|
| 400 | `INVALID_BODY` | Request was not `multipart/form-data`. |
| 400 | `VALIDATION_ERROR` | Missing `file`, or invalid field (see `fieldErrors`). |
| 400 | `FILE_TOO_LARGE` | File exceeds the 50 MB limit. |
| 400 | `UNSUPPORTED_FILE_TYPE` | MIME type not in the allowed list. |
| 400 | `INVALID_FOLDER_NAME` | A `folderPath` segment has no alphanumeric characters (e.g. `"!!!"`). |
| 400 | `FOLDER_PATH_TOO_DEEP` | `folderPath` exceeds 10 segments. |
| 401 | — | Missing, invalid, expired, or revoked API key. |
| 403 | — | Valid key without the `admin:assets:write` scope. |
| 404 | `TENANT_NOT_FOUND` | The key's tenant is missing or inactive. |

---

## FAQ

**Does re-uploading the same file create a duplicate?**
Each `POST` creates a new asset record. Because `cdnUrl` and `contentHash` are derived from the file bytes, identical bytes always yield the same `cdnUrl`. If you need to avoid duplicate records, check `POST /api/v1/admin/import/find-asset-by-hash` before uploading.

**Can I move an asset to a different folder later?**
This endpoint only creates. Use the dashboard, or the session-authenticated folder/move endpoints, to reorganize existing assets.

**What if a folder name and an existing one differ only in case or spacing?**
They are matched by slug, so `Launch`, `launch`, and `Launch ` all resolve to the same folder.
