Supercommerce API Docs
Admin API

Assets Module — Admin

HTTP surface for the operator's media library — upload any file once, catalogue it, and hand out the public link that the storefront's asset host serves.

HTTP surface for the media library: the operator uploads images, video, audio and documents once, and the library hands back a public link on the same asset host the storefront already serves from. That link is what goes into content pages, email templates, notification payloads, banners — anywhere a URL is wanted.

Source: api-modules/asset/src/controllers/admin-asset.controller.ts.

Bytes never pass through the API. The browser asks for a presigned PUT, sends the file straight to storage, then registers the resulting key. The row is a catalogue entry over that object, and it doubles as the upload history — a removed or purged entry is never deleted, so who uploaded what and when stays auditable.


Conventions

Authentication

All endpoints require a Better-Auth admin session and a role granting the matching asset:* permission.

Endpoint groupPermission
GET /admin/assets, GET /admin/assets/:id, GET /admin/assets/stats, GET /admin/assets/foldersasset: read
POST /admin/assets/upload-url, POST /admin/assetsasset: create
PUT /admin/assets/:id, POST /admin/assets/:id/restoreasset: update
DELETE /admin/assets/:id (with or without purge)asset: delete

Response envelope

Successful responses are wrapped by ResponseInterceptor:

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* optional, e.g. pagination */ }
}

Error envelope

{ "message": "…", "statusCode": 400, "errorCode": "…" }

Upload flow

Two calls, plus one direct-to-storage PUT the API never sees.

  1. POST /admin/assets/upload-url — mints a presigned PUT. The declared contentLength is signed into the URL, so a body of any other size is rejected by storage; the content type is checked against the library allowlist.
  2. PUT <uploadUrl> — the browser sends the bytes with the returned headers verbatim.
  3. POST /admin/assets — registers the key. The service HEADs the object first, so a failed upload can never leave a library entry whose public link 404s.

Accepted content types

Images (jpeg, png, webp, gif, avif, bmp, tiff, x-icon), video (mp4, webm, quicktime), audio (mpeg, mp4, ogg, wav), documents (pdf, csv, plain, json, zip, the Office/OpenXML family) and web fonts (woff, woff2, ttf, otf).

text/html, image/svg+xml and every script type are rejected. Anything a browser will execute, served from the asset host, is stored XSS — the library is wider than the generic POST /storage/presigned allowlist but held to that same rule.

Size is bounded by STORAGE_MAX_UPLOAD_MB (default 25 MB).


Endpoints

POST /admin/assets/upload-url

Permission: asset: create.

FieldTypeNotes
fileNamestringOriginal name; its extension shapes the generated key
contentTypestring?Falls back to a lookup on fileName
contentLengthintThe file's exact byte size — signed into the PUT
{
  "data": {
    "key": "library/2026/9/6f1f…-a2.png",
    "uploadUrl": "https://…",
    "method": "PUT",
    "expiresIn": 300,
    "headers": { "Content-Type": "image/png", "Content-Length": "48213" },
    "publicUrl": "https://assets.example.com/library/2026/9/6f1f…-a2.png"
  }
}

Keys live under library/<year>/<month>/<uuid><ext> — deliberately not the generic endpoint's per-uploader assets/<userId>/… scope, because library objects outlive the staff account that uploaded them.

POST /admin/assets

Permission: asset: create. Registers an object already uploaded.

FieldTypeNotes
keystringMust be a library/… key this API minted
fileNamestring
mimeTypestringMust be in the allowlist
sizeBytesint
titlestring?Defaults to fileName
folderstring?Flat operator label, not a storage path
altstring?
width, heightint?Sent by the browser for images
metadataobject?

Returns 201 with the asset, including its url.

GET /admin/assets

Permission: asset: read. Paginated (buildPaginatedResponsemetadata: { total, limit, offset, hasMore }).

QueryNotes
searchValueMatches both title and fileName
kindimage | video | audio | document | other
folderExact folder
uploadedByStaff user id
includeRemovedInclude removed + purged entries — this is what turns the list into the upload history
sortBycreatedAt (default, desc), updatedAt, title, fileName, sizeBytes
limit, offsetStandard querySchema pagination

Each row:

{
  "id": "…",
  "key": "library/2026/9/6f1f…-a2.png",
  "url": "https://assets.example.com/library/2026/9/6f1f…-a2.png",
  "fileName": "hero-banner.png",
  "title": "Homepage hero",
  "mimeType": "image/png",
  "kind": "image",
  "sizeBytes": 48213,
  "folder": "campaigns",
  "alt": "Autumn sale hero",
  "width": 1600,
  "height": 600,
  "status": "available",
  "uploadedBy": { "id": "…", "name": "Priya", "email": "priya@example.com" },
  "metadata": null,
  "createdAt": "2026-09-21T09:12:44.000Z",
  "updatedAt": "2026-09-21T09:12:44.000Z",
  "deletedAt": null,
  "purgedAt": null
}

status is derived: available (listed, object served), deleted (hidden from the library, object still served so existing links live), purged (object gone from storage; the entry survives as history).

GET /admin/assets/stats

Permission: asset: read. { totalAssets, uploadedLast30Days } over the non-removed rows — two counts, deliberately nothing that has to scan every row's size.

GET /admin/assets/folders

Permission: asset: read. [{ folder, count }] for every folder in use — drives the library's folder filter and the upload dialog's autocomplete.

GET /admin/assets/:id

Permission: asset: read. 404 when unknown.

PUT /admin/assets/:id

Permission: asset: update. Accepts title, folder, alt, metadata only — the stored file is immutable once catalogued, so re-uploading is the way to change the bytes and every link already handed out keeps pointing at the same object. 400 on a purged asset.

DELETE /admin/assets/:id

Permission: asset: delete.

QueryEffect
(none)Removes the entry from the library. The object stays in storage, so links already pasted into a page or an email keep resolving. Reversible via restore.
purge=trueAlso deletes the object. Every link already handed out starts returning 404. Irreversible.

Either way the row survives with deletedAt (and purgedAt) set, so the upload stays in the history.

POST /admin/assets/:id/restore

Permission: asset: update. Returns a removed entry to the library. 400 when the asset was purged — the bytes are gone, so re-upload instead.


Events

Emitted via EventEmitter2 after the write: asset.created, asset.updated, asset.deleted (carries purged), asset.restored.

Admin UI

Storefront → Assets, with two tabs:

  • Library — the usable assets, with totals (count, uploads in the last 30 days), search, type + folder filters, one-click copy-link on every row, and an upload dialog that takes multiple files at once.
  • Upload history — every upload ever made, newest first, including removed and purged ones, showing the uploader and the timestamp alongside the status.

On this page