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
- An API key with the
admin:assets:writescope. Create one in the CMS Dashboard → Settings → API Keys. Keys look likeek_live_.... - Your tenant slug, sent as the
X-Tenant-Slugheader (optional but recommended; it is validated against the key's tenant). - The base URL of your CMS instance (e.g.
https://cms.example.comorhttp://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
| 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:
{
"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, ornullwhen 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/2026andmarketing/2026resolve 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 toMarketing→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
- Images:
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.