glokki
View .md

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. For the SDK delivery client, see the Integration Guide.


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:

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

Endpoint

POST /api/v1/admin/import/asset

Headers

HeaderRequiredDescription
AuthorizationYesBearer ek_live_... — an API key with the admin:assets:write scope.
X-Tenant-SlugOptionalYour tenant slug. Validated against the key's tenant when present.

Body (multipart/form-data)

FieldTypeRequiredDescription
fileFileYesThe asset binary.
folderPathstringNoA /-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.
altstringNoAlt text for the asset.
widthnumberNoImage width hint in pixels.
heightnumberNoImage height hint in pixels.

Response

201 Created:

{
  "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 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:

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):

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)

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:

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.

HTTPcodeCause
400INVALID_BODYRequest was not multipart/form-data.
400VALIDATION_ERRORMissing file, or invalid field (see fieldErrors).
400FILE_TOO_LARGEFile exceeds the 50 MB limit.
400UNSUPPORTED_FILE_TYPEMIME type not in the allowed list.
400INVALID_FOLDER_NAMEA folderPath segment has no alphanumeric characters (e.g. "!!!").
400FOLDER_PATH_TOO_DEEPfolderPath exceeds 10 segments.
401—Missing, invalid, expired, or revoked API key.
403—Valid key without the admin:assets:write scope.
404TENANT_NOT_FOUNDThe 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.