Asset Store REST API v1
The same endpoints the Lumo editor's Asset Store tab uses. Public reads need no authentication; publishers authenticate with a Bearer JWT and machine clients can send X-API-Key instead.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/assets | List & search assets |
| GET | /api/assets/[id] | Asset detail |
| POST | /api/auth/login | Get a JWT |
| POST | /api/assets | Publish (multipart) |
| PATCH | /api/admin/assets/[id] | Approve / reject |
Base URL
https://<your-deployment>/api
Every endpoint accepts and returns JSON unless stated otherwise. The same routes are used by this website, the Lumo editor's Asset Store tab and any machine client.
Authentication
Requests are authenticated in one of two ways — both work on every route:
Authorization: Bearer <supabase-access-token>
X-API-Key: $STORE_API_KEY
The X-API-Key header exists so the desktop app can call the API without a browser session.
Treat STORE_API_KEY as a secret with admin-level power and never ship it in client code.
Public read endpoints (GET /api/assets, GET /api/assets/[id], free downloads) need no
credentials at all.
CORS
Allowed origins come from the CORS_ORIGINS environment variable (comma separated). When the
variable is empty, any origin may call the API. OPTIONS preflight requests are handled on
every route with a 204 response.
Caching
| Endpoint class | Cache |
|---|---|
| Public reads | s-maxage=60, stale-while-revalidate=300 |
| Downloads | no-store (counter must stay accurate) |
| Auth / admin | no-store |
Error format
Every failure uses the same envelope and a meaningful HTTP status:
{
"error": {
"code": "invalid_category",
"message": "Category must be one of: 3D Models, Textures & Materials, ..."
}
}
See Errors for the full status-code table.
Versioning
The v1 prefix is part of the path (/api/...). Breaking changes will ship under /api/v2
while v1 keeps working.
POST /api/auth/signup
Creates an account from an email and password.
Body
{
"email": "publisher@example.com",
"password": "correct-horse-battery",
"username": "Ada Lovelace",
"handle": "ada"
}
Response — 201 Created
{
"token": "eyJhbGciOi...",
"user": {
"id": "9c2f...",
"email": "publisher@example.com",
"username": "Ada Lovelace",
"handle": "ada",
"role": "user",
"banned": false,
"createdAt": "2026-01-08T12:00:00.000Z"
},
"requiresConfirmation": false,
"message": "Account created."
}
When the Supabase project requires email confirmation, token is null and
requiresConfirmation is true.
| Status | Meaning |
|---|---|
| 201 | Account created |
| 400 | Invalid email, weak password or duplicate account |
| 503 | Supabase environment variables missing |
POST /api/auth/login
Exchanges credentials for a token.
Body
{ "email": "publisher@example.com", "password": "correct-horse-battery" }
Response — 200 OK
{
"token": "eyJhbGciOi...",
"refreshToken": "...",
"expiresAt": 1767225600,
"user": { "id": "9c2f...", "role": "user" }
}
| Status | Meaning |
|---|---|
| 200 | Signed in |
| 401 | Wrong credentials |
| 403 | Account suspended (account_banned) |
Using the token
Send it on every authenticated request:
TOKEN=$(curl -s -X POST /api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"a@b.dev","password":"secret123"}' | jq -r .token)
curl -X POST /api/assets \
-H "Authorization: Bearer $TOKEN" \
-F title="Sci-Fi Door" \
-F category="3D Models" \
-F file=@door.zip
Roles
| Role | Can do |
|---|---|
user |
Download, publish own assets, edit own assets |
admin |
Everything above plus moderation, user management and featuring |
Roles live in the users.role column and are enforced server-side on every admin route.
GET /api/assets
Public catalogue listing with search, filters, sorting and pagination.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
category |
string | — | One of the six store categories |
q |
string | — | Matches title and description |
price |
free | paid |
— | Filter by price |
sort |
newest | popular | rating |
newest |
|
page |
number | 1 |
|
perPage |
number | 12 |
Max 48 |
curl "https://store.example.com/api/assets?category=Audio&sort=popular&page=1"
Response — 200 OK
{
"items": [
{
"id": "6b1f...",
"title": "Footstep Pack Vol. 1",
"slug": "footstep-pack-vol-1",
"author": { "id": "9c2f...", "username": "Ada Lovelace", "handle": "ada", "avatar": null },
"category": "Audio",
"tags": ["footsteps", "sfx"],
"price": 0,
"downloads": 6712,
"thumbnail": "https://.../cover.png",
"version": "1.2.0",
"file": "https://.../pack.zip",
"license": "CC0 1.0",
"fileSize": 18432000,
"rating": 4.5,
"featured": true,
"status": "approved",
"createdAt": "2026-01-12T09:00:00.000Z"
}
],
"total": 42,
"page": 1,
"perPage": 12,
"pageCount": 4
}
GET /api/assets/[id]
Accepts an asset id (UUID) or a slug.
curl "https://store.example.com/api/assets/footstep-pack-vol-1"
Response — 200 OK — a single object with the same shape as one element of items above.
| Status | Meaning |
|---|---|
| 200 | Found |
| 404 | Missing, or not yet approved |
POST /api/assets
Requires a Bearer JWT (publisher) or X-API-Key.
Send multipart/form-data:
| Field | Required | Notes |
|---|---|---|
title |
yes | max 120 chars |
category |
yes | must match a store category |
description |
no | max 5000 chars |
tags |
no | comma separated or repeated fields |
license |
no | defaults to Lumo Asset License |
version |
no | defaults to 1.0.0 |
price |
no | USD amount, 0 = free |
status |
no | draft keeps it private, otherwise pending |
file |
yes* | the asset .zip, max 50 MB |
thumbnail |
no | image, max 5 MB |
file_url / thumbnail_url may be sent as plain fields instead of uploading a file (JSON
bodies are supported too).
Response — 201 Created — { "asset": { ... } } with status: "pending".
PATCH /api/assets/[id]
Owner or admin only. Accepts the same fields as POST (partial payloads are merged with the
current values) and may replace file / thumbnail.
Sending {"status": "draft"} unpublishes an asset; {"status": "pending"} sends it back
through review. Only admins can approve.
| Status | Meaning |
|---|---|
| 200 | Updated |
| 400 | Validation failed |
| 403 | Not the owner |
| 404 | Missing asset |
GET /api/assets/[id]/download
Increments the download counter and returns the file.
- Browsers receive a
302redirect to the file. - API clients (
Accept: application/json,?format=jsonorX-API-Key) receive JSON:
{
"id": "6b1f...",
"title": "Footstep Pack Vol. 1",
"slug": "footstep-pack-vol-1",
"url": "https://.../pack.zip",
"filename": "footstep-pack-vol-1-v1.2.0.zip",
"fileSize": 18432000,
"downloads": 6713,
"license": "CC0 1.0"
}
| Status | Meaning |
|---|---|
| 200 | URL returned |
| 302 | Browser redirect |
| 401 | Paid asset without a session (login_required) |
| 404 | Asset or file missing |
All admin endpoints require an admin Bearer JWT or the X-API-Key machine credential.
GET /api/admin/queue
Returns submissions for review.
| Query | Values | Default |
|---|---|---|
status |
pending | approved | rejected | draft | all |
pending |
curl "https://store.example.com/api/admin/queue?status=pending" \
-H "X-API-Key: $STORE_API_KEY"
Response — 200 OK
{
"items": [ { "id": "6b1f...", "title": "Sci-Fi Door", "status": "pending" } ],
"total": 1,
"status": "pending"
}
| Status | Meaning |
|---|---|
| 200 | Queue returned |
| 401 | No credentials |
| 403 | Caller is not an admin |
PATCH /api/admin/assets/[id]
{ "action": "approve", "comment": "Looks great, published." }
| Action | Effect |
|---|---|
approve |
status = approved, visible in the store |
reject |
status = rejected + review_note (comment required) |
feature |
Adds the asset to the homepage carousel |
unfeature |
Removes it from the carousel |
Response — 200 OK — { "asset": { ... }, "action": "approve" }
GET /api/admin/users
Lists every account: { "items": [ { "id", "email", "username", "handle", "role", "banned", "createdAt" } ], "total": 12 }.
PATCH /api/admin/users/[id]
{ "role": "admin" }
{ "banned": true }
Both fields are optional. You cannot demote or ban your own account.
GET /api/admin/stats
{
"totalAssets": 12,
"approved": 10,
"pending": 2,
"rejected": 0,
"totalUsers": 2,
"totalDownloads": 48210,
"featured": 4
}
Envelope
Every non-2xx response uses the same shape:
{
"error": {
"code": "invalid_category",
"message": "Category must be one of: 3D Models, Textures & Materials, Sprites & 2D, Audio, Plugins, Templates.",
"details": {}
}
}
code is stable and safe to branch on. message is human readable and may change.
Status codes
| Status | code examples |
What to do |
|---|---|---|
| 400 | invalid_title, invalid_category, invalid_price, invalid_license, missing_file, missing_comment, invalid_action |
Fix the payload |
| 401 | unauthorized, invalid_credentials, login_required, signup_failed |
Log in and retry with a fresh token |
| 402/401 | login_required |
Paid asset — sign in first |
| 403 | forbidden, account_banned, role_required |
Caller is not allowed |
| 404 | not_found |
Asset or user does not exist (or is not public) |
| 409 | not_approved |
Approve the asset before featuring it |
| 500 | internal_error |
Retry, then open an issue |
| 503 | not_configured, storage_unavailable |
Deployment is missing environment variables |
Handling errors in a client
const res = await fetch("/api/assets", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: form,
});
if (!res.ok) {
const { error } = await res.json();
console.error(error.code, error.message);
}
CORS failures
If your request fails with a network error instead of a JSON body, the origin is probably not
listed in CORS_ORIGINS. Add your deployment origin to the variable and redeploy.
Reporting bugs
The API is part of Lumo Engine — report problems on the issue tracker.
Was this page helpful?
Tell the Lumo team what is missing.