Appearance
Magic.ai â Complete Storefront Developer Guide
Auto-merged whole-picture reference for storefront (customer-facing) developers. Combines every developer doc in one file, ordered as you'd build: auth â discover models â generate â track jobs â assets â community â billing â the rest. Source docs live alongside this file; the live API explorer is at
/docs.
Contents
- Getting started
- Authentication
- Client Implementation Guide (Storefront)
- Errors & rate limits
- Models API
- AI Gateway
- Creation Studio Storefront Guide
- Async jobs & live status
- Files & media
- Library
- Community Storefront Implementation Guide
- Community feed
- Billing & payments
- Usage API
- Account API
- Notifications API
- Outgoing webhooks
- Workspaces / Teams
- Chat API
- Support (tickets)
- Announcements & feature flags
Getting started
Everything a client app needs lives under one base URL.
| Environment | Base URL |
|---|---|
| Local dev | http://localhost:4040 |
| Production | https://api-momagic.ai.hullor.io |
- All endpoints are under
/v1. - All request/response bodies are
application/jsonunless stated. - All timestamps are ISO-8601 UTC.
- Interactive OpenAPI explorer:
GET /docson the API host (164+ endpoints, always in sync with the code).
Conventions
- Auth â send
Authorization: Bearer <accessToken>. Server-to-server integrations can use an API key instead, where the route allows it. - Errors â one envelope everywhere:
{ "error": { "code", "message", "details" } }. See Errors & rate limits. - Cursor pagination â list endpoints take
?limit=&cursor=and return{ "items": [...], "nextCursor": "..." }. PassnextCursorback as?cursor=for the next page;nextCursor: nullmeans the end. Every row is returned exactly once. - Strict query params â list filters reject unknown query keys with a
400. A typo like?favorite=true(correct:favoritesOnly) fails loudly instead of silently returning unfiltered data.
60-second integration
bash
# 1. Create an account
curl -X POST http://localhost:4040/v1/auth/signup \
-H 'Content-Type: application/json' \
-d '{"email":"dev@example.com","password":"hunter2hunter2","name":"Dev"}'
# â { "user": {...}, "accessToken": "eyJ..." } + httpOnly `rt` refresh cookie
# 2. Call the AI gateway
curl -X POST http://localhost:4040/v1/ai/text \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"Hello!"}]}'Demo accounts + seed data
Run npm run seed:demo (after the base npm run seed) to populate realistic content for integration testing â an active subscription, generations, a community feed, notifications, saved prompts, and a support ticket.
| Password | What they have | |
|---|---|---|
demo@magic.ai | demo1234 | Active Pro subscription, generations, notifications, a collection |
creator@magic.ai | demo1234 | Public generations + community posts with likes/comments |
newbie@magic.ai | demo1234 | A couple of public posts, follows creator |
After logging in as demo@magic.ai you'll immediately see data in: GET /v1/community/feed/recent, /v1/library/generations, /v1/notifications, /v1/library/prompts, /v1/users/me (activeSubscription).
Client stack recommendations
The repo ships a reference implementation in admin/src/lib/:
api.tsâ fetch wrapper with Bearer injection, single-flight token refresh on 401, and uniform error unwrap. Copy it.auth.tsxâ React context handling silent session restore on boot (POST /v1/auth/refreshusing the cookie), login, logout.
Boot sequence for a SPA
POST /v1/auth/refresh(cookie is sent automatically) â access token, or 401 â show login.GET /v1/users/meâ user profile + active subscription.GET /v1/flagsâ feature flags for the viewer.GET /v1/announcementsâ banners.GET /v1/models/me?type=TEXTâ models the user's plan allows, for the picker.
Authentication
Two token types:
| Token | Form | TTL | Where it lives |
|---|---|---|---|
| Access token | JWT | 15 min | JSON response body â keep in memory, send as Authorization: Bearer <token> |
| Refresh token | opaque | 30 days | httpOnly cookie rt scoped to /v1/auth â browser sends it automatically |
Endpoints
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /v1/auth/signup | { email, password, name? } | { user, accessToken } + sets rt |
| POST | /v1/auth/login | { email, password } | { user, accessToken } + sets rt |
| POST | /v1/auth/refresh | â (cookie) | { accessToken } + rotated rt |
| POST | /v1/auth/logout | â | { ok: true } + clears rt |
| POST | /v1/auth/google/token | { idToken } or { credential } | { user, accessToken, ⦠} + sets rt |
| GET | /v1/auth/google | browser redirect | 302 â Google consent |
| GET | /v1/auth/google/callback | Google sends code | 302 â ${FRONTEND_URL}/auth/callback#access_token=⦠|
Token response shape
signup and login return both tokens in the body plus set the rt cookie:
json
{
"user": { "id": "â¦", "email": "â¦", "role": "USER", "name": "â¦" },
"accessToken": "eyJâ¦",
"refreshToken": "AbCâ¦",
"tokenType": "Bearer",
"expiresIn": 900
}- Web apps can ignore
refreshTokenand rely on the httpOnlyrtcookie. - Mobile / server-to-server clients (which can't use cookies) store
refreshTokenand send it in the refresh call body.
The refresh loop
POST /v1/auth/refresh accepts the refresh token either in the body (API clients) or via the rt cookie (web):
bash
# API-client style
curl -X POST /v1/auth/refresh -H 'Content-Type: application/json' \
-d '{"refreshToken":"AbCâ¦"}'
# â { accessToken, refreshToken, tokenType, expiresIn } â a NEW pair
# Web style (cookie sent automatically, empty body)
curl -X POST /v1/auth/refresh --cookie "rt=â¦"On any 401, call /v1/auth/refresh once and replay the original request. Both tokens rotate â every refresh returns a new access and refresh token, and invalidates the previous refresh token. Store the new refreshToken from each response. Reusing a revoked token is treated as theft: the server revokes all sessions for that user.
POST /v1/auth/logout likewise takes the token from body ({ refreshToken }) or cookie.
ts
// Single-flight refresh â parallel 401s coalesce onto one promise
let refreshing: Promise<string | null> | null = null;
async function refresh() {
refreshing ??= fetch('/v1/auth/refresh', { method: 'POST', credentials: 'include' })
.then(r => r.ok ? r.json().then(b => b.accessToken) : null)
.finally(() => { refreshing = null; });
return refreshing;
}Google sign-in
Two ways in â both find-or-create the user by verified Google email, mark emailVerified: true, open a normal session, and set the rt cookie. A brand-new Google user is created with an unguessable random password (they sign in with Google, not a password). An existing email is linked automatically.
Option A â ID token (recommended for SPA / mobile)
Render Google's "Sign in with Google" button or One Tap (Google Identity Services). The browser SDK hands you a credential (a Google ID token) with no redirect. POST it to the API and you get the exact same token bundle as a password login:
js
// GIS callback â `response.credential` is the Google ID token
async function onGoogle(response) {
const r = await fetch('/v1/auth/google/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // so the rt cookie is stored
body: JSON.stringify({ credential: response.credential }),
});
const { accessToken /*, user, refreshToken */ } = await r.json();
// store accessToken in memory; you're logged in
}- Body accepts
{ credential }(the GIS field) or{ idToken }â either works. - Response is identical to
POST /v1/auth/login:{ user, accessToken, refreshToken, tokenType, expiresIn }+rtcookie. - The server verifies the token was minted for its
GOOGLE_CLIENT_ID; a token for another app is rejected401. - Needs only
GOOGLE_CLIENT_IDon the server (no client secret).503 GOOGLE_SIGNIN_DISABLEDif it's unset. - Use the same
GOOGLE_CLIENT_ID(an OAuth Web client) in your GIS button config and on the server.
Option B â redirect (server-side OAuth, browser)
Enabled when the server has both GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET:
- Browser navigates to
GET /v1/auth/google(plain link, not XHR). - Server runs Authorization-Code + PKCE with Google.
- On success it redirects to
${FRONTEND_URL}/auth/callback#access_token=â¦
The token arrives in the URL fragment (never sent to servers, never logged):
js
// SPA route /auth/callback
const token = new URLSearchParams(location.hash.slice(1)).get('access_token');
history.replaceState(null, '', location.pathname); // strip it from the URL barGoogle Cloud Console setup
Create one OAuth Web client (console â APIs & Services â Credentials) and configure:
| Setting | Value | Needed for |
|---|---|---|
Server env GOOGLE_CLIENT_ID | the client id | both options |
Server env GOOGLE_CLIENT_SECRET | the client secret | Option B only |
| Authorized JavaScript origins | your frontend origin, e.g. https://your-app.example.com (and http://localhost:5173 for dev) | Option A (the GIS button loads there) |
| Authorized redirect URIs | ${PUBLIC_API_URL}/v1/auth/google/callback â e.g. https://api-momagic.ai.hullor.io/v1/auth/google/callback (and the localhost form for dev) | Option B |
The redirect URI must match exactly â scheme, host, and port. A mismatch makes Google return Error 400: redirect_uri_mismatch before any login (the request never reaches our API). It must equal PUBLIC_API_URL + /v1/auth/google/callback, so keep PUBLIC_API_URL in the server env in sync with what you register here.
Two-factor login (TOTP)
If the account has 2FA enabled, POST /v1/auth/login does not return tokens. Instead it responds 402 with a challenge:
json
{ "mfaRequired": true, "challengeToken": "eyJâ¦", "tokenType": "Bearer" }Complete the login by submitting the challenge + a code to POST /v1/auth/2fa/verify:
bash
curl -X POST /v1/auth/2fa/verify -H 'Content-Type: application/json' -d '{
"challengeToken": "eyJâ¦",
"code": "123456" // 6-digit authenticator code, OR a recovery code like "a1b2-c3d4"
}'
# â { user, accessToken, refreshToken, tokenType, expiresIn } â same as a normal loginClient handling:
ts
const res = await api('/v1/auth/login', { email, password });
if (res.status === 402) {
const { challengeToken } = await res.json();
const code = await promptUserForCode(); // authenticator or recovery code
const tokens = await api('/v1/auth/2fa/verify', { challengeToken, code });
// store tokens, continue
}Rules:
- The
challengeTokenis valid 5 minutes and works only at/2fa/verifyâ it is rejected as a Bearer access token on every other endpoint. - A 6-digit code is verified as TOTP (±1 time-step drift tolerance); anything else is tried as a single-use recovery code (consumed on success).
- Wrong code â
401, logged as aTWO_FA_FAILEDsecurity event.
Managing 2FA (while logged in)
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/account/2fa/setup | Returns { secret, otpauthUrl } â render the otpauthUrl as a QR code. |
| POST | /v1/account/2fa/enable | { code } (6-digit) â enables 2FA, returns recoveryCodes once. |
| POST | /v1/account/2fa/disable | { password } â turns 2FA off. |
| GET | /v1/account/2fa/status | { enabled, since, recoveryCodesRemaining }. |
| POST | /v1/account/2fa/recovery-codes/regenerate | { code } (6-digit) â replaces recovery codes, returns the new set once. |
Recovery codes are single-use â show recoveryCodesRemaining and prompt the user to regenerate when it runs low.
Forgot password (4-digit OTP)
Three steps â the guessable 4-digit code never changes the password directly:
1. POST /v1/account/password/forgot { email }
â { ok: true, validMinutes: 10 } (OTP emailed; always ok â anti-enumeration)
2. POST /v1/account/password/verify-otp { email, otp: "4829" }
â { resetToken: "W7O3_kYxâ¦" } (valid 15 min)
3. POST /v1/account/password/reset { token: resetToken, password: "new-password" }
â { ok: true } (all sessions revoked)Rules the client should surface in UI:
- OTP is valid 10 minutes, single-use.
- 5 wrong attempts kill the code â user must request a new one.
- Re-requesting within 60 seconds is silently ignored (show a countdown on the "Resend" button).
- Requesting a new code invalidates the previous one.
- After a successful reset every session is revoked â take the user to login.
Security model
- 10 failed logins in 15 min â 15-minute lockout (per userId and per email).
- First login from an unseen IP â in-app security notification.
- 2FA (TOTP) available via
/v1/account/2fa/*. - API keys for server-to-server:
POST /v1/account/api-keys(scoped, revocable).
Client Implementation Guide (Storefront)
Audience: client-side (storefront web/mobile) developers wiring the customer app to the Magic.ai API â authentication, session/token lifecycle, and the API client, then the feature surfaces.
This is the entry point. Feature endpoints live in:
- Creation studio guide â image/video/audio generation + controls, async jobs.
- Community storefront guide â public feed, likes, comments, follows, profiles, publish.
TL;DR: Get an access token (email/password, Google, or signup), send it as Authorization: Bearer <token> on every call, and refresh it on 401. Public GETs (feeds, works, models) work anonymously; anything that writes or is user-scoped needs the token.
- Base: prod
https://api-momagic.ai.hullor.io· devhttp://localhost:4040· live schema/docs. - Success: single resources return the object; lists return
{ items, nextCursor }(cursor pagination). - Errors: uniform
{ "error": { "code", "message", "details" } }. Unknown query params â400.
1. Auth model in one picture
signup / login / google ââ⺠{ user, accessToken, refreshToken, expiresIn } (+ httpOnly `rt` cookie)
â â
â store accessToken in memory
â¼ â¼
every API call: Authorization: Bearer <accessToken>
â
â¼
401 Unauthorized ââ⺠POST /v1/auth/refresh ââ⺠new accessToken ââ⺠replay the request once- Access token â short-lived JWT (
expiresInseconds). Keep it in memory (not localStorage). Send asAuthorization: Bearer. - Refresh token â returned two ways: an httpOnly
rtcookie (browsers, same-origin) and arefreshTokenfield in the body (mobile / cross-origin clients that can't use the cookie). Use one. GET /v1/users/mereturns the signed-in user incl.handle,avatarUrl,bioâ call it on boot to hydrate.
2. Discover enabled sign-in methods
GET /v1/auth/config (public) â { "googleClientId": "â¦apps.googleusercontent.com" | null }.
Render the Google button only when googleClientId is non-null; otherwise show email/password only.
3. Sign up
POST /v1/auth/signup â { "email", "password" (â¥8), "name"? } â { user, accessToken, refreshToken, tokenType: "Bearer", expiresIn } + sets rt cookie. Store accessToken, you're logged in.
4. Email / password login
POST /v1/auth/login â { "email", "password" }.
- 200 â
{ user, accessToken, refreshToken, tokenType, expiresIn }. - 402 â
{ "mfaRequired": true, "challengeToken": "â¦" }â the account has 2FA. Prompt for the 6-digit code, then:POST /v1/auth/2fa/verifyâ{ "challengeToken", "code" }â the same token bundle. ThechallengeTokenlives ~5 min. - 401 â bad credentials. (10 failures within 15 min â temporarily locked.)
5. Google sign-in
Two options â pick one.
5a. Google Identity Services button (recommended for SPAs)
Load GIS, render a button, exchange the returned credential (a Google ID token) for our session. No redirect.
html
<script src="https://accounts.google.com/gsi/client" async defer></script>ts
const { googleClientId } = await api.get('/v1/auth/config');
if (googleClientId) {
google.accounts.id.initialize({
client_id: googleClientId,
callback: async ({ credential }) => {
const res = await api.post('/v1/auth/google/token', { credential }); // { user, accessToken, ... }
setAccessToken(res.accessToken);
// res.user is now signed in
},
});
google.accounts.id.renderButton(document.getElementById('gbtn'), { theme: 'outline', size: 'large' });
}POST /v1/auth/google/token â { "credential" } (or { "idToken" }) â same bundle as login. 401 invalid token · 503 if Google sign-in isn't configured server-side.
The client's origin must be an Authorized JavaScript origin on the Google OAuth client, and the server must have
GOOGLE_CLIENT_ID/SECRETset.
5b. Redirect flow (no GIS / server-rendered)
Navigate the browser to {API}/v1/auth/google. After consent the server redirects to {FRONTEND_URL}/auth/callback#access_token=<jwt> (token in the URL fragment, so it never hits a server log) â or â¦/auth/callback?error=<reason> on failure. Your /auth/callback route reads the fragment, stores the access token, and drops the fragment from history.
6. Token lifecycle: refresh + auto-replay
Access tokens expire (expiresIn). On any 401, call refresh once and replay:
POST /v1/auth/refresh â { accessToken, refreshToken, tokenType, expiresIn }.
- Browser same-origin: the httpOnly
rtcookie is sent automatically â sendcredentials: 'include', no body. - Mobile / cross-origin: send the stored
refreshTokenin the body.
If refresh fails â the session is gone; clear state and route to login.
7. Logout
POST /v1/auth/logout (send credentials: 'include' so the rt cookie is cleared). Then drop the in-memory access token and reset UI to anonymous.
8. Cross-origin note (important)
The rt cookie is SameSite=Lax, Secure in prod, scoped to path /v1/auth.
- Storefront same-origin with the API (or behind the same proxy): the cookie flow works as-is.
- Storefront on a different domain: the cookie won't ride cross-site. Either (a) reverse-proxy the API under the storefront origin, or (b) ignore the cookie and persist the
refreshTokenfrom the body (treat it like the access token's long-lived partner; store securely). All API calls needcredentials: 'include'and the server's CORS must allow your origin.
9. Reference API client (framework-agnostic TS)
Drop-in pattern: in-memory token, Bearer injection, single-flight refresh, 401 replay, uniform error unwrap.
ts
let accessToken: string | null = null;
export const setAccessToken = (t: string | null) => { accessToken = t; };
let refreshInflight: Promise<string | null> | null = null;
async function refresh(): Promise<string | null> {
refreshInflight ??= (async () => {
try {
const r = await fetch('/v1/auth/refresh', { method: 'POST', credentials: 'include' });
if (!r.ok) return null;
const { accessToken: t } = await r.json();
setAccessToken(t);
return t;
} catch { return null; } finally { refreshInflight = null; }
})();
return refreshInflight;
}
async function request<T>(path: string, opts: { method?: string; body?: unknown; retry?: boolean } = {}): Promise<T> {
const { method = 'GET', body, retry = true } = opts;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
const res = await fetch(path, {
method, headers, credentials: 'include',
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401 && retry) {
const t = await refresh();
if (t) return request<T>(path, { ...opts, retry: false });
setAccessToken(null);
}
const json = res.headers.get('content-type')?.includes('application/json') ? await res.json() : null;
if (!res.ok) throw Object.assign(new Error(json?.error?.message ?? `HTTP ${res.status}`), { status: res.status, code: json?.error?.code, details: json?.error?.details });
return json as T;
}
export const api = {
get: <T>(p: string) => request<T>(p),
post: <T>(p: string, body?: unknown) => request<T>(p, { method: 'POST', body }),
patch:<T>(p: string, body?: unknown) => request<T>(p, { method: 'PATCH', body }),
del: <T>(p: string) => request<T>(p, { method: 'DELETE' }),
};App boot: try refresh(); if it returns a token, GET /v1/users/me to hydrate; else anonymous.
Async SSE (video job stream) needs the header, so EventSource won't work â use fetch + a stream reader (see the studio guide §8).
10. Community onboarding (before first publish)
Publishing / having a public profile needs a handle. After login, if me.handle == null, prompt for one:
PATCH /v1/account/profile â { "handle": "3-30 chars [a-zA-Z0-9_]", "avatarUrl"?, "bio"? } â 409 on collision.
Avatar upload is the 2-step file flow (studio guide §9, kind: "AVATAR").
11. Error codes to handle globally
| HTTP | code | Meaning | Client action |
|---|---|---|---|
| 400 | BAD_REQUEST | Validation / content filter | Show message inline |
| 401 | UNAUTHORIZED | Missing/expired token | Auto-refresh + replay; else â login |
| 402 | NO_ACTIVE_SUBSCRIPTION | No active plan | Prompt to subscribe |
| 402 | mfaRequired (login) | 2FA needed | Go to code step (/2fa/verify) |
| 403 | FORBIDDEN / MODEL_NOT_IN_PLAN | Not allowed / plan gate | Upsell / hide action |
| 404 | NOT_FOUND | Gone / wrong id | Empty state |
| 409 | CONFLICT | e.g. handle taken | Ask for a different value |
| 429 | QUOTA_EXCEEDED | Out of credits (bucket named in message) | "Top up / upgrade" |
| 503 | PROVIDER_UNAVAILABLE / GOOGLE_SIGNIN_DISABLED | Upstream down / feature off | Retry later / hide method |
12. Suggested build order
- API client (§9) +
GET /v1/auth/configgating. - Auth: email/password + Google button, refresh-on-boot, logout.
/users/mehydration + handle onboarding (§10).- Studio: model picker â text-to-image â job tray for video (studio guide).
- Community: gallery grid â detail â like/follow â publish (community guide).
- Global error handling (§11) + credit/quota surfacing.
Errors & rate limits
Every error uses one envelope:
json
{ "error": { "code": "QUOTA_EXCEEDED", "message": "daily request limit reached", "details": null } }| HTTP | code | Meaning | Client action |
|---|---|---|---|
| 400 | BAD_REQUEST | Invalid payload â details carries zod issues. Also returned for unknown query parameters (list filters are strict: ?favorite=true fails; the correct name is favoritesOnly) | Fix the request |
| 401 | UNAUTHORIZED | Missing/expired token | Refresh once, replay; then login |
| 403 | FORBIDDEN | Role/ownership check failed | Don't retry |
| 404 | NOT_FOUND | Resource missing | Don't retry |
| 409 | CONFLICT | Duplicate (email taken, slug takenâ¦) | Surface to user |
| 402/403 | QUOTA_EXCEEDED | Plan limit hit | Upsell / show usage page |
| 403 | NO_ACTIVE_SUBSCRIPTION | Needs a plan | Redirect to pricing |
| 403 | MODEL_NOT_IN_PLAN | Model not allowed on this tier | Show plan-gated UI |
| 503 | PROVIDER_UNAVAILABLE | All upstream accounts down/exhausted | Retry with backoff |
| 500 | INTERNAL | Unhandled | Report; retry once |
Rate limits
Global gateway limit: 300 requests / minute / IP. Exceeding returns Fastify's 429 envelope. Per-plan quotas (daily/monthly requests, token budget, image/video credits) are enforced by the AI gateway and surface as QUOTA_EXCEEDED â read GET /v1/subscriptions/credits/balance for the remaining wallet, and GET /v1/usage/summary for the usage breakdown.
Models API
The catalog of AI models you can send to the gateway â plus which ones the caller's plan unlocks.
| Method | Path | Notes |
|---|---|---|
GET | /v1/models | Public catalog of servable models. Optional Bearer adds a per-row allowed flag. |
GET | /v1/models/me | Requires Bearer. Only the models the caller's active plan grants â use this for the picker. |
Both endpoints hide anything not currently servable: a model is shown only when its own status is ACTIVE or BETA and its provider status is ACTIVE or DEGRADED. Models under a DISABLED/inactive provider never appear. Results are ordered by type, then slug.
GET /v1/models
Public â no auth required. Supports two optional query filters:
| Query | Values | Effect |
|---|---|---|
type | TEXT | IMAGE | VIDEO | AUDIO | EMBEDDING | MULTIMODAL | Only models of that modality. |
provider | provider slug | Only models from that provider. |
bash
curl "http://localhost:4040/v1/models?type=TEXT"
# â {
# "items": [
# {
# "id": "clxâ¦", "slug": "openai/gpt-4o-mini", "displayName": "GPT-4o mini",
# "type": "TEXT", "status": "ACTIVE",
# "capabilities": { "vision": true, "tools": true },
# "pricing": { "inputPerMTok": 0.15, "outputPerMTok": 0.6 },
# "maxContextTokens": 128000,
# "provider": { "slug": "openai", "name": "OpenAI", "status": "ACTIVE" },
# "allowed": null
# }
# ]
# }allowed is null for anonymous callers. Pass a token and each row reports whether the caller's plan grants it:
bash
curl "http://localhost:4040/v1/models" -H "Authorization: Bearer $TOK"
# â { "items": [ { "slug": "openai/gpt-4o-mini", â¦, "allowed": true }, ⦠] }GET /v1/models/me
Requires a Bearer access token. Returns only the models the caller's active subscription plan allows (already filtered to servable providers) â this is what a client uses to build its model picker. Accepts no query filters.
bash
curl "http://localhost:4040/v1/models/me" -H "Authorization: Bearer $TOK"
# â { "items": [ { "slug": "openai/gpt-4o-mini", "displayName": "GPT-4o mini",
# "type": "TEXT", "provider": { "slug": "openai", "name": "OpenAI" }, ⦠} ] }With no active plan the list is empty. Missing or invalid token â 401.
The model object
| Field | Notes |
|---|---|
id | Stable model id (used internally). |
slug | The identifier you send as model to the gateway, e.g. openai/gpt-4o-mini. |
displayName | Human label for the picker. |
type | TEXT | IMAGE | VIDEO | AUDIO | EMBEDDING | MULTIMODAL. |
status | ACTIVE | BETA | DISABLED (only ACTIVE/BETA are returned). |
capabilities | Free-form object, e.g. vision/tools/streaming support. |
pricing | Provider cost, for reference only â not the customer price. Customers spend credits by count, not by this figure. |
maxContextTokens | Context window, or null if not applicable. |
provider | { slug, name, status } (status omitted on /me). |
allowed | /v1/models only: true/false for authenticated callers, null when anonymous. |
AI Gateway
One endpoint family for every model. The gateway resolves the model slug, checks the caller's plan, picks a healthy provider account, meters usage, and returns a normalized response â the client never talks to OpenAI/Gemini/Kling/etc. directly.
Text â POST /v1/ai/text
json
{
"model": "openai/gpt-4o-mini",
"messages": [
{ "role": "system", "content": "You are terse." },
{ "role": "user", "content": "Explain HTTP 402." }
],
"maxTokens": 512,
"temperature": 0.7,
"stream": false
}Response:
json
{
"text": "â¦",
"usage": { "inputTokens": 31, "outputTokens": 118, "creditsConsumed": 1 },
"model": "openai/gpt-4o-mini",
"requestId": "req_â¦"
}Each call is saved as text history (retrievable via GET /v1/library/generations?kind=TEXT) but is not shown in the media library. For threaded, session-based history (ChatGPT/Claude-style), use Chat instead â it persists conversations and replays context.
Streaming (SSE)
Set "stream": true and consume as an EventSource-style stream. Each event is data: <json>:
data: {"type":"text","delta":"Hy"}
data: {"type":"text","delta":"pertext"}
data: {"type":"usage","usage":{"inputTokens":31,"outputTokens":118}}
data: {"type":"finish","finishReason":"stop"}type â text · usage · finish · error.
ts
const res = await fetch('/v1/ai/text', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ model, messages, stream: true }),
});
const reader = res.body!.getReader();
// parse "data: {...}\n\n" frames from the byte streamImage â POST /v1/ai/image
json
{ "model": "fal/flux-schnell", "prompt": "a lighthouse at dawn", "width": 1024, "height": 1024, "count": 1 }Returns { images: [{ url, mimeType }], usage: { imagesGenerated } }. Images are copied off the provider CDN into platform storage â the url is stable and permanent.
Video â POST /v1/ai/video (async)
json
{ "model": "kling/v1-6-pro", "prompt": "waves crashing in slow motion", "durationSeconds": 5, "aspectRatio": "16:9" }Returns immediately with a job handle: { jobId, status: "QUEUED", creditsReserved }. See Async jobs for polling / SSE.
Enhance a prompt â POST /v1/ai/enhance/prompt
Rewrites a rough idea into a rich, detailed prompt for a target modality. Metered exactly like a text generation (needs the ai:text scope for API keys).
json
{ "model": "gemini/gemini-2.5-flash", "prompt": "a knight fighting a dragon", "target": "image" }Response:
json
{ "enhanced": "An epic dark-fantasy cinematic shot of a battle-worn knight in wet gothic plate armorâ¦" }modelmust be a multimodal model (the Gemini line). Small text-only models (e.g. Gemma) are rejected with400â they restate the instruction instead of rewriting. Pick a model fromGET /v1/models/me?type=MULTIMODAL.targetisimage | video | audio | text, defaulttext. It tunes the rewrite (animagetarget adds composition/lighting/lens;textproduces a better writing instruction). Always set it to what you're generating.- The response is the finished prompt only â feed it straight into
/v1/ai/text|image|video.
Enhance an image â POST /v1/ai/enhance/image
Runs a source image back through an image model with an enhancement instruction (upscale / sharpen / clean, or any edit). Needs the ai:image scope.
json
{
"model": "gemini/gemini-2.5-flash-image",
"imageUrl": "/v1/files/raw/users/â¦/source.png",
"instruction": "upscale and sharpen, keep composition"
}Returns the same shape as /v1/ai/image: { images: [{ url, mimeType }], usage: { imagesGenerated } } (stable platform URLs). The enhanced image is saved to the library (it's a real derived asset) but tagged enhanced with a neutral caption ("Enhanced image") â not your internal instruction â so you can distinguish/filter it from things the user composed. Prompt-enhance (text) is not saved at all.
imageUrlaccepts adata:URL, a platform/v1/files/raw/â¦URL, or a plainhttps://URL. Prefer uploading via Files and passing the/v1/files/rawURL â it's read straight from storage (fastest, no external fetch).instructionis optional; omit it for a general upscale/clean.- A remote
imageUrlis fetched with an 8 s timeout and a 15 MB cap. If the source can't be read (404, hotlink-blocked403, too large, timeout), you get a400 BAD_REQUESTwith a message like "input image could not be fetched (HTTP 403)" â that means your source URL, not the AI service. A503/PROVIDER_UNAVAILABLEis a real provider issue; retry those with backoff, but don't retry a400.
Model discovery
GET /v1/modelsâ public catalog (slug, type, pricing).GET /v1/models/me?type=TEXT|IMAGE|VIDEO|AUDIO|MULTIMODALâ filtered to the caller's plan. Use this to populate pickers so users never select a model that will 403 (and to listMULTIMODALmodels for prompt enhancement).
Creation Studio Storefront Guide
Audience: storefront developers building the AI creation studio â the Kling-style /app workspace where users generate images and videos, tune controls, watch async jobs finish, and manage their assets.
Scope: this is the creation app (text/image/video/audio generation + controls), not the community feed. For the public gallery/social layer see Community storefront guide.
TL;DR: The generation gateway is built and production-grade. Text-to-image, image-to-image, text-to-video, image-to-video, TTS, prompt-enhance, and image-enhance all ship today, with async job tracking (SSE + webhooks), credit metering, and file uploads. As of 2026-07-20: advanced video controls are reachable via an extra passthrough (§7.1), the model catalog returns a dynamic control schema (capabilities, §2), and lip sync, video extend, and image inpaint are wired for Kling/Stability (// VERIFY against live specs). Remaining gaps (virtual try-on, multi-element) are in §14.
- Base: prod
https://api-momagic.ai.hullor.io· devhttp://localhost:4040· schema explorer/docs. - Auth:
Authorization: Bearer <JWT>(browser) orsk-magic-...API key. Every/v1/ai/*route needs a scope (ai:text/ai:image/ai:video/ai:audio). - Errors:
{ "error": { "code", "message", "details" } }.
1. Core concepts (read first)
The studio is four generation endpoints plus a job tracker, a model catalog, an uploader, and a credit ledger.
| Concept | Endpoint(s) | Notes |
|---|---|---|
| Model catalog | GET /v1/models, GET /v1/models/me | What can be generated + whether the user's plan grants it |
| Image (sync) | POST /v1/ai/image | Returns images inline |
| Video (async) | POST /v1/ai/video â job | Returns jobId; poll/stream for the result |
| Lip sync (async) | POST /v1/ai/video/lip-sync â job | Audio/text-driven mouth animation (Kling) |
| Video extend (async) | POST /v1/ai/video/extend â job | Continue a clip (Kling) |
| Audio (sync) | POST /v1/ai/audio | TTS |
| Text (sync/SSE) | POST /v1/ai/text | Chat/caption/idea helper |
| Prompt enhance | POST /v1/ai/enhance/prompt | Rewrite a rough idea into a rich prompt |
| Image enhance | POST /v1/ai/enhance/image | Upscale/clean via img2img |
| Jobs | GET /v1/jobs, GET /v1/jobs/:id, GET /v1/jobs/:id/stream, POST /v1/jobs/:id/cancel | Video lifecycle |
| Uploads | POST /v1/files/init â PUT | Seed/reference images |
| Assets | GET /v1/library/generations | The user's own creations |
Two things to internalize:
- Image is synchronous, video is asynchronous. Image returns pixels in the HTTP response. Video returns a
jobIdâ you then pollGET /v1/jobs/:idor subscribe to its SSE stream. Design the studio around a job queue/tray from day one. - Advanced/provider-specific controls go under
extra. The typed request fields cover the common controls (prompt, size, duration, aspect ratio, seed image). Everything model-specific â camera moves, motion paths, cfg, resolution, end frame, video negative prompt â is passed asextra(§7).
2. Model picker
GET /v1/models â public. Query: type=TEXT|IMAGE|VIDEO|AUDIO|MULTIMODAL · provider=<slug>.
json
{ "items": [
{ "id": "â¦", "slug": "kling/kling-v1", "displayName": "Kling v1", "type": "VIDEO",
"status": "ACTIVE", "capabilities": {}, "pricing": { "perSecond": 12 },
"maxContextTokens": null, "provider": { "slug": "kling", "name": "Kling", "status": "ACTIVE" },
"allowed": true } ] }- Group the picker by
type.slugis what you send asmodelin every generate call. allowed= the caller's plan grants this model (nullwhen anonymous). Gate the "Generate" button on it; deep-link to upgrade whenfalse.GET /v1/models/mereturns only the models the caller's active plan allows â use it to populate the picker for a signed-in user.capabilitiesis now a control schema (as of 2026-07-20):GET /v1/modelsreturnscapabilities = { flags?, controls, extra }â a per-model, dynamic-UI descriptor.controlslists first-class request fields (with type/label/range/default);extralists provider-specific keys to send under the requestextraobject. Render the studio control panel straight from this:
json
"capabilities": {
"controls": {
"durationSeconds": { "type": "integer", "label": "Duration (s)", "values": [5,10], "default": 5 },
"aspectRatio": { "type": "enum", "values": ["16:9","9:16","1:1","4:3","3:4"], "default": "16:9" },
"seedImageUrl": { "type": "image", "label": "Start frame", "role": "startFrame" },
"endImageUrl": { "type": "image", "label": "End frame", "role": "endFrame" },
"negativePrompt": { "type": "string" }, "seed": { "type": "integer" }
},
"extra": {
"cfg_scale": { "type": "number", "min": 0, "max": 1, "step": 0.1, "label": "Prompt adherence (CFG)" },
"mode": { "type": "enum", "values": ["std","pro"], "default": "std" },
"camera_control": { "type": "object", "label": "Camera movement" },
"dynamic_masks": { "type": "object", "label": "Motion brush" }
}
}Values are a built-in default per model type + provider; an admin can override any field per model via the DB capabilities JSON (deep-merged, DB wins). Keys in controls map to top-level request fields; keys in extra go under the request's extra object.
3. Text-to-image
POST /v1/ai/image â scope ai:image. Synchronous.
json
// request
{ "model": "openai/gpt-image-1", "prompt": "a neon koi in a rain puddle",
"negativePrompt": "text, watermark", "width": 1024, "height": 1024,
"count": 1, "seed": 42 }json
// response
{ "images": [ { "url": "/v1/files/raw/users/â¦/abc.png", "mimeType": "image/png" } ],
"usage": { "imagesGenerated": 1 } }- Aspect ratio: send explicit
width/height(no named ratio field; adapters convert). Offer ratio presets in the UI that map to pixel sizes. count1â8 â grid of results.seedfor reproducibility; surface a "reuse seed" affordance.- URLs are already re-hosted to our storage (stable, no signed-URL refresh). May occasionally be a
data:URL â render both transparently.
4. Image-to-image / edit
Same endpoint, add a source image:
json
{ "model": "â¦", "prompt": "make it winter", "initImageUrl": "/v1/files/raw/â¦/src.png", "strength": 0.6 }initImageUrlaccepts adata:URL, anhttpsURL, or a same-origin/v1/files/raw/*URL (from the uploader, §9).strength0â1 (0 = keep source, 1 = ignore it). Provider-dependent; adapters that don't support it ignore it.- Not supported: mask/inpaint, structured style presets (§12).
5. Prompt enhance
POST /v1/ai/enhance/prompt â scope ai:text. Turn a rough idea into a rich prompt before generating.
json
{ "prompt": "cat astronaut", "model": "gemini/gemini-2.5-flash", "target": "image" }
// â { "enhanced": "A photorealistic tabby cat in a detailed white spacesuit, floatingâ¦" }modelmust be multimodal or you get400.targetâimage|video|audio|texttunes the rewrite. Metered like a text call; kept out of the library.- Studio pattern: an "⨠Enhance" button next to the prompt box; on click, replace/preview the enhanced prompt.
6. Image enhance / upscale
POST /v1/ai/enhance/image â scope ai:image. Runs the source through img2img with a clean/upscale instruction.
json
{ "imageUrl": "/v1/files/raw/â¦/a.png", "model": "gemini/gemini-2.5-flash-image", "instruction": "upscale, sharpen" }
// â same shape as POST /v1/ai/image7. Video generation
POST /v1/ai/video â scope ai:video. Async â returns a job.
json
// text-to-video
{ "model": "kling/kling-v1", "prompt": "a drone shot over neon Tokyo",
"durationSeconds": 5, "aspectRatio": "16:9" }
// image-to-video (animate a still)
{ "model": "kling/kling-v1", "prompt": "gentle parallax", "seedImageUrl": "/v1/files/raw/â¦/frame.png",
"durationSeconds": 5, "aspectRatio": "9:16" }json
// response â no video yet; track the job
{ "jobId": "job_â¦", "handle": { "externalId": "task_â¦", "estimatedSeconds": 90 } }Typed fields: prompt, durationSeconds, aspectRatio (16:9|9:16|1:1|4:3|3:4), seedImageUrl (start frame), endImageUrl (end frame â first-last-frame video), negativePrompt, seed. Provider-portable â adapters map them to the provider's own keys and ignore what a provider doesn't support (Kling maps endImageUrlâimage_tail, negativePromptânegative_prompt).
7.1 Advanced controls via extra
Anything model-specific goes under extra â merged verbatim into the upstream provider call. This is how the studio exposes Kling's director-grade controls. Keys are provider-specific; send the exact keys the chosen model's provider expects.
json
{ "model": "kling/kling-v1", "prompt": "â¦", "seedImageUrl": "â¦/first.png",
"durationSeconds": 5, "aspectRatio": "16:9",
"extra": {
"negative_prompt": "blurry, distorted",
"cfg_scale": 0.5,
"mode": "pro",
"image_tail": "/v1/files/raw/â¦/last.png",
"camera_control": { "type": "simple", "config": { "horizontal": 5, "zoom": 0 } }
} }Reachable today via extra on the Kling provider (it maps 1:1 to Kling's own API â see src/adapters/providers/kling.ts):
| Kling studio feature | extra key(s) |
|---|---|
| Negative prompt (video) | negative_prompt |
| Prompt adherence | cfg_scale |
| Standard/Professional mode | mode: "std" | "pro" |
| End frame (start+end) | image_tail (with seedImageUrl as the start frame) |
| Camera movement | camera_control: { type, config } |
| Motion brush | dynamic_masks / static_mask / trajectories |
MiniMax (minimax/*) merges extra too â e.g. resolution. General rule: unknown keys a provider ignores are harmless, so validate ranges client-side and key the control set off the selected model.
The typed fields (
durationSeconds,aspectRatio,seedImageUrl) are just conveniences that map to common keys across providers. When in doubt, put it inextrawith the provider's exact key name.
7.2 Lip sync & video extend (async, own endpoints)
These are separate upstream calls, now wired as their own routes (Kling; // VERIFY against the live Kling spec before enabling in prod). Both return a jobId tracked exactly like a normal video job.
Lip sync â POST /v1/ai/video/lip-sync (scope ai:video):
json
{ "model": "kling/â¦", "videoId": "<work id>", "audioUrl": "/v1/files/raw/â¦/speech.mp3" }
// or drive from text: { "model": "â¦", "videoUrl": "â¦", "text": "Hello!", "voice": "â¦" }Require videoId or videoUrl, and audioUrl or text (else 400).
Video extend â POST /v1/ai/video/extend:
json
{ "model": "kling/â¦", "videoId": "<work id>", "prompt": "keep panning right" }Require videoId or videoUrl.
7.3 Still needs provider work
Virtual try-on and multi-element / multi-reference are separate model families not yet wired â they need a new adapter method + route once a backing provider/model is available. See §14 Gap C.
8. Async job lifecycle (video)
After POST /v1/ai/video you have a jobId. Three ways to learn when it finishes â use SSE for the active job, notifications/webhooks for background completion.
Poll: GET /v1/jobs/:id
json
{ "id": "job_â¦", "operation": "video.generate", "status": "RUNNING", "progress": 40,
"output": null, "errorCode": null, "errorMessage": null,
"createdAt": "â¦", "startedAt": "â¦", "completedAt": null }On success status: "SUCCEEDED" and output: { "videos": [ { "url": "/v1/files/raw/â¦/v.mp4", "mimeType": "video/mp4", "durationSeconds": 5 } ] }.
Stream (recommended for the active job): GET /v1/jobs/:id/stream (SSE). Emits event: status on each change and event: done on a terminal state. Auth is a Bearer header, so EventSource won't work â use fetch() with a ReadableStream reader.
List / tray: GET /v1/jobs (cursor-paginated) to render a "recent generations / in-progress" tray.
Cancel: POST /v1/jobs/:id/cancel.
Background completion: a finished job also fires a JOB_SUCCEEDED/JOB_FAILED notification and a job.succeeded/job.failed outbound webhook â wire the notification bell to these so a video that finishes after the user navigates away still surfaces. Typical end-to-end time is up to ~20 min (worker polls every 5s, 240 attempts).
- Refunds: if the worker fails a video job, the reserved video credit + provider budget are refunded automatically â reflect the restored balance in the UI on
FAILED. - Poll cadence when not using SSE: every 3â5s is plenty.
9. Uploads (seed / reference images)
Two-step, for initImageUrl (img2img), seedImageUrl/image_tail (image-to-video / end frame), etc.
POST /v1/files/initâ{ "kind": "SEED_IMAGE", "mimeType": "image/png", "sizeBytes": 812345 }â{ id, uploadUrl, method: "PUT", storageKey, publicUrl }.PUT <uploadUrl>with the raw bytes â marks the file READY.- Pass the returned
publicUrl(a/v1/files/raw/<key>URL) asinitImageUrl/seedImageUrl/extra.image_tail.
Limits: SEED_IMAGE ⤠10 MB, MIME png/jpeg/webp only (AVATAR ⤠2 MB, ATTACHMENT ⤠25 MB). Serve URLs support HTTP Range (video scrubbing works natively).
10. Audio / TTS
POST /v1/ai/audio â scope ai:audio. Sync.
json
{ "model": "elevenlabs/â¦", "prompt": "Welcome to the studio.", "voice": "â¦", "format": "mp3" }
// â { "audio": { "url": "/v1/files/raw/â¦/a.mp3", "mimeType": "audio/mpeg", "durationSeconds": 3.2 } }11. Assets (the user's creations)
GET /v1/library/generations â auth. Query: kind? · favoritesOnly · search · tag · limit(â¤100, def 24) · cursor. Media only (TEXT excluded by default).
- Each item carries
output(render),thumbnailUrl(grid),params(now includesextraâ re-run with the same advanced settings),model,isFavorite,visibility,tags. PATCH /v1/library/generations/:idâ rename, favorite, tag, or flipvisibility.DELETE â¦/:idâ remove.- Publish to the community from here â see the community guide (
POST /v1/community/posts). - Collections:
GET/POST /v1/library/collections(+ items) for folders/boards.
12. Credits & quota UX
Every generate call is gated (checkAndReserveQuota). Handle these statuses:
| HTTP | code | Meaning | UI |
|---|---|---|---|
| 402 | NO_ACTIVE_SUBSCRIPTION | No active plan | Prompt to subscribe |
| 403 | MODEL_NOT_IN_PLAN | Plan doesn't include this model | Upsell / disable model |
| 429 | QUOTA_EXCEEDED | Out of credits (text/image/video bucket) | "Top up or upgrade"; show which bucket |
| 503 | PROVIDER_UNAVAILABLE | All provider accounts failed/over budget | "Try again shortly" |
| 400 | BAD_REQUEST | Upstream user error / content filter | Show message inline |
Credits are per-bucket (textRequests, tokens, imageCredits, videoCredits). Image/video credits are debited after success; a failed video job is refunded. Show live balances from the subscription/credits endpoint and disable "Generate" pre-emptively when a bucket is empty.
13. Kling /app feature parity
| Kling feature | Status |
|---|---|
| Text-to-image | â
POST /v1/ai/image |
| Image-to-image / edit | â
initImageUrl + strength |
| Text-to-video | â
POST /v1/ai/video |
| Image-to-video (start frame) | â
seedImageUrl |
| Duration / aspect ratio | â typed fields |
| Prompt enhance | â
POST /v1/ai/enhance/prompt |
| Image upscale/enhance | â
POST /v1/ai/enhance/image |
| Negative prompt (video) | â
via extra.negative_prompt (Kling) |
| CFG / Std-Pro mode | â
via extra.cfg_scale / extra.mode (Kling) |
| End frame (start+end) | â
via extra.image_tail (Kling) |
| Camera movement | â
via extra.camera_control (Kling) |
| Motion brush | â
via extra.dynamic_masks/trajectories (Kling) â storefront must build the mask/path UI |
| Resolution / fps | ð¡ provider-dependent via extra (e.g. MiniMax resolution); not universal |
| Lip sync (audio-driven) | â
POST /v1/ai/video/lip-sync (Kling; // VERIFY) |
| Video extend | â
POST /v1/ai/video/extend (Kling; // VERIFY) |
| Virtual try-on | â needs a backing model + adapter method |
| Multi-element / multi-reference | â needs a backing model + adapter method |
| Inpaint / mask (image) | â
initImageUrl + maskUrl (Stability; // VERIFY) |
| Dynamic per-model control schema | â
capabilities.controls/.extra on GET /v1/models |
Legend: â works now · ð¡ works for some providers · â needs backend/provider work.
14. Gaps & resolutions
Resolved in this pass (2026-07-20)
extrapassthrough (structural unlock).POST /v1/ai/image,/video,/audio,/textnow accept anextraobject, merged verbatim into the upstream provider call. This makes every provider-specific control that shares the same upstream endpoint reachable from the storefront without a per-feature backend change â Kling camera/motion/end-frame/cfg/negative-prompt, MiniMax resolution, etc.extrais also persisted intoGeneration.params, so "re-run" preserves advanced settings. Code:src/modules/gateway/routes.ts,gateway.ts.
Resolved 2026-07-20 (this pass)
- Gap A â per-model capability schema â
.
GET /v1/modelsnow returnscapabilities = { flags?, controls, extra }(built-in default per type/provider, DB-overridable, deep-merged). The studio renders its control panel dynamically instead of hardcoding. Code:src/modules/models/capabilities.ts,models/routes.ts. - Gap B â first-class video controls â
.
negativePrompt,seed,endImageUrlare now typedVideoBody/VideoGenerateInputfields, provider-portable (mapped in the Kling adapter; ignored where unsupported). Code:gateway/routes.ts,adapters/providers/types.ts,kling.ts. - Gap C (partial) â lip sync + video extend â
. New async routes
POST /v1/ai/video/lip-syncand/extend, new optional adapter methods (startLipSyncJob/startVideoExtendJob), Kling implementations, and poll-routing byJob.operation. All reuse the existing job/credit/refund/worker path via a sharedstartVideoLikeJobhelper. Marked// VERIFYâ request/query shapes must be checked against the live Kling spec before enabling in production. - Gap D â image inpaint/mask â
.
maskUrladded toImageBody/ImageGenerateInput; Stability adapter branches to its inpaint endpoint wheninitImageUrl+maskUrlare present.// VERIFYagainst Stability's current v2beta spec.
Still open
- Gap C (remainder) â virtual try-on & multi-element / multi-reference. No backing model/provider is wired. Each needs a new adapter method +
/v1/ai/*route once a provider/model is chosen. The extension points exist on theProviderAdaptercontract for lip-sync/extend and can be followed for these. - Provider
// VERIFYitems. The Kling lip-sync/extend and Stability inpaint calls are implemented to the documented API shapes but not yet exercised against live credentials â verify field names, endpoints, and response parsing before production rollout. extracapability coverage.capabilities.extrais populated for Kling and MiniMax video; extend the per-provider maps incapabilities.tsas more providers gain controls.
15. Suggested build order
- Model picker (
/v1/models/me) + text-to-image (/v1/ai/image) â fastest visible win, fully sync. - Uploader (
/v1/files/init) + image-to-image and image enhance. - Prompt enhance button.
- Text-to-video + job tray (
/v1/ai/video+/v1/jobs/:id/stream) â get the async UX right early. - Image-to-video (seed frame) + advanced controls via
extra(start with Kling: negative prompt, cfg, mode, end frame, camera). - Assets/library (list, re-run from
params, favorite, publish). - Credits/quota surfacing everywhere (disable buttons on empty buckets; handle 402/403/429/503).
- Land Gap A (capability schema) to make the control panel dynamic instead of hardcoded.
Async jobs & live status
Video generation (and heavy image work) is asynchronous: submit â job â poll or stream.
Lifecycle
QUEUED â RUNNING â SUCCEEDED
â FAILED (credits auto-refunded)
â CANCELEDEndpoints
| Method | Path | Notes |
|---|---|---|
| GET | /v1/jobs | Caller's jobs, filterable by status |
| GET | /v1/jobs/:id | Snapshot: status, progress 0-100, output when done |
| GET | /v1/jobs/:id/stream | SSE â live status pushes |
| POST | /v1/jobs/:id/cancel | Best-effort upstream cancel |
SSE stream
The endpoint authenticates from the Authorization: Bearer header (same as every other call). The browser EventSource API cannot be used â it cannot set headers, and the rt cookie is a refresh token, not an access token. Consume the stream with fetch() + a reader instead:
ts
const res = await fetch(`/v1/jobs/${jobId}/stream`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Frames are separated by a blank line; each carries a `data: <json>` line.
for (const frame of buf.split('\n\n')) {
const line = frame.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const job = JSON.parse(line.slice(5).trim()); // { id, status, progress, output? }
if (job.status === 'SUCCEEDED') { render(job.output.videos[0].url); return; }
if (job.status === 'FAILED') { showError(job.errorMessage); return; }
}
buf = buf.slice(buf.lastIndexOf('\n\n') + 2);
}The stream sends a snapshot immediately on connect, then an event on every state change. Progress updates arrive roughly every 5 s while the provider renders.
Failure & refunds
When a job fails, the platform refunds automatically:
- Video credits reserved at submit are returned to the subscription.
- The provider-account budget reservation is released.
Clients only need to show errorMessage â no cleanup calls required.
Files & media
User uploads (avatars, video seed images, attachments) use a two-step flow; generated media is persisted automatically.
Upload flow
bash
# 1. Initialize â declares kind, MIME, size; returns the PUT target
curl -X POST /v1/files/init -H "Authorization: Bearer $T" \
-H 'Content-Type: application/json' \
-d '{"kind":"SEED_IMAGE","mimeType":"image/png","sizeBytes":812345}'
# â { id, uploadUrl, method: "PUT", storageKey, publicUrl }
# 2. PUT the RAW bytes (not multipart/form-data) with Content-Type = the init MIME
curl -X PUT "$uploadUrl" -H "Authorization: Bearer $T" \
-H 'Content-Type: image/png' --data-binary @seed.png
# â { ok: true, publicUrl, sizeBytes }Step 2 sends the file as the raw request body â not a multipart form. Set Content-Type to the MIME type you declared in step 1 (image/png, image/jpeg, application/octet-stream, â¦). Size limits: avatar 2 MB · seed image 10 MB · attachment 25 MB.
Serving
GET /v1/files/raw/<storageKey> serves any stored asset with HTTP Range support â required for <video> scrubbing. Returns 206 Partial Content for range requests; cacheable (immutable, 1-year max-age).
In production the platform stores files in MinIO (S3-compatible). URLs returned by the API are either:
- CDN URLs â when the operator sets
STORAGE_PUBLIC_URL - Presigned URLs (5-min TTL) â refetch from the API rather than caching these long-term
Generated media
Provider CDN URLs expire in hours. The platform downloads every generated image/video on completion and rewrites the generation output to a stable platform URL â clients never need to handle provider URL expiry.
Library
A user's persistent record of past AI generations, plus collections (folders) and saved prompts. Every endpoint requires Authorization: Bearer <accessToken> and is scoped to the caller â you only ever see your own rows.
Endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /v1/library/generations | Cursor-paginated list of past generations |
| GET | /v1/library/generations/:id | Fetch one generation |
| PATCH | /v1/library/generations/:id | Update title / favorite / visibility / tags |
| DELETE | /v1/library/generations/:id | Permanently delete a generation |
| GET | /v1/library/collections | List collections (with item counts) |
| POST | /v1/library/collections | Create a collection |
| PATCH | /v1/library/collections/:id | Rename / edit a collection |
| DELETE | /v1/library/collections/:id | Delete a collection (its items are not deleted) |
| GET | /v1/library/collections/:id/items | List items in a collection |
| POST | /v1/library/collections/:id/items | Add a generation to a collection |
| DELETE | /v1/library/collections/:id/items/:itemId | Remove an item from a collection |
| GET | /v1/library/prompts | List saved prompts (+ system templates) |
| POST | /v1/library/prompts | Save a prompt template |
| PATCH | /v1/library/prompts/:id | Edit a saved prompt |
| DELETE | /v1/library/prompts/:id | Delete a saved prompt |
| POST | /v1/library/prompts/:id/use | Bump the prompt's usage counter |
Generations
List
Cursor-paginated. Query params: kind (TEXT|IMAGE|VIDEO|AUDIO), favoritesOnly (boolean, default false), search (matches title or prompt, case-insensitive), tag (exact tag), limit (1â100, default 24), cursor.
Text is history, not a file. The default listing (no
kind) is the user's media gallery â it returns onlyIMAGE/VIDEO/AUDIOand excludesTEXT, so text generations don't show up as file cards. Text is still stored as data: request it explicitly with?kind=TEXTto build a flat history view (each row'soutputis{ "text": "â¦" }), or use Chat for full session-based ChatGPT/Claude-style threads. Prompt-enhancement results (/v1/ai/enhance/prompt) are a utility and are not stored at all.
The favourites filter is
favoritesOnlyâ notfavorite. The query schema is closed: any unknown or misspelled param returns 400, so a typo fails loudly instead of silently returning unfiltered data.
bash
curl "http://localhost:4040/v1/library/generations?kind=IMAGE&favoritesOnly=true&limit=2" \
-H "Authorization: Bearer $TOK"
# â {
# "items": [ { "id": "gen_9", "kind": "IMAGE", "title": "Sunset",
# "prompt": "...", "output": { ... }, "thumbnailUrl": "...",
# "visibility": "PRIVATE", "isFavorite": true, "tags": ["art"],
# "createdAt": "2026-07-16T10:00:00Z",
# "model": { "slug": "...", "displayName": "...", "type": "IMAGE" } } ],
# "nextCursor": "gen_9"
# }nextCursor is the id of the last returned row. Pass it back as ?cursor=<nextCursor> to fetch the next page; when it is null you have reached the end.
Get / update / delete
bash
curl http://localhost:4040/v1/library/generations/gen_9 -H "Authorization: Bearer $TOK"
# â { "id": "gen_9", ... } # 404 if not yours
# Update â all fields optional: title (â¤120), isFavorite,
# visibility (PRIVATE|UNLISTED|PUBLIC), tags (â¤20 tags, each â¤40 chars)
curl -X PATCH http://localhost:4040/v1/library/generations/gen_9 \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"title":"Sunset v2","isFavorite":true,"visibility":"PUBLIC","tags":["art","hero"]}'
# â { "id": "gen_9", "title": "Sunset v2", ... }
curl -X DELETE http://localhost:4040/v1/library/generations/gen_9 -H "Authorization: Bearer $TOK"
# â { "ok": true }Collections
Body: name (required, 1â80), description (â¤500), coverUrl (URL). PATCH accepts any subset of these.
bash
curl -X POST http://localhost:4040/v1/library/collections \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"name":"Best of 2026","description":"Favourites"}'
# â { "id": "col_1", "name": "Best of 2026", ... }
curl "http://localhost:4040/v1/library/collections" -H "Authorization: Bearer $TOK"
# â { "items": [ { "id": "col_1", "name": "...", "_count": { "items": 3 } } ] }
# Add / list / remove items
curl -X POST http://localhost:4040/v1/library/collections/col_1/items \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"generationId":"gen_9"}'
# â { "id": "item_5", "collectionId": "col_1", "generationId": "gen_9" } (idempotent upsert)
curl http://localhost:4040/v1/library/collections/col_1/items -H "Authorization: Bearer $TOK"
# â [ { "id": "item_5", "generation": { ... } } ]
curl -X DELETE http://localhost:4040/v1/library/collections/col_1/items/item_5 -H "Authorization: Bearer $TOK"
# â { "ok": true }Saved prompts
GET /prompts query: kind (TEXT|IMAGE|VIDEO|AUDIO), includeTemplates (boolean, default true â includes shared system templates alongside your own). Returns an array, newest-updated first.
Create body: kind (required), title (required, 1â120), body (required, 1â8000), variables (array of { name, label, type: string|number|select, options? }, default []), tags (default []). PATCH accepts any subset.
bash
curl -X POST http://localhost:4040/v1/library/prompts \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"kind":"IMAGE","title":"Product shot","body":"A {{style}} photo of {{item}}",
"variables":[{"name":"style","label":"Style","type":"select","options":["studio","outdoor"]}],
"tags":["marketing"]}'
# â { "id": "pr_1", "title": "Product shot", "usageCount": 0, ... }
curl -X POST http://localhost:4040/v1/library/prompts/pr_1/use -H "Authorization: Bearer $TOK"
# â { "ok": true } # increments usageCount for popularity sortingCommunity Storefront Implementation Guide
Audience: storefront (customer-facing web) developers building a Kling-style community â a public gallery of AI-generated videos/images with likes, comments, follows, profiles, and a publish flow.
TL;DR: The backend is ~90% built. This is a wiring job, not a greenfield build. This guide maps every Kling.ai community surface to an existing Magic.ai endpoint, gives exact request/response shapes, and calls out the handful of real gaps (with resolutions) at the end.
- Base URL (prod):
https://api-momagic.ai.hullor.io· (dev):http://localhost:4040 - Live schema explorer:
/docs(Swagger UI) · human docs:/reference - Auth:
Authorization: Bearer <JWT>. Public feeds work anonymously; interactions require a token. - All list endpoints are cursor-paginated:
{ items, nextCursor }. Pass?cursor=<nextCursor>for the next page;nextCursor: nullmeans end. - Errors are uniform:
{ "error": { "code", "message", "details" } }. Unknown query params â400.
0. The two content surfaces (read this first)
There are two public content APIs. Pick per surface â do not mix them on the same grid.
| Surface | Model | Shape | Use for |
|---|---|---|---|
Works /v1/community/works | CommunityWork | Kling envelope ({ type, userWork:{â¦} }), verbatim | The main Kling-style gallery grid â imported (Kling) + native works in one feed |
Posts /v1/community/* | Post + Generation | Native social shape ({ id, author, generation, likeCount⦠}) | Social layer: feeds, comments, follows, profiles, publish |
Why two: "Works" was built to render imported Kling exports with zero transformation. "Posts" is the native social graph (follow/comment/trending). As of the gap-resolution pass (2026-07-20), publishing a native generation now also mirrors it into /works as a source=NATIVE work â so the gallery grid shows imported + native content in one feed. The two still track separate like counters (a native item is both a Post and a CommunityWork); pick the surface per page and don't double-count.
Recommended storefront model:
- Render the landing gallery from
/works(matches Kling's grid 1:1, needs no transform â now includes native creations). - Render social pages (profile, comments, following feed, publish) from
/v1/community/*Posts. - On a native work's detail,
_magic.source === "NATIVE"and the inneruserProfile.handlelinks to the creator's/u/:handleprofile.
1. Page-by-page map to Kling.ai
1.1 Community landing â the gallery grid
Kling's /community masonry of autoplaying video cards with a content-type filter and Latest/Popular sort.
GET /v1/community/works â anonymous OK.
Query: cursor · limit (1â50, def 20) · contentType=video|image|audio · source=KLING|NATIVE · sort=recent|top
json
{
"items": [
{
"type": "WORK",
"userWork": {
"workId": 314598490610002,
"contentType": "video",
"resource": { "resource": "https://â¦/output.mp4", "width": 736, "height": 1232, "duration": 16433 },
"cover": { "resource": "https://â¦/cover.jpg" },
"starNum": 96,
"favored": false,
"starred": false,
"viewCountLocal": 2,
"userProfile": { "userId": 123, "userName": "Dolldance", "userAvatar": ["https://â¦/a.jpg"] },
"_magic": { "id": "cmr0â¦", "source": "KLING", "externalId": "3145â¦", "localLikeCount": 0, "localViewCount": 2, "sourceLikeCount": 96 }
}
}
],
"nextCursor": "cmr0â¦"
}Rendering rules:
- Envelope key varies by type:
userWork(WORK) ·userSkit(SKIT) ·userTemplateCreative(TEMPLATE_CREATIVE). Read whichever key is present (typetells you, or just probe the three). - Media:
resource.resource= full video/image URL.cover.resource= poster/thumb. Usewidth/heightfor aspect-ratio masonry cells (avoid layout shift).durationis ms. - Likes:
starNumis the single rolled-up number (imported + local likes).favored/starred= viewer's like state (falsewhen anonymous). Send the Bearer token on the GET so these come back correct â no second call needed. - Card identity: use
_magic.idfor all interaction calls below (notworkId). - Autoplay: lazy-load video, autoplay muted on hover/in-viewport, poster =
cover.resource.
Filter tabs â contentType. Sort tabs â sort=recent (Latest) / sort=top (Popular).
1.2 Work detail (lightbox / dedicated page)
GET /v1/community/works/:id â anonymous OK. :id accepts our _magic.id or the source externalId.
Returns a single Kling envelope (same shape as one items[] entry). Bumps nothing â call the view endpoint separately.
- Count a view:
POST /v1/community/works/:id/view(no auth). Fire once when the detail opens or the card first enters the viewport. Returns{ ok, viewCount }. Not deduped per IP â best-effort. - Prompt / params: the imported
rawpayload carries whatever the source included (Kling nests task info under the inner object). For native works we control the envelope â see Gap 1.
1.3 Like / unlike
Auth required.
| Method | Path | Returns |
|---|---|---|
POST | /v1/community/works/:id/like | { ok: true, starNum, favored: true } |
DELETE | /v1/community/works/:id/like | { ok: true, starNum, favored: false } |
Both idempotent (double-tap safe). Optimistically toggle the heart, then reconcile starNum from the response.
1.4 User profile page (/u/:handle)
Kling's creator page: avatar, name, follower/following/works counts, follow button, their works grid.
GET /v1/community/users/:handle â anonymous OK.
json
{
"id": "usr_â¦", "name": "Dolldance", "handle": "dolldance",
"avatarUrl": "https://â¦", "bio": "AI filmmaker", "createdAt": "2026-01-â¦",
"_count": { "posts": 42, "followers": 1180, "following": 7 },
"followingThem": false
}Their content grid: GET /v1/community/users/:handle/posts (cursor-paginated Post items â see 1.6 for the Post shape).
- Follow:
POST /v1/community/users/:handle/followâ{ ok: true }(idempotent). - Unfollow:
DELETE /v1/community/users/:handle/follow. followingThemon the profile tells you the initial button state (needs Bearer token).
1.5 "My community" â following feed
GET /v1/community/feed/following â auth required. Cursor-paginated Posts from accounts the caller follows. Empty array if the user follows nobody.
1.6 Social feeds (Post shape)
GET /v1/community/feed/recent (anon OK) · GET /v1/community/feed/trending (anon OK) · GET /v1/community/feed/following (auth).
Query: limit (1â50, def 20) · cursor.
json
{
"items": [
{
"id": "post_â¦", "caption": "midnight city", "tags": ["cyberpunk","city"],
"likeCount": 12, "commentCount": 3, "viewCount": 88, "trendingScore": 4.2,
"publishedAt": "2026-07-19Tâ¦",
"author": { "id": "usr_â¦", "name": "Neo", "handle": "neo", "avatarUrl": "https://â¦" },
"generation": {
"id": "gen_â¦", "kind": "VIDEO", "prompt": "â¦", "thumbnailUrl": "https://â¦",
"output": { "videos": [{ "url": "https://â¦/v.mp4", "durationSeconds": 6 }] },
"model": { "slug": "â¦", "displayName": "â¦", "type": "VIDEO" }
},
"likedByMe": false
}
],
"nextCursor": "post_â¦"
}generation.outputshape depends onkind:TEXT â { text },IMAGE â { images:[{url}] },VIDEO â { videos:[{url,durationSeconds}] },AUDIO â { audio:{url} }.likedByMereflects the viewer (Bearer token);falseanonymous.- Trending score recomputed by cron every ~15m:
(likes*3 + comments*4 + views*0.5) / hours^1.5.
1.7 Post detail + comments
GET /v1/community/posts/:idâ bumpsviewCount, returns the Post +likedByMe.- Like/unlike:
POST/DELETE /v1/community/posts/:id/likeâ{ ok: true }(idempotent). - Comments (one-level threads):
GET /v1/community/posts/:id/commentsâ{ items: [{ id, body, author:{name,handle,avatarUrl}, replies:[â¦], createdAt }] }(up to 100, oldest first).POST /v1/community/posts/:id/commentsâ body{ "body": "â¦", "parentId"?: "â¦" }. Notifies the post author.parentIdset = reply.DELETE /v1/community/comments/:idâ soft-delete your own.
1.8 Publish flow (from Library â Community)
The creator's "Publish to community" action on one of their generations.
POST /v1/community/posts â auth required.
json
// request
{ "generationId": "gen_â¦", "caption": "optional â¤2000 chars", "tags": ["â¤10 tags","â¤40 chars each"] }Server auto-flips the generation to visibility: PUBLIC so a direct link is viewable, and mirrors the generation into the /works gallery as a NATIVE work (keyed on the generation id â re-publishing re-surfaces the same row, never duplicates). TEXT and not-yet-rendered videos are skipped for the gallery mirror. Returns the created Post.
Unpublish: DELETE /v1/community/posts/:id (soft-hide, author only) â also hides the mirrored gallery work.
Publish picker source: list the user's own generations from the Library: GET /v1/library/generations â auth. Query: kind? · favoritesOnly · search · tag · limit(â¤100, def 24) · cursor. Returns { items, nextCursor }; TEXT excluded by default.
1.9 Report
POST /v1/community/reports â auth. Body { "targetType": "POST|COMMENT|USER", "targetId": "â¦", "reason": "â¤120", "details"?: "â¤2000" }. Routed to admin moderation.
2. Auth & session notes
- Optional-auth GETs: feeds, works, profile all decode a Bearer token if present (to fill
likedByMe/favored/followingThem) but never require it. Always send the token when the user is logged in. - Handle required to publish/appear on a profile. A user with no
handlehas no public profile URL. Prompt them to set one before first publish:PATCH /v1/account/profileâ{ "handle": "3-30 chars [a-zA-Z0-9_]", "avatarUrl"?, "bio"? }.409on handle collision.
- Community-readiness check:
GET /v1/users/menow returnshandle,avatarUrl,bioon theuserobject â readhandle == nullto decide whether to run handle onboarding before the publish action. - Avatar upload: 2-step â
POST /v1/files/initthenPUTthe returned URL (see/referenceâ Files & media), then setavatarUrl.
3. Media & performance
- URLs in payloads are stable, re-hosted to our storage (provider CDN bytes are copied on generation; credential query-params stripped).
- If
STORAGE_PUBLIC_URLis configured, URLs are CDN, non-expiring, cacheable. Otherwise they are same-origin/v1/files/raw/<key>with HTTP Range/206 support â native<video>scrubbing works out of the box. Either way: no signed-URL refresh dance needed for public content. - Masonry: use
resource.width/height(works) orgeneration.outputdims to reserve cell size and kill layout shift. - Videos:
preload="none", poster fromcover.resource, autoplay muted only when in viewport; pause off-screen. - View counting is fire-and-forget â do not block UI on it; debounce to once per work per session.
4. Reference: endpoint index
| Surface | Method · Path | Auth |
|---|---|---|
| Gallery grid | GET /v1/community/works | optional |
| Work detail | GET /v1/community/works/:id | optional |
| Work like / unlike | POST · DELETE /v1/community/works/:id/like | required |
| Work view | POST /v1/community/works/:id/view | none |
| Recent feed | GET /v1/community/feed/recent | optional |
| Trending feed | GET /v1/community/feed/trending | optional |
| Following feed | GET /v1/community/feed/following | required |
| Publish post | POST /v1/community/posts | required |
| Post detail | GET /v1/community/posts/:id | optional |
| Unpublish post | DELETE /v1/community/posts/:id | required |
| Post like / unlike | POST · DELETE /v1/community/posts/:id/like | required |
| Comments list / add / delete | GET · POST /v1/community/posts/:id/comments · DELETE /v1/community/comments/:id | mixed |
| Public profile | GET /v1/community/users/:handle | optional |
| User's posts | GET /v1/community/users/:handle/posts | optional |
| Follow / unfollow | POST · DELETE /v1/community/users/:handle/follow | required |
| Report | POST /v1/community/reports | required |
| Publish picker (own generations) | GET /v1/library/generations | required |
| Set handle/avatar/bio | PATCH /v1/account/profile | required |
5. Gaps & resolutions
Status as of 2026-07-20. Gaps 1â4 were resolved in the same pass that produced this guide; Gap 5 remains a follow-up.
Gap 1: Unify the gallery grid â â RESOLVED
Was. Publishing a native generation created a Post but no CommunityWork, so native creations never appeared in the /works gallery.
Now. POST /v1/community/posts upserts a CommunityWork (source: NATIVE, keyed on the generation id) with a synthesised Kling envelope built from generation.output. The single /works feed renders imported + native uniformly â build one grid. Unpublish hides the mirror. Code: community/works-service.ts (buildNativeWorkRaw), community/routes.ts (publish/unpublish).
Gap 2: defaultGenerationVisibility â â
RESOLVED
Now. New media generations (image/video/audio) read the user's UserPreference.defaultGenerationVisibility and are created PUBLIC/UNLISTED/PRIVATE accordingly (TEXT stays private history). The account toggle takes effect. Code: library/service.ts (resolveDefaultVisibility).
Gap 3: GET /v1/users/me community fields â â
RESOLVED
Now. /v1/users/me returns handle, avatarUrl, bio on user. Use handle == null for the community-readiness check. Code: users/routes.ts.
Gap 4: like/follow notifications â â RESOLVED (needs migration deploy)
Now. NotificationType gained POST_LIKED + NEW_FOLLOWER; a first-time post-like notifies the author and a new follow notifies the followee. Requires the DB migration 20260720000000_add_community_notification_types to be applied (prisma migrate deploy) before deploy. Code: notifications/service.ts, community/routes.ts.
Gap 5: No tag/hashtag browse endpoint â ⬠OPEN
Problem. Tags are stored on Posts and Works but there's no "browse by tag" route, so Kling-style hashtag pages aren't queryable server-side. Storefront can filter within a loaded feed short-term; the proper fix is a ?tag= filter on /works when hashtag pages are prioritized.
Non-gaps (already covered â don't rebuild)
Likes, comments (threaded), follows, public profiles, recent/trending/following feeds, view counting, reports, cursor pagination, admin moderation + analytics, media re-hosting with Range support, optional-auth like-state â all present.
6. Suggested build order
- Gallery grid from
/works(landing) â highest visible parity, zero transform. - Work detail + like + view lightbox.
- Profiles + follow (
/users/:handle, follow buttons) â requires handle onboarding (PATCH /account/profile). - Publish flow from Library picker (
/library/generationsâPOST /posts). - Comments + following feed.
Note: step 4's published creations already appear in step 1's grid (Gap 1 resolved) â no extra client merge needed.
Community feed
A unified gallery of short videos/images: content imported from external platforms (Kling) plus native user creations. The API returns the original source envelope verbatim â clients already rendering Kling responses need zero transformation.
Feed â GET /v1/community/works
Query params: cursor · limit (1-50, default 20) · contentType=video|image|audio · source=KLING|NATIVE · sort=recent|top
json
{
"items": [
{
"type": "WORK",
"userWork": {
"workId": 314598490610002,
"contentType": "video",
"resource": { "resource": "https://â¦/output.mp4", "width": 736, "height": 1232, "duration": 16433 },
"cover": { "resource": "https://â¦/cover.jpg" },
"starNum": 96,
"favored": false,
"starred": false,
"viewCountLocal": 2,
"userProfile": { "userName": "Dolldance", "userAvatar": ["â¦"] },
"_magic": { "id": "cmr0â¦", "source": "KLING", "externalId": "3145â¦", "localLikeCount": 0, "localViewCount": 2 }
}
}
],
"nextCursor": "cmr0â¦"
}Notes:
- The wrapper key varies by entry type:
userWork(WORK) ·userSkit(SKIT) ·userTemplateCreative(TEMPLATE_CREATIVE). Read whichever is present. starNum= imported source likes + local platform likes â one rolled-up number.favored/starredreflect the current viewer's like state when the request carries a Bearer token; alwaysfalseanonymous._magic.idis the platform id â use it for the endpoints below.
Interactions
| Method | Path | Auth | Effect |
|---|---|---|---|
| POST | /v1/community/works/:id/like | required | Idempotent like â { ok, starNum, favored: true } |
| DELETE | /v1/community/works/:id/like | required | Unlike |
| POST | /v1/community/works/:id/view | none | Count a view â call once when a card enters the viewport |
| GET | /v1/community/works/:id | optional | Single work; :id accepts platform id or source externalId |
Native posts (user-generated)
The classic social surface â separate API family under /v1/community: feed/recent · feed/trending · feed/following · posts (+ like/comment) · u/:handle public profiles · report. See the live explorer for full schemas.
Billing & payments
Plans, subscriptions, add-on credits, coupons, and hosted checkout across six gateways. Every protected endpoint takes Authorization: Bearer <accessToken>.
Money is in minor units via priceCents + currency. For BDT that is poisha (1/100 BDT), so priceCents: 29900 = 299.00 BDT. Never hardcode prices â read them from GET /v1/plans and GET /v1/subscriptions/credits/packs.
Plans
Public (no auth). Lists customer-purchasable packages; the internal playground plan is excluded.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/plans | Active plans, sorted by sortOrder then priceCents |
| GET | /v1/plans/:slug | One plan by slug (null if not found) |
bash
curl http://localhost:4040/v1/plans
# â { "items": [ {
# "id":"pl_...", "slug":"pro", "name":"Pro",
# "description":"...", "interval":"MONTHLY",
# "priceCents":29900, "currency":"BDT",
# "limits":{...}, "features":{...}, "allowedModelIds":["openai/gpt-4o", ...]
# } ] }interval is one of DAILY | WEEKLY | MONTHLY | YEARLY | ENTERPRISE.
Subscriptions
All require auth. planId in bodies is a plan id (from GET /v1/plans), not a slug.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/subscriptions | Caller's subscriptions, newest first |
| POST | /v1/subscriptions/:id/cancel | Access continues until endsAt |
| POST | /v1/subscriptions/:id/upgrade | To a higher plan, effective now, prorated as credits |
| POST | /v1/subscriptions/:id/downgrade | Scheduled for next renewal |
| POST | /v1/subscriptions/trial | One free trial per user; plan must have trialDays > 0. Starts TRIALING and grants the plan's allowance to the wallet â the trial user can generate immediately. A TRIALING subscription is treated as active everywhere. |
bash
curl http://localhost:4040/v1/subscriptions -H "Authorization: Bearer $TOK"
# â [ {
# "id":"sub_...", "status":"ACTIVE",
# "startsAt":"...", "endsAt":"...", "autoRenew":true, "nextPlanId":null,
# "usage":{"tokensUsed":0,"storageMbUsed":0,"imageCreditsUsed":0,
# "videoCreditsUsed":0,"dailyRequestsUsed":0,"monthlyRequestsUsed":0},
# "plan":{"slug":"pro","name":"Pro","interval":"MONTHLY","priceCents":29900,"currency":"BDT"}
# } ]
curl -X POST http://localhost:4040/v1/subscriptions/sub_123/upgrade \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"planId":"pl_business"}'status is one of PENDING | TRIALING | ACTIVE | PAST_DUE | GRACE | CANCELED | EXPIRED.
Credits & coupons
Add-on credits are a per-bucket wallet (textRequests, tokens, imageCredits, videoCredits) that is separate from the plan's per-cycle quota. The gateway debits plan quota first, then falls back to this balance.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/subscriptions/credits/packs | One-time credit top-ups (public) |
| GET | /v1/subscriptions/credits/balance | Current add-on balance |
| GET | /v1/subscriptions/credits/transactions | Last 50 ledger entries, newest first |
| POST | /v1/subscriptions/coupons/preview | Compute a discount without redeeming it |
bash
curl http://localhost:4040/v1/subscriptions/credits/packs
# â [ { "id":"pk_...", "slug":"boost-500", "name":"Boost 500",
# "priceCents":9900, "currency":"BDT",
# "grants":{"textRequests":500,"tokens":0,"imageCredits":100,"videoCredits":5},
# "active":true, "sortOrder":0 } ]
curl http://localhost:4040/v1/subscriptions/credits/balance -H "Authorization: Bearer $TOK"
# â { "textRequests":120, "tokens":0, "imageCredits":40, "videoCredits":2 }
curl -X POST http://localhost:4040/v1/subscriptions/coupons/preview \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"code":"LAUNCH20","planId":"pl_pro"}'
# â { "couponId":"cp_...", "code":"LAUNCH20", "discountCents":5980, "finalCents":23920 }Ledger entries carry a kind of PURCHASE | GRANT | REFUND | PROMO | ADJUSTMENT, signed per-bucket amounts, an optional note/paymentId, and createdAt.
Checkout
To buy a subscription or a credit pack, start a checkout, follow the returned redirectUrl to the hosted gateway, and let the webhook activate the purchase.
| Method | Path | Notes |
|---|---|---|
| POST | /v1/payments/checkout | Returns { paymentId, redirectUrl } |
Body (CheckoutBody): supply one of planId (subscription) or packId (credit pack), plus provider, successUrl, cancelUrl, and an optional extra bag for gateway-specific fields.
bash
curl -X POST http://localhost:4040/v1/payments/checkout \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{
"planId":"pl_pro",
"provider":"sslcommerz",
"successUrl":"https://your-app.example.com/billing/done",
"cancelUrl":"https://your-app.example.com/billing/cancel"
}'
# â { "paymentId":"pay_...", "redirectUrl":"https://securepay.sslcommerz.com/..." }Flow: POST /checkout creates a PENDING payment and asks the chosen adapter for a hosted URL â user completes payment on the gateway â the gateway fires a webhook â on SUCCEEDED the subscription activates (or the pack tops up the wallet). There is no synchronous confirmation â after the redirect, poll GET /v1/users/me (or subscribe to the payment.succeeded platform webhook) to detect activation.
Payment providers
provider is one of stripe | paypal | sslcommerz | bkash | nagad | dcb. Each adapter returns a hosted redirectUrl; you never handle card/wallet data.
| Provider | Region | extra required | Gateway webhook |
|---|---|---|---|
stripe | Global | â | POST (HMAC signature) |
paypal | Global | â | POST (signature verify) |
sslcommerz | BDT | â | POST IPN (val_id re-validation) |
bkash | BDT | â | POST (status re-query) |
nagad | BDT | â | POST (RSA-signed) |
dcb | BDT only | paymentProvider + msisdn | GET, unsigned |
DCB (carrier billing via Hullor) needs two extra fields:
extra.paymentProviderâ the carrier:GP | BL | ROBI | ROBI_WAP | BKASH.extra.msisdnâ subscriber phone, digits only, local format (10â15 digits, no country code).
bash
curl -X POST http://localhost:4040/v1/payments/checkout \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{
"planId":"pl_pro", "provider":"dcb",
"successUrl":"https://your-app.example.com/billing/done",
"cancelUrl":"https://your-app.example.com/billing/cancel",
"extra":{ "paymentProvider":"GP", "msisdn":"1712345678" }
}'
# â { "paymentId":"pay_...", "redirectUrl":"https://.../confirm-with-carrier" }Gateway webhooks (server-to-server)
These are called by the gateways, not by client apps. The adapter verifies each one and, on success, activates the purchase.
| Method | Path | Used by |
|---|---|---|
| POST | /v1/payments/webhook/:provider | Stripe, PayPal, SSLCommerz, bKash, Nagad |
| GET | /v1/payments/webhook/:provider | DCB carrier callbacks (payload in query string) |
DCB webhooks are unsigned GETs, verified by matching the carrier's order_tracking_id to the in-flight payment; restrict that URL to Hullor's IP allowlist at the proxy in production. The other providers use signed POST bodies.
Usage API
Per-request usage records and aggregated consumption counters for the authenticated user.
Endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /v1/usage | Per-request usage feed, cursor-paginated |
| GET | /v1/usage/summary | Usage counters grouped by operation and model over a window |
Every call requires Authorization: Bearer <accessToken>. Records are always scoped to the caller.
GET /v1/usage
Cursor-paginated feed of individual gateway requests, newest first.
Query: limit (1-100, default 20), cursor, and optional filters operation and status (SUCCESS | FAILED | PARTIAL | TIMEOUT).
bash
curl "http://localhost:4040/v1/usage?limit=2&status=SUCCESS" \
-H "Authorization: Bearer $TOK"
# â {
# "items": [
# {
# "id": "clr_9f2...",
# "userId": "usr_1a...",
# "subscriptionId": "sub_7c...",
# "modelId": "mdl_44...",
# "providerId": "prv_gemini",
# "resellerAccountId": null,
# "jobId": null,
# "requestId": "req_0b8...", // gateway idempotency id
# "operation": "text.generate",
# "status": "SUCCESS",
# "inputUnits": 812, // tokens / images / video-seconds
# "outputUnits": 344,
# "creditsConsumed": 6, // debited from plan quota
# "providerCostCents": 3,
# "latencyMs": 1420,
# "metadata": { "promptHash": "..." },
# "createdAt": "2026-07-16T10:22:05.000Z",
# "model": { "slug": "gemini/gemini-2.5-flash", "displayName": "Gemini 2.5 Flash", "type": "TEXT" }
# }
# ],
# "nextCursor": "clr_9f2..." // pass as ?cursor= for the next page; null = end
# }nextCursor is the id of the last returned row. When it is null you have reached the end of the feed.
GET /v1/usage/summary
Aggregated counters over a rolling window. Query: window = day | week | month (default month), covering the last 1, 7, or 30 days respectively.
bash
curl "http://localhost:4040/v1/usage/summary?window=week" \
-H "Authorization: Bearer $TOK"
# â {
# "window": "week",
# "since": "2026-07-09T10:22:05.000Z",
# "byOperation": [
# {
# "operation": "text.generate",
# "status": "SUCCESS",
# "_count": { "_all": 128 },
# "_sum": { "inputUnits": 98210, "outputUnits": 41003, "creditsConsumed": 640, "latencyMs": 181240 }
# }
# ],
# "byModel": [
# { "modelId": "mdl_44...", "_count": { "_all": 128 } }
# ]
# }byOperation groups on (operation, status); sum creditsConsumed across its rows to show credits spent in the window. byModel gives a per-model request count for the same window.
Account API
Identity, profile, API keys, and security settings for the signed-in user.
- Management endpoints authenticate with the user's access token:
Authorization: Bearer <accessToken>. - Exceptions (no auth â token/OTP is the credential):
POST /v1/account/verify-email/confirm,POST /v1/account/email/confirm, and thePOST /v1/account/password/{forgot,verify-otp,reset}reset flow. - All timestamps are ISO-8601 UTC. Examples below use
TOK=<accessToken>.
Profile
| Method | Path | Notes |
|---|---|---|
| GET | /v1/users/me | Current user + active subscription summary |
| PATCH | /v1/users/me | Body { name } â updates display name only |
| PATCH | /v1/account/profile | Public fields: handle, avatarUrl, bio |
| POST | /v1/account/verify-email/send | (Re-)send verification link; { ok, alreadyVerified? } |
| POST | /v1/account/verify-email/confirm | No auth. Body { token } â { userId } |
bash
curl /v1/users/me -H "Authorization: Bearer $TOK"
# â { "user": { "id":"â¦","email":"â¦","name":"â¦","role":"USER","status":"ACTIVE","createdAt":"2026-07-16Tâ¦Z" },
# "activeSubscription": { ⦠} | null }
curl -X PATCH /v1/account/profile -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"handle":"neo_dev","avatarUrl":"https://cdn.magic.ai/a.png","bio":"builder"}'
# â public user row. handle must match ^[a-zA-Z0-9_]{3,30}$, bio ⤠500 chars. 409 if handle taken.API keys
Server-to-server credentials for the /v1/ai/* gateway. Requires a plan with apiAccess: true (else 403).
| Method | Path | Notes |
|---|---|---|
| GET | /v1/account/api-keys | List keys (secrets never returned) |
| POST | /v1/account/api-keys | Create a key â returns the raw secret once |
| DELETE | /v1/account/api-keys/:id | Revoke a key |
bash
curl -X POST /v1/account/api-keys -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"label":"prod server","scopes":["ai:text","ai:image"],"expiresAt":"2027-01-01T00:00:00Z"}'
# â { "id":"â¦","label":"prod server","keyPrefix":"a1b2","scopes":"ai:text,ai:image",
# "expiresAt":"2027-01-01T00:00:00Z","createdAt":"2026-07-16Tâ¦Z",
# "key":"sk-magic-XXXXXXXXâ¦" } â store now; never shown again- Body:
label(1â80 chars),scopes(⥠1, from the list below),expiresAt(optional date-time). - Scopes:
ai:text,ai:image,ai:video,jobs:read,jobs:write. GETreturns{ items: [{ id, label, keyPrefix, scopes, lastUsedAt, expiresAt, createdAt }] }â only the last 4 chars (keyPrefix) of each key.DELETEand account deletion revoke keys (soft;{ ok: true }).
Using a key
On the AI gateway, send the raw secret exactly like a Bearer access token â the route accepts either a JWT or an API key:
bash
curl -X POST /v1/ai/text -H "Authorization: Bearer sk-magic-XXXXXXXXâ¦" -H 'Content-Type: application/json' \
-d '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"Hi"}]}'
# 401 if the key is revoked/expired · 403 "API key missing scope ai:text" if the scope wasn't grantedEach /v1/ai/* route enforces one scope (ai:text, ai:image, ai:video, ai:audio). JWT (browser) callers have all scopes implicitly; API keys are limited to their granted scopes. Every use bumps the key's lastUsedAt.
Password
| Method | Path | Notes |
|---|---|---|
| POST | /v1/account/password/change | Auth. Body { currentPassword, newPassword } (new ⥠8) |
| POST | /v1/account/password/forgot | No auth. { email } â 4-digit OTP emailed |
| POST | /v1/account/password/verify-otp | No auth. { email, otp } â { resetToken } |
| POST | /v1/account/password/reset | No auth. { token, password } â resets, revokes all sessions |
bash
curl -X POST /v1/account/password/change -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"currentPassword":"old-pw","newPassword":"new-pw-8+"}' # â { "ok": true } · 401 on wrong current pwThe forgot/verify-otp/reset flow (OTP valid 10 min, 5 attempts, resetToken valid 15 min) is detailed in authentication.md.
Email
| Method | Path | Notes |
|---|---|---|
| POST | /v1/account/email/change | Auth. Body { newEmail } â confirm link sent to the new address |
| POST | /v1/account/email/confirm | No auth. Body { token } â swaps email, marks verified |
Change requests expire in 24 h. 409 if the address is already in use.
Sessions & security
| Method | Path | Notes |
|---|---|---|
| GET | /v1/account/sessions | Active logins, one row per device |
| DELETE | /v1/account/sessions/:id | Sign out one device |
| POST | /v1/account/sessions/revoke-others | Sign out everywhere except the caller |
| GET | /v1/account/security/events | Recent events; ?limit=1..200 (default 50) |
GET /sessions â { items: [{ id, device, userAgent, ipAddress, location, lastSeenAt, createdAt }] }. Event kind is one of: LOGIN_SUCCESS, LOGIN_FAILED, LOGIN_LOCKED, LOGOUT, PASSWORD_CHANGED, PASSWORD_RESET, EMAIL_CHANGED, TWO_FA_ENABLED, TWO_FA_DISABLED, TWO_FA_FAILED, SESSION_REVOKED, IMPERSONATED_BY_ADMIN.
Two-factor (TOTP)
| Method | Path | Notes |
|---|---|---|
| POST | /v1/account/2fa/setup | Begin enrollment â { secret, otpauthUrl } |
| POST | /v1/account/2fa/enable | Body { code } (6-digit) â enables, returns recoveryCodes once |
| POST | /v1/account/2fa/disable | Body { password } â turns 2FA off |
| GET | /v1/account/2fa/status | { enabled, since, recoveryCodesRemaining } |
| POST | /v1/account/2fa/recovery-codes/regenerate | Body { code } (6-digit) â new set once |
bash
curl -X POST /v1/account/2fa/setup -H "Authorization: Bearer $TOK"
# â { "secret":"JBSWY3DPEHâ¦", "otpauthUrl":"otpauth://totp/â¦?secret=â¦" } â render as QR
curl -X POST /v1/account/2fa/enable -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"code":"123456"}'
# â { "ok": true, "recoveryCodes": ["a1b2-c3d4", â¦] } â store now; never shown againRecovery codes are single-use. Regenerating (and, per the login flow, 2FA at sign-in) requires a fresh authenticator code.
Preferences
| Method | Path | Notes |
|---|---|---|
| GET | /v1/account/preferences | Locale, timezone, theme, visibility |
| PATCH | /v1/account/preferences | Partial update (all fields optional) |
| GET | /v1/account/notification-prefs | Email / in-app channel toggles |
| PATCH | /v1/account/notification-prefs | Partial update (all boolean, optional) |
- Preferences:
locale(2â10),timezone(2â64),themelight|dark|system,profileVisibilityPUBLIC|FOLLOWERS_ONLY|PRIVATE,allowDmFromAnyone(bool),defaultGenerationVisibilityPRIVATE|UNLISTED|PUBLIC. Defaults:en/UTC/system/PUBLIC/true/PRIVATE. - Notification prefs (all default
trueexceptemailMarketing):emailJobUpdates,emailBillingAlerts,emailSecurityAlerts,emailMarketing,inAppCommunityActivity,inAppQuotaWarnings.
Blocks
| Method | Path | Notes |
|---|---|---|
| GET | /v1/account/blocks | List blocked / muted users |
| POST | /v1/account/blocks | Body { handle, kind?, reason? } |
| DELETE | /v1/account/blocks/:handle | Unblock / unmute |
kind is BLOCK (default) or MUTE; reason ⤠200 chars. A BLOCK also removes any follow relationship in either direction. 404 if the handle is unknown; blocking yourself is 400.
Data & account deletion
| Method | Path | Notes |
|---|---|---|
| POST | /v1/account/data-export | Queue an async export â returns the request row |
| GET | /v1/account/data-export | List your last 10 export requests |
| POST | /v1/account/delete | Soft-delete the account (body { password }) |
bash
curl -X POST /v1/account/delete -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"password":"my-pw"}'
# â { "ok": true } â requires current password; revokes all refresh tokens + API keysNotifications API
The authenticated user's notification inbox: list, mark one read, mark all read.
Endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /v1/notifications | Cursor-paginated inbox, includes unreadCount |
| POST | /v1/notifications/:id/read | Mark one notification as read |
| POST | /v1/notifications/read-all | Mark every unread notification as read |
Every call requires Authorization: Bearer <accessToken>. Notifications are always scoped to the caller.
GET /v1/notifications
Newest first. Query: limit (1-100, default 30), cursor, and unreadOnly (default false) to return only unread rows.
bash
curl "http://localhost:4040/v1/notifications?unreadOnly=true&limit=2" \
-H "Authorization: Bearer $TOK"
# â {
# "items": [
# {
# "id": "ntf_5a1...",
# "type": "JOB_SUCCEEDED", // JOB_FAILED | PAYMENT_SUCCEEDED | PAYMENT_FAILED
# // | SUBSCRIPTION_EXPIRING | QUOTA_WARNING | ANNOUNCEMENT | SYSTEM
# "title": "Your video is ready",
# "body": "Tap to view the result.", // nullable
# "payload": { "jobId": "job_88..." }, // type-specific, arbitrary keys
# "readAt": null, // ISO-8601 once read, else null
# "createdAt": "2026-07-16T10:22:05.000Z"
# }
# ],
# "nextCursor": "ntf_5a1...", // pass as ?cursor= for the next page; null = end
# "unreadCount": 4 // total unread, ignores unreadOnly / paging
# }nextCursor is the id of the last returned row; null means you have reached the end. unreadCount always reflects the user's full unread total.
POST /v1/notifications/:id/read
Marks a single notification read. Idempotent â a missing, already-read, or foreign id is a no-op that still returns ok.
bash
curl -X POST http://localhost:4040/v1/notifications/ntf_5a1.../read \
-H "Authorization: Bearer $TOK"
# â { "ok": true }POST /v1/notifications/read-all
Marks every unread notification for the caller as read.
bash
curl -X POST http://localhost:4040/v1/notifications/read-all \
-H "Authorization: Bearer $TOK"
# â { "ok": true, "updated": 4 } // number of rows flipped to readOutgoing webhooks
Register HTTPS endpoints and the platform POSTs to them when events happen in the user's account.
Events
job.succeeded · job.failed · generation.persisted · payment.succeeded · payment.refunded · subscription.canceled · * (all)
Management endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /v1/webhooks/events | Published event list |
| POST | /v1/webhooks | { url, events, description? } â returns cleartext secret exactly once |
| GET | /v1/webhooks | List (secret masked whsec_â¢â¢â¢â¢1234) |
| PATCH | /v1/webhooks/:id | Update url/events/status; status=ACTIVE resets failure count |
| POST | /v1/webhooks/:id/rotate-secret | New secret, no grace period |
| GET | /v1/webhooks/:id/deliveries | Recent attempts with response codes |
| POST | /v1/webhooks/:id/deliveries/:dId/resend | Replay with original payload |
What your receiver sees
http
POST /hooks/magic HTTP/1.1
Content-Type: application/json
User-Agent: magicai-webhooks/1
X-Magic-Event: job.succeeded
X-Magic-Delivery: cmrâ¦
X-Magic-Timestamp: 1735557600
X-Magic-Signature: t=1735557600,v1=8f4aâ¦
{ "event": "job.succeeded", "createdAt": "2026-07-01T12:00:00.000Z", "data": { "jobId": "â¦" } }Verify the signature
HMAC-SHA256 over ${timestamp}.${rawBody} with your secret. Reject if the timestamp drifts > 5 min (replay protection).
js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, sigHeader, rawBody) {
const m = /t=(\d+),v1=([0-9a-f]+)/.exec(sigHeader ?? '');
if (!m) return false;
const [, ts, sig] = m;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
return expected.length === sig.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}Delivery semantics
- Retries with exponential backoff: 5 s â 30 s â 2 m â 10 m â 1 h â 6 h (6 attempts).
- After the budget is exhausted the webhook is auto-disabled â re-enable via PATCH once your receiver is fixed.
- Respond
2xxwithin 10 s. Anything else (including timeouts) counts as a failure. - Deliveries can arrive out of order and (rarely) more than once â key your processing on
X-Magic-Delivery.
Workspaces / Teams
Multiple users sharing one subscription. Roles: OWNER (exactly one, billing control) · ADMIN (manage members) · MEMBER.
Endpoints
| Method | Path | Auth rule |
|---|---|---|
| GET | /v1/workspaces | any member â lists caller's workspaces with role + memberCount |
| POST | /v1/workspaces | { name, slug? } â caller becomes OWNER |
| GET / PATCH / DELETE | /v1/workspaces/:id | member / admin+ / owner |
| GET | /v1/workspaces/:id/members | member |
| PATCH | /v1/workspaces/:id/members/:memberId | { role } â promoting to OWNER transfers ownership atomically (old owner â ADMIN) |
| DELETE | /v1/workspaces/:id/members/:memberId | admin+ removes, or self-leave. Owner must transfer first |
| POST | /v1/workspaces/:id/invitations | admin+ â { email, role? } â { token, invitation } |
| GET / DELETE | /v1/workspaces/:id/invitations[/:inviteId] | admin+ |
| POST | /v1/workspaces/invitations/accept | { token } â caller's email must match |
| POST | /v1/workspaces/invitations/decline | { token } |
Invitation flow
- Admin calls POST
/invitationsâ gets a single-use token (valid 7 days). - Your app emails the invitee a link like
https://app.example.com/invite?token=wsi_â¦. - Invitee signs in (or signs up with the same email), then your app calls
POST /v1/workspaces/invitations/accept { token }. - Membership is created atomically; expired/reused tokens return 400 with a specific message.
Shared billing
A Subscription can carry workspaceId â usage and credits then meter at the workspace level, so every member draws from one pool. Assignment of subscriptions to workspaces is currently operator-driven (contact support / your account manager).
Chat API
Multi-turn conversations that persist their message history so the assistant keeps context across turns (like a ChatGPT thread). Mounted at /v1/chat.
Endpoints
| Method | Path | Notes |
|---|---|---|
| POST | /v1/chat/conversations | Start an (empty) conversation |
| GET | /v1/chat/conversations | List the caller's threads, newest activity first |
| GET | /v1/chat/conversations/:id | Fetch a thread with its full message history |
| PATCH | /v1/chat/conversations/:id | Rename a thread |
| DELETE | /v1/chat/conversations/:id | Delete a thread and its messages |
| POST | /v1/chat/conversations/:id/messages | Send a message into an existing thread (needs ai:text) |
| POST | /v1/chat | One-shot: continue a thread, or start one if no conversationId (needs ai:text) |
Auth
Every endpoint accepts either a user session (Authorization: Bearer <accessToken>) or an API key. The two message-sending endpoints additionally require the ai:text scope on the key.
Managing conversations
bash
# Create a thread. All fields optional; `model` becomes the thread default.
curl -X POST http://localhost:4040/v1/chat/conversations \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"title":"Trip planning","model":"openai/gpt-4o-mini","system":"Be concise."}'
# â { "id":"c_123","userId":"u_1","title":"Trip planning","modelSlug":"openai/gpt-4o-mini",
# "system":"Be concise.","createdAt":"2026-07-16T10:00:00.000Z","updatedAt":"2026-07-16T10:00:00.000Z" }List is cursor-paginated with ?limit= (1-100, default 30) and ?cursor=. Pass the returned nextCursor back to fetch the next page; null means the end.
bash
curl "http://localhost:4040/v1/chat/conversations?limit=30" -H "Authorization: Bearer $TOK"
# â { "items":[ { "id":"c_123","title":"Trip planning","modelSlug":"openai/gpt-4o-mini",
# "createdAt":"...","updatedAt":"...","messageCount":4 } ], "nextCursor":"c_123" }bash
curl http://localhost:4040/v1/chat/conversations/c_123 -H "Authorization: Bearer $TOK"
# â { "conversation":{...}, "messages":[ { "id":"m_1","role":"user","content":"Hi","createdAt":"..." } ] }
curl -X PATCH http://localhost:4040/v1/chat/conversations/c_123 \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"title":"Japan trip"}'
# â { ...updated conversation... }
curl -X DELETE http://localhost:4040/v1/chat/conversations/c_123 -H "Authorization: Bearer $TOK"
# â { "ok": true }Sending a message
Body: { content, model?, stream?, temperature?, maxTokens? }. content is required. A model must be resolvable â model overrides the thread default; if neither is set the call fails with 400. The user turn and the assistant reply are both persisted, and the thread's updatedAt (and its auto-title, if unset) is bumped.
bash
curl -X POST http://localhost:4040/v1/chat/conversations/c_123/messages \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"content":"What should I pack?"}'
# â { "conversationId":"c_123",
# "message":{ "role":"assistant","content":"Pack light layers..." },
# "usage":{ "inputTokens":42,"outputTokens":88 },
# "finishReason":"stop" }POST /v1/chat is the one-shot variant. It takes the same body plus conversationId?, title?, and system?. Pass conversationId to continue an existing thread, or omit it to start a fresh one.
bash
curl -X POST http://localhost:4040/v1/chat \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"content":"Hello!","model":"openai/gpt-4o-mini","system":"Be concise."}'
# â { "conversationId":"c_456", "message":{...}, "usage":{...}, "finishReason":"stop" }Streaming (SSE)
Set stream: true to receive Server-Sent Events. The chat stream differs from /v1/ai/text: it emits a conversation frame first, which is the only place a newly created thread's id is returned (when you POST /v1/chat without a conversationId). The frame sequence is:
data: {"type":"conversation","conversationId":"c_456"}
data: {"type":"text","delta":"Pack "}
data: {"type":"text","delta":"light "}
data: {"type":"usage","usage":{"inputTokens":42,"outputTokens":88}}
data: {"type":"finish","finishReason":"stop"}
data: [DONE]On failure the stream emits data: {"type":"error","message":"..."} instead of finish/[DONE]. Consume the stream with fetch() + a reader (the browser EventSource API cannot set the Authorization header) â see Async jobs & live status for the reader loop.
Support (tickets)
Threaded support tickets â customers open and reply to their own tickets; admins triage every ticket.
All endpoints require Authorization: Bearer <accessToken>.
Customer endpoints
Mounted under /v1/support. Each requires a signed-in user and only ever touches the caller's own tickets.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/support/tickets | List the caller's tickets, newest activity first |
| POST | /v1/support/tickets | Open a new ticket â { subject, message } |
| GET | /v1/support/tickets/:id | Fetch one ticket with its full message thread |
| POST | /v1/support/tickets/:id/reply | Append a message â { message } |
A ticket looks like:
jsonc
{
"id": "clxâ¦",
"userId": "clxâ¦",
"subject": "Can't export my video",
"status": "OPEN", // OPEN | PENDING | RESOLVED | CLOSED
"messages": [
{ "authorId": "clxâ¦", "authorRole": "USER", "body": "â¦", "createdAt": "2026-07-16T10:00:00.000Z" }
],
"createdAt": "2026-07-16T10:00:00.000Z",
"updatedAt": "2026-07-16T10:00:00.000Z"
}authorRole is USER or ADMIN. New tickets start as OPEN.
bash
# List your tickets
curl http://localhost:4040/v1/support/tickets \
-H "Authorization: Bearer $TOKEN"
# â [ { "id": "clxâ¦", "subject": "â¦", "status": "OPEN", ⦠} ]
# Open a ticket (subject 3â200 chars, message 1â8000 chars)
curl -X POST http://localhost:4040/v1/support/tickets \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subject":"Can'\''t export my video","message":"The download button does nothing."}'
# â { "id": "clxâ¦", "status": "OPEN", "messages": [ { "authorRole": "USER", ⦠} ], ⦠}
# Read one ticket + its thread
curl http://localhost:4040/v1/support/tickets/clx123 \
-H "Authorization: Bearer $TOKEN"
# â { "id": "clx123", "subject": "â¦", "messages": [ ⦠] }
# Reply (rejected with 400 if the ticket is CLOSED; otherwise moves it to PENDING)
curl -X POST http://localhost:4040/v1/support/tickets/clx123/reply \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"message":"Still broken on the latest build."}'
# â { "id": "clx123", "status": "PENDING", "messages": [ â¦, { "authorRole": "USER", ⦠} ] }A missing or non-owned :id returns 404. Replying to a CLOSED ticket returns 400 "ticket is closed".
Admin endpoints
Mounted under /v1/admin (routes are shaped /support/ticketsâ¦, so they resolve to /v1/admin/support/ticketsâ¦). Every route requires an admin Bearer token and can see tickets across all users. This is where close and reopen live â customers cannot close or reopen tickets themselves.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/admin/support/tickets | List all tickets â optional ?status=OPEN|PENDING|CLOSED and ?q=<subject search> (max 200) |
| GET | /v1/admin/support/tickets/:id | Fetch any ticket, with the requester's user joined in |
| POST | /v1/admin/support/tickets/:id/reply | Reply as staff â { message }; bumps status â OPEN |
| POST | /v1/admin/support/tickets/:id/close | Close the ticket â CLOSED |
| POST | /v1/admin/support/tickets/:id/reopen | Reopen the ticket â OPEN |
bash
# Triage open tickets
curl "http://localhost:4040/v1/admin/support/tickets?status=OPEN" \
-H "Authorization: Bearer $ADMIN_TOKEN"
# â [ { "id": "clxâ¦", "status": "OPEN", "user": { "id": "â¦", "email": "â¦", "name": "â¦" }, ⦠} ]
# Reply, then close
curl -X POST http://localhost:4040/v1/admin/support/tickets/clx123/reply \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"message":"Fixed in build 412 â please retry."}'
# â { "id": "clx123", "status": "OPEN", "messages": [ â¦, { "authorRole": "ADMIN", ⦠} ] }
curl -X POST http://localhost:4040/v1/admin/support/tickets/clx123/close \
-H "Authorization: Bearer $ADMIN_TOKEN"
# â { "id": "clx123", "status": "CLOSED" }Announcements & feature flags
Two read-only endpoints the SPA calls on boot â one for banners, one for per-viewer feature flags.
| Method | Path | Notes |
|---|---|---|
| GET | /v1/announcements | Active platform announcements (banners). No auth required. |
| GET | /v1/flags | Feature flags evaluated for the current viewer. Bearer token optional. |
Announcements
Returns published, unexpired announcements only, newest first (max 20). Public â no token needed.
bash
curl http://localhost:4040/v1/announcements
# â {
# "items": [
# {
# "id": "clxâ¦",
# "title": "Scheduled maintenance",
# "body": "The API will be read-only from 02:00â03:00 UTC.",
# "level": "warn", // "info" | "warn" | "critical"
# "publishedAt": "2026-07-16T00:00:00.000Z",
# "expiresAt": "2026-07-17T00:00:00.000Z" // may be null
# }
# ]
# }Feature flags
Returns a flat key â boolean map of every flag, resolved for the caller. No token is required, but sending one changes the result: anonymous viewers only see flags with no targeting rules (global on/off). Send a Bearer token to resolve flags targeted by user id, plan, or percentage rollout.
bash
# Anonymous â only globally-enabled flags
curl http://localhost:4040/v1/flags
# â { "flags": { "new_editor": true, "beta_video": false } }
# Authenticated â user/plan/percentage targeting is applied
curl http://localhost:4040/v1/flags \
-H "Authorization: Bearer $TOKEN"
# â { "flags": { "new_editor": true, "beta_video": true } }Legal documents (privacy · terms)
Admin-managed policy pages, fetched publicly by the storefront. Content is markdown â render it client-side.
GET /v1/legalâ{ items: [{ slug, title, version, updatedAt }] }â metadata for all docs.GET /v1/legal/:slugâ{ slug, title, content, version, updatedAt }â full markdown.slugisprivacy,terms, or any other page an admin adds.404if the slug doesn't exist.
Both are public (no auth). Cache client-side; version bumps on each admin edit, so use it as a cache key. Seeded with starter privacy + terms docs that admins edit in the admin console (Ops â Legal).
No storefront? Use the built-in pages. The API also serves ready-made HTML at GET /privacy and GET /terms (and GET /legal/:slug) — the admin-authored markdown rendered to a clean, standalone, dark-mode-aware page. Link to them directly, or fetch the JSON above and render in your own UI.