Management API

Assets

The HTTP contract your own clients use to browse and upload assets in a project's connected media libraries, authorized by a project-scoped restricted key.

Assets

A project's storefront exposes six routes that browse, upload and update assets in whichever media libraries its installed apps registered. One wire contract in front of any number of asset backends: the same call reaches Shopify Files, the Laioutr CDN, or a DAM you connected yourself, and the response shape does not change.

The routes are built for a client that is not yours to run — an internal tool, a partner's page, a desktop app, a migration script, an MCP server that sets media on a section. Access is authorized by a restricted key: bound to one project, limited to reading or writing assets, and revocable on its own.

This page documents the calling side. To make a new backend appear behind these routes, write a connector: Media Libraries.

Base URL

The routes live on the project's frontend deployment, not on api.laioutr.cloud. The base URL is the storefront's own URL, listed under Hosting in Cockpit.

POST https://shop.example.com/api/public/media/list

Every media route is POST, including the two that only read. Bodies and responses are JSON, except media/upload, which takes multipart/form-data. The one GET in the class is the OpenAPI document.

RoutePurposeScope
/api/public/media/librariesList the registered libraries and their capabilitiesmedia:read
/api/public/media/listBrowse or search one library, one page at a timemedia:read
/api/public/media/upload-targetsStaged upload, phase 1: mint signed upload targetsmedia:write
/api/public/media/finalizeStaged upload, phase 2: commit the uploaded refsmedia:write
/api/public/media/uploadProxied upload: relay the bytes through the storefrontmedia:write
/api/public/media/update-assetChange an asset's name, alt text or tagsmedia:write

A path under /api/public/ that maps to no scope answers 404, so a route can never be added without one.

Getting access

Two things must be true before a call succeeds: the project has opted the route class in by listing an allowed origin, and the caller holds a restricted key with the right scope.

Allow an origin. /api/public/* answers 404 on every path until the project lists at least one allowed origin under Allowed origins in project settings — nothing before that confirms the routes exist. An entry is normalized to scheme, host and port, so a pasted https://admin.example.com/media is stored as https://admin.example.com; anything that is not an http/https URL is refused, because it could never match an Origin header.

Mint a restricted key. Create one under Create key in API keys, choosing the Restricted key type and the project it should address. The full value is shown once. It carries only the scopes you tick — media:read to browse, media:write to upload, organise folders and edit assets, media:delete to delete assets and folders — and reaches /api/public/media/* on that one project and nothing else, so it can neither read the project's configuration nor deploy. An organization key presented here is refused, and a restricted key presented to any other route class is refused.

Authorization: Bearer rstk_<...>
An origin change takes effect on the project's next deploy. The origins travel to the storefront in its laioutrrc, which is baked at build time. Adding an origin in Cockpit does not open it on the running deployment.

CORS is the whole origin policy. Every route needs an Authorization header, which is not CORS-safelisted, so a cross-origin browser call always preflights — and a disallowed origin gets no Access-Control-Allow-Origin, so the browser sends neither the read nor the write.

How the gate behaves

  1. The project listed no allowed origin — 404, the whole class.
  2. Preflight — answered from the project's origin list, with no credential.
  3. A path with no scope mapping — 404. /api/public/openapi.json maps to no scope on purpose and is served here, with no credential.
  4. No bearer credential — 401.
  5. The key is unknown, revoked, expired, or belongs to another project — 401.
  6. The key is valid here but lacks the scope the path needs — 403.
  7. Cockpit unreachable, slow or malformed — 503. The gate never fails open.

A caller that sends no Origin header is not a browser, so the origin list does not apply to it and the key alone authorizes the call. Use that for server-to-server access.

curl -X POST https://shop.example.com/api/public/media/list \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "limit": 24 }'

Before you ship one

  • There is no rate limiting. Revocation is the whole control surface, which is why the key list shows last use and revokes in one click. Revoking takes effect on the next request; nothing is cached.
  • Expiry is optional on purpose. A client with no backend cannot renew a key, so a forced expiry only schedules an outage. Set one when the client is short-lived.
  • Treat the key as published from the moment it reaches a browser. Scope it to media:read unless the client genuinely uploads.
  • Proxy through your own backend when you need per-user rules the scope model cannot express. Hold the key server-side and forward; the hop sends no Origin, so no origin needs allowing.

OpenAPI specification

Available since v0.62.0 in @laioutr-core/frontend-core

Every storefront that has opted in serves an OpenAPI 3.1 description of the whole /api/public/ class:

curl https://shop.example.com/api/public/openapi.json

It needs no key, but it follows the same opt-in and origin list as the other routes: a project with no allowed origin answers 404 here too.

The document is generated when it is requested, from the same schemas the storefront validates requests with. It therefore describes the version that is deployed, and it grows as routes are added to the class. It includes each operation's request and response bodies, the scope the key needs, and the error statuses listed under Errors. Its server URL is /, so a tool that loads it from the storefront targets that storefront.

Use it to import the routes into Postman, Insomnia or Bruno, or to generate a typed client:

npx openapi-typescript https://shop.example.com/api/public/openapi.json -o laioutr-public-api.ts
The document describes the contract, not a project's content. Library ids are plain strings in it; call media/libraries for the ones a deployment actually registers.

Discover libraries

POST /api/public/media/libraries takes no body and returns one descriptor per registered library. Call it first: the descriptors say which fields the other routes will honour.

curl -X POST https://shop.example.com/api/public/media/libraries \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY"
[
  {
    "id": "@laioutr-app/cms",
    "label": "Laioutr",
    "iconSrc": "/app-cms/logo.png",
    "capabilities": {
      "search": true,
      "tags": true,
      "folders": true,
      "createFolders": true,
      "moveAssets": true,
      "sorts": [
        { "key": "createdAt:desc", "label": "Newest" },
        { "key": "createdAt:asc", "label": "Oldest" },
        { "key": "name:asc", "label": "Name A–Z" },
        { "key": "fileSize:desc", "label": "Largest" }
      ],
      "upload": {
        "transfer": "staged",
        "maxBatchSize": 20,
        "maxFileSize": 104857600,
        "accept": ["image/png", "image/jpeg", "image/webp", "image/avif", "image/gif", "image/svg+xml", "video/mp4", "audio/mpeg", "application/pdf", "application/zip"]
      },
      "deleteAssets": true,
      "renameFolders": true,
      "moveFolders": true,
      "deleteFolders": true,
      "updateAssets": { "name": true, "alt": true, "tags": true }
    }
  },
  {
    "id": "@laioutr-app/shopify",
    "label": "Shopify",
    "iconSrc": "/app-shopify/shopify-logo.svg",
    "capabilities": {
      "search": true,
      "folders": false,
      "sorts": [
        { "key": "CREATED_AT:desc", "label": "Newest first" },
        { "key": "CREATED_AT:asc", "label": "Oldest first" }
      ],
      "upload": {
        "transfer": "staged",
        "maxBatchSize": 10,
        "accept": ["image/*", "video/*"]
      }
    }
  }
]

Capabilities drive the rest of the contract

Capabilities are static. A caller cannot push a connector outside its declared contract.

CapabilityWhat it unlocks
searchterm on media/list
tagstags on media/list
foldersfolderId and scope on media/list, plus folders in the response
sortssorting on media/list, restricted to the declared key values
uploadtransfer picks the upload path; accept, maxFileSize and maxBatchSize are client hints
createFoldersmedia/create-folder
renameFoldersmedia/rename-folder
moveFoldersmedia/move-folder
deleteFoldersmedia/delete-folder, for an empty folder
moveAssetsmedia/move-assets
deleteAssetsmedia/delete-assets
updateAssetsmedia/update-asset, for each field set to true: name, alt, tags

A route answers 400 when the library does not implement its operation. The capability tells you the same in advance, so read the descriptor before you offer an action.

A sorting value that is not one of the declared keys is dropped rather than rejected, and the library falls back to its own default order.

What the shipped libraries declare

Capabilities differ per backend, so write against the descriptor rather than against a library you have tested by hand. What ships today:

Laioutr CDNShopifyShopware
id@laioutr-app/cms@laioutr-app/shopify@laioutr/app-shopware
search✅✅✅
tags✅❌✅
folders✅❌✅
sortsdate, name, sizedate, namedate, name, size
upload✅ staged✅ staged❌
maxBatchSize2010n/a
maxFileSizeper project, 100 MB by defaultnot declaredn/a
acceptimages, mp4, audio, documents, archives, PSD, glTF (Files)image/*, video/*n/a
updateAssetsname, alt, tags❌❌
folder writescreate, rename, move, delete❌❌
moveAssets✅❌❌
deleteAssets✅❌❌

Browse a library

POST /api/public/media/list returns one page of one location.

library
string required
The library id from media/libraries. An unregistered id answers 404.
limit
number required
Page size, between 1 and 100. There is no default; omitting it fails validation.
cursor
string
Opaque continuation token from a previous response's nextCursor. Omit it for the first page. Pagination is always cursor-based, never offset-based.
term
string
Free-text search. Requires capabilities.search.
type
('image' | 'video' | 'audio' | 'file')[]
Restricts results to these kinds. Absent returns media and files. Media kinds return only those kinds, ['file'] returns only distribution files, and both together return both. Filtering happens in the connector, so an image-only request never pays for video results. A library without distribution files returns no items for ['file'].
tags
string[]
Tag filter. Requires capabilities.tags.
sorting
string
One of the key values from capabilities.sorts.
folderId
string
Scopes the page to one folder. Omit it for the root. Requires capabilities.folders.
scope
'folder' | 'all'
folder (the default) browses the location folderId selects. all ignores folderId and searches the whole library. The distinction matters on backends where the root holds only unfiled assets, so "root" and "everywhere" are different sets. Requires capabilities.folders.
curl -X POST https://shop.example.com/api/public/media/list \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "library": "@laioutr-app/cms",
    "limit": 24,
    "term": "hero",
    "type": ["image"],
    "sorting": "createdAt:desc"
  }'

The response is a MediaListResult:

interface MediaListResult {
  items: ProviderStudioLibraryItem[];
  folders?: MediaFolder[];  // subfolders of the queried location, first page only
  nextCursor?: string;      // absent means end of results
  total?: number;           // optional; many backends cannot count cheaply
}

type ProviderStudioLibraryItem = ProviderStudioMediaItem | ProviderStudioFileItem;

interface ProviderStudioMediaItem {
  kind?: 'media';           // absent also means media
  media: Media;             // the canonical Media object that gets stored on a prop
  previewUrl?: string;      // thumbnail for a picker grid; absent when there is none, e.g. audio
  file?: LibraryFile;       // the stored original
  fileName?: string;
  externalId?: string;      // the backend's stable asset id
  tags?: string[];
  status?: 'ready' | 'processing' | 'failed';
  statusMessage?: string;
}

interface ProviderStudioFileItem {
  kind: 'file';             // a distribution file: no media, never stored on a media prop
  file: LibraryFile;
  fileName?: string;
  externalId?: string;
  tags?: string[];
  status?: 'ready' | 'processing' | 'failed';
  statusMessage?: string;
}

interface LibraryFile {
  url: string;              // the original; on the Laioutr CDN it ends in the asset's name
  mimeType: string;
  size?: number;            // bytes
  downloadUrl?: string;     // makes the browser save the file instead of opening it
}

interface MediaFolder {
  id: string;               // pass back as folderId to browse into it
  name: string;
  parentId?: string;
  childCount?: number;
}
{
  "items": [
    {
      "media": {
        "type": "image",
        "sources": [
          {
            "provider": "laioutrCms",
            "src": "https://p_2f9k4x.cdn.laioutr.cloud/d1m7q8v3.webp",
            "width": 2400,
            "height": 1350,
            "origin": { "libraryId": "@laioutr-app/cms", "externalId": "d1m7q8v3" }
          }
        ]
      },
      "previewUrl": "https://p_2f9k4x.cdn.laioutr.cloud/cdn-cgi/image/width=400,quality=80,format=auto/d1m7q8v3.webp",
      "file": {
        "url": "https://p_2f9k4x.cdn.laioutr.cloud/d1m7q8v3/hero-spring-campaign.webp",
        "mimeType": "image/webp",
        "size": 284120,
        "downloadUrl": "https://p_2f9k4x.cdn.laioutr.cloud/d1m7q8v3/hero-spring-campaign.webp?download"
      },
      "kind": "media",
      "fileName": "hero-spring-campaign.webp",
      "externalId": "d1m7q8v3"
    },
    {
      "kind": "file",
      "file": {
        "url": "https://p_2f9k4x.cdn.laioutr.cloud/k9w3f2pa/price-list.pdf",
        "mimeType": "application/pdf",
        "size": 96410,
        "downloadUrl": "https://p_2f9k4x.cdn.laioutr.cloud/k9w3f2pa/price-list.pdf?download"
      },
      "fileName": "price-list.pdf",
      "externalId": "k9w3f2pa"
    }
  ],
  "folders": [{ "id": "fld_campaigns", "name": "Campaigns", "childCount": 4 }],
  "nextCursor": "eyJzb3J0VmFsdWUiOjE3NTY5MDA4MDAsImlkIjoiZDFtN3E4djMifQ",
  "total": 312
}

Four things about that shape catch callers out:

  • Check the kind before you read media. A file item has no media and no previewUrl. Send type with media kinds when you only want something to store on a media prop.
  • folders rides the first page only. A request that carries a cursor continues the asset stream and returns no folders. Build the folder tree from cursorless requests.
  • total is optional. Do not build a pager that needs a page count. Follow nextCursor until it is absent.
  • An absent status means ready. Backends that transcode report processing while they work and failed when they give up, with an optional statusMessage. A failed item is listed but is not safe to store on a prop.

Staged upload

In staged mode the bytes go straight from the client to the asset backend, and the storefront only signs and records. Libraries that declare capabilities.upload.transfer: 'staged' take this path.

Phase 1: mint targets

POST /api/public/media/upload-targets takes file metadata only. No bytes cross this route.

library
string required
The library id. The library must expose staged uploads, or the route answers 400.
files
UploadFileMeta[] required
At least one { name, mimeType, size }. Validate against the library's accept, maxFileSize and maxBatchSize first; a backend that rejects the batch will do so here, not politely.
curl -X POST https://shop.example.com/api/public/media/upload-targets \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "library": "@laioutr-app/cms",
    "files": [{ "name": "lookbook-01.jpg", "mimeType": "image/jpeg", "size": 1843200 }]
  }'

The response is one UploadTarget per file, in the same order:

interface UploadTarget {
  ref: string;                        // opaque token; hand it back to media/finalize
  url: string;
  method?: 'PUT' | 'POST';            // defaults to PUT
  fields?: Record<string, string>;    // form fields for S3/GCS-style POST targets
  headers?: Record<string, string>;   // send these verbatim with the bytes
}

Send the bytes exactly as the target describes:

  • Set every entry of headers on the request, verbatim.
  • With no fields, send the raw file as the body (PUT unless method says otherwise).
  • With fields, build a multipart/form-data body, append every field first, and append the file last under the name file. S3 and GCS POST policies reject a body whose file part arrives before the policy fields.

Backends pin Content-Type and Content-Length into the signature, so a request that deviates is rejected, and the failure surfaces at finalize as a missing object rather than at upload time.

The upload is a cross-origin request from the client to the backend, so the target host must allow it. A bucket without a CORS rule permitting PUT from the calling origin fails in the browser with an opaque error while every server-side log looks healthy.

Phase 2: finalize

POST /api/public/media/finalize commits the refs whose bytes landed and returns finished items. Connectors absorb backend processing here, so a returned item is ready to store on a prop.

library
string required
The library id, same as phase 1.
refs
string[] required
At least one ref from the targets you actually uploaded. Skip the ones that failed in transit.
curl -X POST https://shop.example.com/api/public/media/finalize \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "refs": ["r_8kd2mq1f"] }'

Proxied upload

In proxied mode the bytes travel through the storefront, which hands them to the connector. Libraries declaring transfer: 'proxied' take this path; it suits small files and backends with no signed-upload support.

POST /api/public/media/upload is the one route that takes multipart/form-data. Send a library text field plus one or more file parts. The field name of a file part is ignored, so any name works.

curl -X POST https://shop.example.com/api/public/media/upload \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -F "library=@laioutr-app/acme-dam" \
  -F "file=@./lookbook-01.jpg"

The response is the same UploadOutcome[] finalize returns.

Upload outcomes

Both upload paths return per-file results, so one bad file never sinks the batch. A request whose files all failed is still 200.

type UploadOutcome =
  | { ref: string; ok: true; item: ProviderStudioLibraryItem }
  | { ref: string; ok: false; error: { code: UploadErrorCode; message: string } };

type UploadErrorCode = 'too_large' | 'unsupported_type' | 'processing_failed' | 'upload_failed';
[
  {
    "ref": "r_8kd2mq1f",
    "ok": true,
    "item": {
      "media": {
        "type": "image",
        "sources": [
          {
            "provider": "laioutrCms",
            "src": "https://p_2f9k4x.cdn.laioutr.cloud/h5r2n9wc.jpg",
            "width": 1600,
            "height": 2400,
            "origin": { "libraryId": "@laioutr-app/cms", "externalId": "h5r2n9wc" }
          }
        ]
      },
      "previewUrl": "https://p_2f9k4x.cdn.laioutr.cloud/cdn-cgi/image/width=400,quality=80,format=auto/h5r2n9wc.jpg",
      "fileName": "lookbook-01.jpg",
      "externalId": "h5r2n9wc"
    }
  },
  {
    "ref": "r_p3xw07ba",
    "ok": false,
    "error": { "code": "too_large", "message": "File exceeds the 100 MB limit" }
  }
]

An item that fails the canonical Media parse on the way out is converted into an upload_failed outcome rather than returned, so a successful media outcome always carries a storable media. An uploaded distribution file comes back as a file item. On the Laioutr CDN, content that is an executable or an installer is refused with unsupported_type, whatever type it was declared as; see Files.

Update an asset

POST /api/public/media/update-asset changes the name, the alt text or the tags of one asset, and returns the stored item. Only the fields you send change. The library must declare each field you send in capabilities.updateAssets.

library
string required
The library id.
assetId
string required
The item's externalId.
name
string
The asset's name, which is also its title. Trimmed, 1 to 255 characters. A rename never changes a delivery URL already stored on a prop.
alt
string | null
The default alt text. A media field that picks the asset copies it into the field value; a value already stored on a prop does not change. Trimmed, up to 1000 characters. null or an empty string clears it.
tags
string[]
Replaces the whole list. Each tag is trimmed and at most 64 characters; empty tags and duplicates are removed, case is kept; at most 50 tags. [] clears it.

Send at least one of name, alt and tags. No value may contain a NUL character.

curl -X POST https://shop.example.com/api/public/media/update-asset \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "assetId": "d1m7q8v3", "alt": "Spring campaign, three jackets on a rail" }'

The response is the stored ProviderStudioLibraryItem, with the values as the library saved them after trimming and removing duplicates.

Organise folders

Four routes create, rename, move and delete folders in a library that declares capabilities.folders. Each one needs its own capability as well, and the media:write scope; deleting needs media:delete.

A rename or a move never changes the delivery URL of an asset inside the folder, so a URL already stored on a prop keeps working. A library that would have to change one does not offer the operation.

A folder name is trimmed and must be 1 to 255 characters, with no NUL character. Two folders in the same parent cannot share a name.

Create a folder

POST /api/public/media/create-folder creates a folder and returns it. Requires capabilities.createFolders.

library
string required
The library id.
name
string required
The folder's name.
parentId
string
The id of the folder to create it in. Omit it for the root.
curl -X POST https://shop.example.com/api/public/media/create-folder \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "name": "Spring 2027", "parentId": "fld_campaigns" }'

The response is the new MediaFolder:

{ "id": "fld_spring27", "name": "Spring 2027", "parentId": "fld_campaigns" }

The library refuses with name_taken (409) when the parent already holds a folder of that name, and with invalid_target (422) when parentId names no folder.

Rename a folder

POST /api/public/media/rename-folder gives a folder a new name and returns it. Requires capabilities.renameFolders.

library
string required
The library id.
folderId
string required
The folder to rename.
name
string required
The new name.
curl -X POST https://shop.example.com/api/public/media/rename-folder \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "folderId": "fld_spring27", "name": "Spring 2027 campaign" }'

The response is the renamed MediaFolder. The library refuses with not_found (404) for an unknown folder, and with name_taken (409) when a sibling already has the name. A new spelling of the folder's own name is no conflict.

Move a folder

POST /api/public/media/move-folder moves a folder, with everything in it, under another folder or to the root, and returns it. Requires capabilities.moveFolders.

library
string required
The library id.
folderId
string required
The folder to move.
parentId
string | null required
The new parent folder. null moves the folder to the root; the field itself is required.
curl -X POST https://shop.example.com/api/public/media/move-folder \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "folderId": "fld_spring27", "parentId": null }'

The response is the moved MediaFolder. The library refuses with:

  • not_found (404) for an unknown folder;
  • invalid_target (422) when parentId names no folder, or names the folder itself or one of its subfolders;
  • name_taken (409) when the new parent already holds a folder of that name.

Delete a folder

POST /api/public/media/delete-folder deletes an empty folder. Requires capabilities.deleteFolders and the media:delete scope.

library
string required
The library id.
folderId
string required
The folder to delete.
curl -X POST https://shop.example.com/api/public/media/delete-folder \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "folderId": "fld_spring27" }'

The response is { "ok": true }. The route never deletes contents: a folder that holds an asset or a subfolder is refused with not_empty (409). Move or delete what is inside first, then delete the folder. An unknown folder is refused with not_found (404).

Move assets

POST /api/public/media/move-assets moves one or more assets into a folder, or to the root. Requires capabilities.moveAssets and the media:write scope.

library
string required
The library id.
assetIds
string[] required
At least one item externalId.
folderId
string | null required
The target folder. null moves the assets to the root; the field itself is required.
curl -X POST https://shop.example.com/api/public/media/move-assets \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "assetIds": ["d1m7q8v3", "h5r2n9wc"], "folderId": "fld_spring27" }'

The response is { "ok": true }. A move never changes an asset's delivery URL. A library that can only tell at run time that a move would change one refuses with url_change (409). An unknown target folder is refused with invalid_target (422).

On the Laioutr CDN, an id that matches no asset in the project is skipped, not refused, and the response does not count what moved. List the target folder to confirm the result.

Delete assets

POST /api/public/media/delete-assets deletes one or more assets. Requires capabilities.deleteAssets and the media:delete scope.

A delete is permanent, and this route does not check where an asset is used. A prop that stores the asset's URL keeps that URL, and the file behind it is gone. The Media page in Cockpit checks usage before it deletes; this route does not.
library
string required
The library id.
assetIds
string[] required
At least one item externalId.
curl -X POST https://shop.example.com/api/public/media/delete-assets \
  -H "Authorization: Bearer $LAIOUTR_RESTRICTED_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "library": "@laioutr-app/cms", "assetIds": ["h5r2n9wc"] }'

The response is { "ok": true }. On the Laioutr CDN the asset leaves the library first and its stored file is removed after that. An id that matches no asset in the project is skipped, not refused.

Errors

The gate answers first, before any request body is read:

StatusstatusMessageCauseResolution
401UnauthorizedNo bearer credential, or a key that is unknown, revoked, expired or another project'sMint a key for this project; a denial never says whether the key exists elsewhere
403ForbiddenThe key is valid here but lacks the scope the path requiresAdd the scope the path needs: media:write, or media:delete for media/delete-assets and media/delete-folder
404Not FoundThe project has not listed an allowed origin, or the path maps to no scopeAdd an origin in project settings and redeploy; check the path against the route table
503Service UnavailableCockpit could not be reached, answered slowly, or the storefront's own configuration is incompleteTransient — retry. If it persists, the storefront's configuration is the problem, not your key

Past the gate, failures arrive as H3 error bodies: { statusCode, statusMessage, message, data? }.

StatusstatusMessageCauseResolution
400Validation ErrorThe body failed schema validation, for example a missing or out-of-range limitRead data for the Zod issue list; it names the offending path
400Provider does not support staged uploadsmedia/upload-targets or media/finalize against a library without themCheck capabilities.upload.transfer and use media/upload instead
400Provider does not support proxied uploadsmedia/upload against a staged-only libraryUse the media/upload-targets and media/finalize pair
400Provider does not support <operation>A folder, move, delete or update route against a library without that operation, for example Provider does not support deleting foldersCheck the matching capability before you call the route
400Provider cannot update the field: <field>media/update-asset sends a field the library does not declareSend only the fields set to true in capabilities.updateAssets
404Unknown media library: <id>library names no registered libraryCall media/libraries; the app may not be installed on this deployment
403, 404, 409, 422Media Library ErrorThe library refused the request; see the codes belowdata.code names the refusal; data.message is written for an editor
502Media Library ErrorThe connector threwdata.library and data.message carry the library id and the connector's message

A refusal carries one of these codes in data.code, and the status follows from the code:

data.codeStatusMeans
not_found404The asset or folder does not exist in this library
not_empty409The folder holds an asset or a subfolder
name_taken409The parent already holds a folder of that name
url_change409The operation would change a delivery URL, so the library refuses it
invalid_target422The target folder does not exist, or a folder would move into itself or a subfolder
not_enabled403The project has not enabled what the request needs, for example video uploads on the CDN

The 502 exists so a connector failure stays legible. Without it, a raw throw would be masked as an opaque 500 in production and leave the caller with nothing to display.

API keys

Mint and revoke the restricted key these routes accept.

Media and Media Library

How the picker, the stored Media prop, and the connector contract fit together.

Media Libraries

Write a connector so your own backend appears behind these routes.

Media type reference

The canonical Media union every item's media is parsed against.

Changelog

Storefronts that enable the public API now serve an OpenAPI 3.1 description of it at GET /api/public/openapi.json, generated from the schemas the routes validate with. It needs no key but follows the project's opt-in and origin list. See OpenAPI specification.

v0.35.0Changed · Breaking

A media library connector is now declared with defineX.mediaLibrary(...) on the app's Orchestr builder instead of the standalone defineMediaLibraryProvider factory. Identity comes from the builder's .meta(); the connector declares static capabilities and its handlers receive the app's per-request ctx. Browsing moves to opaque-cursor pagination with folders, an explicit type filter, and per-file upload results (proxied or staged).

Show migration

Move the connector onto your app's Orchestr builder. Identity moves from the provider's name/label/iconSrc to the builder's .meta(), pagination moves from offset/limit to an opaque cursor, and list returns a MediaListResult and takes ctx as a second argument.

// Before
export default defineMediaLibraryProvider({
  name: 'my-dam',
  label: 'My DAM',
  list: async ({ offset, limit, search, sort }) => ({ items, total, offset, limit }),
});
// After
export default defineMyDam.mediaLibrary({
  capabilities: { search: true, folders: false, sorts: [] },
  list: async (query, ctx) => ({ items, nextCursor, total }),
});
v0.35.0Deprecated

defineMediaLibraryProvider(...) is deprecated in favour of defineX.mediaLibrary(...). It keeps working as a compatibility shim, in a degraded mode (no folders, no staged upload, no declared sorts, no server-side type filtering), and logs a one-time warning at registration.

Copyright © 2026 Laioutr GmbH