Base URL
All endpoints live under /api/v1 on your own instance. The OpenAPI schema is the source of truth — if this guide and the schema ever disagree, the schema wins.
# local — through the UI's nginx http://localhost:3000/api/v1 # local — backend direct http://localhost:8000/api/v1 # production https://<your-host>/api/v1 # the machine-readable contract (no token needed): GET /api/v1/openapi.json # interactive Swagger UI: /api/v1/docs
curl at openapi.json — it always matches the version you're actually running.Conventions
- Everything is under
/api/v1; onlyGET /healthis unversioned, for infra probes. - Authenticate with
Authorization: Bearer <token>— a JWT access token or an API key (ah_…). - Payloads go in the JSON body, never the query string. List responses are always an
{"items": [...]}envelope. - Errors follow RFC 9457
application/problem+json:{type, title, status, detail}(pluserrorson validation failures). - Generation is asynchronous: you get
202 Accepted+ aLocation: /api/v1/jobs/{job_id}header, then poll the job. - Create →
201, delete →204, someone else's resource →403, state conflict →409.
Authentication
Log in for a token pair, then send the access token on every request. The first registered user becomes an activated admin; everyone else needs admin approval.
POST /auth/login {"email","password"} → {access_token, refresh_token} POST /auth/refresh {"refresh_token"} → new token pair GET /auth/me → UserResponse # then, on every request: Authorization: Bearer <access_token>
API keys (for machine clients)
For scripts or integrations (e.g. a Unity plugin), create a long-lived API key and use it exactly like a bearer token. The plaintext key is shown once.
POST /auth/api-keys {"name"} → 201 {key: "ah_…"} # copy it now GET /auth/api-keys → {"items": [...]} DELETE /auth/api-keys/{key_id} → 204
GET /auth/oidc/login starts the flow; set the IdP redirect URI to {PUBLIC_URL}/api/v1/auth/oidc/callback.Search & items
Hybrid search combines keyword (pg_trgm) and semantic (vector) matching, fused and paginated. Image URLs come back as short-lived signed links that work directly in an <img> without a token.
GET /items/search?q=chair&limit=60&offset=0&asset_filter=image,3d → {"items": [ItemSummary], "limit", "offset", "has_more"} GET /items/{id} → ItemDetail (files, jobs, signed URLs) GET /items/{id}/similar → {"items": [...]} # embedding neighbours PATCH /items/{id} # edit allowed fields → {"status":"ok"} DELETE /items/{id} → 204 POST /items/{id}/tags {"tags":["chair"]} → 201 {"tags":[...]}
limit1–200,sort∈date-asc | name-asc | name-desc(newest first by default; withq, relevance wins).asset_filter— comma-separated modalities (image,3d);item_type— origin facet (generated | upload | …).
Generation
All generation is asynchronous and returns a job to poll. Send a prompt, pick a model/provider, optionally spread the work across several providers at once with distribution — every result lands in one Set for side-by-side comparison.
POST /items/generate GenerateRequest (prompt, generator_model, provider?, distribution?) POST /items/generate-upload # multipart: files + prompt/category/provider… → 202 Accepted Location: /api/v1/jobs/{job_id} JobInfo {job_id, status:"QUEUED", generation_mode, item_id, service} # race several providers — one Set, results side by side: {"distribution": {"comfyui": 2, "meshy": 1}} → + {group_id, job_ids}
Derive from an existing asset
Derive actions never mutate the source — each result is a new item in the source's Set.
POST /items/{id}/image-to-3d {"provider"?, "distribution"?, "workflow_filename"?} POST /items/{id}/reimagine {"prompt"?, "provider"?, …} POST /items/{id}/image-to-video {"prompt"?, "duration_seconds"?, …} POST /items/{id}/retexture {"prompt"?, "provider"?, …} # operate on many at once: POST /items/bulk-generate {"item_ids", "target":"3d"|"image"|"video"} → 202
Jobs
After a 202, poll the job until it reaches a terminal state. Active jobs report their queue position; finished ones carry timing, progress and any error.
GET /jobs?limit=50 → {"items": [JobInfo]} # queue_position while active GET /jobs/{job_id} → JobInfo (progress, stage, error_message, timestamps) POST /jobs/{job_id}/retry # re-queue a FAILED / CANCELED job POST /jobs/{job_id}/cancel # 409 once it's already at the provider
QUEUED → IN_PROGRESS → SUCCEEDED (preview → final), then the LLM auto-enriches metadata. On failure the item exposes a failed_job_id so the UI can offer a retry.Sets & collections
Sets (groups) bundle related items — e.g. every variant from a race. Collections are the named tiles in the library sidebar.
GET /groups → {"items": [{id, name, member_count, modalities}]} POST /groups {"name", "item_ids"?} → 201 POST /groups/{id}/bulk-generate {"target":"3d", …} → 202 GET /collections → {"items": [tiles]} PUT /collections {"collections":[tiles]} # full replace, order = list
Errors
Every error is application/problem+json — a predictable shape you can branch on.
GET /api/v1/items/999999 → 404 Content-Type: application/problem+json {"type":"about:blank", "title":"Not Found", "status":404, "detail":"Item not found"} # 401 additionally carries: WWW-Authenticate: Bearer (RFC 6750)
/api/v1/docs on your instance, or read API.md in the repository.