Skip to content

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

  1. Getting started
  2. Authentication
  3. Client Implementation Guide (Storefront)
  4. Errors & rate limits
  5. Models API
  6. AI Gateway
  7. Creation Studio Storefront Guide
  8. Async jobs & live status
  9. Files & media
  10. Library
  11. Community Storefront Implementation Guide
  12. Community feed
  13. Billing & payments
  14. Usage API
  15. Account API
  16. Notifications API
  17. Outgoing webhooks
  18. Workspaces / Teams
  19. Chat API
  20. Support (tickets)
  21. Announcements & feature flags

Getting started

Everything a client app needs lives under one base URL.

EnvironmentBase URL
Local devhttp://localhost:4040
Productionhttps://api-momagic.ai.hullor.io
  • All endpoints are under /v1.
  • All request/response bodies are application/json unless stated.
  • All timestamps are ISO-8601 UTC.
  • Interactive OpenAPI explorer: GET /docs on 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": "..." }. Pass nextCursor back as ?cursor= for the next page; nextCursor: null means 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.

EmailPasswordWhat they have
demo@magic.aidemo1234Active Pro subscription, generations, notifications, a collection
creator@magic.aidemo1234Public generations + community posts with likes/comments
newbie@magic.aidemo1234A 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/refresh using the cookie), login, logout.

Boot sequence for a SPA

  1. POST /v1/auth/refresh (cookie is sent automatically) → access token, or 401 → show login.
  2. GET /v1/users/me → user profile + active subscription.
  3. GET /v1/flags → feature flags for the viewer.
  4. GET /v1/announcements → banners.
  5. GET /v1/models/me?type=TEXT → models the user's plan allows, for the picker.

Authentication

Two token types:

TokenFormTTLWhere it lives
Access tokenJWT15 minJSON response body — keep in memory, send as Authorization: Bearer <token>
Refresh tokenopaque30 dayshttpOnly cookie rt scoped to /v1/auth — browser sends it automatically

Endpoints

MethodPathBodyReturns
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/googlebrowser redirect302 → Google consent
GET/v1/auth/google/callbackGoogle sends code302 → ${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 refreshToken and rely on the httpOnly rt cookie.
  • Mobile / server-to-server clients (which can't use cookies) store refreshToken and 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.

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 } + rt cookie.
  • The server verifies the token was minted for its GOOGLE_CLIENT_ID; a token for another app is rejected 401.
  • Needs only GOOGLE_CLIENT_ID on the server (no client secret). 503 GOOGLE_SIGNIN_DISABLED if 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:

  1. Browser navigates to GET /v1/auth/google (plain link, not XHR).
  2. Server runs Authorization-Code + PKCE with Google.
  3. 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 bar

Google Cloud Console setup

Create one OAuth Web client (console → APIs & Services → Credentials) and configure:

SettingValueNeeded for
Server env GOOGLE_CLIENT_IDthe client idboth options
Server env GOOGLE_CLIENT_SECRETthe client secretOption B only
Authorized JavaScript originsyour 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 login

Client 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 challengeToken is 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 a TWO_FA_FAILED security event.

Managing 2FA (while logged in)

MethodPathPurpose
POST/v1/account/2fa/setupReturns { 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:

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 · dev http://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 (expiresIn seconds). Keep it in memory (not localStorage). Send as Authorization: Bearer.
  • Refresh token — returned two ways: an httpOnly rt cookie (browsers, same-origin) and a refreshToken field in the body (mobile / cross-origin clients that can't use the cookie). Use one.
  • GET /v1/users/me returns 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. The challengeToken lives ~5 min.
  • 401 → bad credentials. (10 failures within 15 min → temporarily locked.)

5. Google sign-in

Two options — pick one.

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/SECRET set.

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 rt cookie is sent automatically — send credentials: 'include', no body.
  • Mobile / cross-origin: send the stored refreshToken in 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 refreshToken from the body (treat it like the access token's long-lived partner; store securely). All API calls need credentials: '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

HTTPcodeMeaningClient action
400BAD_REQUESTValidation / content filterShow message inline
401UNAUTHORIZEDMissing/expired tokenAuto-refresh + replay; else → login
402NO_ACTIVE_SUBSCRIPTIONNo active planPrompt to subscribe
402mfaRequired (login)2FA neededGo to code step (/2fa/verify)
403FORBIDDEN / MODEL_NOT_IN_PLANNot allowed / plan gateUpsell / hide action
404NOT_FOUNDGone / wrong idEmpty state
409CONFLICTe.g. handle takenAsk for a different value
429QUOTA_EXCEEDEDOut of credits (bucket named in message)"Top up / upgrade"
503PROVIDER_UNAVAILABLE / GOOGLE_SIGNIN_DISABLEDUpstream down / feature offRetry later / hide method

12. Suggested build order

  1. API client (§9) + GET /v1/auth/config gating.
  2. Auth: email/password + Google button, refresh-on-boot, logout.
  3. /users/me hydration + handle onboarding (§10).
  4. Studio: model picker → text-to-image → job tray for video (studio guide).
  5. Community: gallery grid → detail → like/follow → publish (community guide).
  6. 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 } }
HTTPcodeMeaningClient action
400BAD_REQUESTInvalid 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
401UNAUTHORIZEDMissing/expired tokenRefresh once, replay; then login
403FORBIDDENRole/ownership check failedDon't retry
404NOT_FOUNDResource missingDon't retry
409CONFLICTDuplicate (email taken, slug taken…)Surface to user
402/403QUOTA_EXCEEDEDPlan limit hitUpsell / show usage page
403NO_ACTIVE_SUBSCRIPTIONNeeds a planRedirect to pricing
403MODEL_NOT_IN_PLANModel not allowed on this tierShow plan-gated UI
503PROVIDER_UNAVAILABLEAll upstream accounts down/exhaustedRetry with backoff
500INTERNALUnhandledReport; 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.

MethodPathNotes
GET/v1/modelsPublic catalog of servable models. Optional Bearer adds a per-row allowed flag.
GET/v1/models/meRequires 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:

QueryValuesEffect
typeTEXT | IMAGE | VIDEO | AUDIO | EMBEDDING | MULTIMODALOnly models of that modality.
providerprovider slugOnly 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

FieldNotes
idStable model id (used internally).
slugThe identifier you send as model to the gateway, e.g. openai/gpt-4o-mini.
displayNameHuman label for the picker.
typeTEXT | IMAGE | VIDEO | AUDIO | EMBEDDING | MULTIMODAL.
statusACTIVE | BETA | DISABLED (only ACTIVE/BETA are returned).
capabilitiesFree-form object, e.g. vision/tools/streaming support.
pricingProvider cost, for reference only — not the customer price. Customers spend credits by count, not by this figure.
maxContextTokensContext 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 stream

Image — 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…" }
  • model must be a multimodal model (the Gemini line). Small text-only models (e.g. Gemma) are rejected with 400 — they restate the instruction instead of rewriting. Pick a model from GET /v1/models/me?type=MULTIMODAL.
  • target is image | video | audio | text, default text. It tunes the rewrite (an image target adds composition/lighting/lens; text produces 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.

  • imageUrl accepts a data: URL, a platform /v1/files/raw/… URL, or a plain https:// URL. Prefer uploading via Files and passing the /v1/files/raw URL — it's read straight from storage (fastest, no external fetch).
  • instruction is optional; omit it for a general upscale/clean.
  • A remote imageUrl is fetched with an 8 s timeout and a 15 MB cap. If the source can't be read (404, hotlink-blocked 403, too large, timeout), you get a 400 BAD_REQUEST with a message like "input image could not be fetched (HTTP 403)" — that means your source URL, not the AI service. A 503/PROVIDER_UNAVAILABLE is a real provider issue; retry those with backoff, but don't retry a 400.

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 list MULTIMODAL models 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 · dev http://localhost:4040 · schema explorer /docs.
  • Auth: Authorization: Bearer <JWT> (browser) or sk-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.

ConceptEndpoint(s)Notes
Model catalogGET /v1/models, GET /v1/models/meWhat can be generated + whether the user's plan grants it
Image (sync)POST /v1/ai/imageReturns images inline
Video (async)POST /v1/ai/video → jobReturns jobId; poll/stream for the result
Lip sync (async)POST /v1/ai/video/lip-sync → jobAudio/text-driven mouth animation (Kling)
Video extend (async)POST /v1/ai/video/extend → jobContinue a clip (Kling)
Audio (sync)POST /v1/ai/audioTTS
Text (sync/SSE)POST /v1/ai/textChat/caption/idea helper
Prompt enhancePOST /v1/ai/enhance/promptRewrite a rough idea into a rich prompt
Image enhancePOST /v1/ai/enhance/imageUpscale/clean via img2img
JobsGET /v1/jobs, GET /v1/jobs/:id, GET /v1/jobs/:id/stream, POST /v1/jobs/:id/cancelVideo lifecycle
UploadsPOST /v1/files/init → PUTSeed/reference images
AssetsGET /v1/library/generationsThe user's own creations

Two things to internalize:

  1. Image is synchronous, video is asynchronous. Image returns pixels in the HTTP response. Video returns a jobId — you then poll GET /v1/jobs/:id or subscribe to its SSE stream. Design the studio around a job queue/tray from day one.
  2. 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 as extra (§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. slug is what you send as model in every generate call.
  • allowed = the caller's plan grants this model (null when anonymous). Gate the "Generate" button on it; deep-link to upgrade when false.
  • GET /v1/models/me returns only the models the caller's active plan allows — use it to populate the picker for a signed-in user.
  • capabilities is now a control schema (as of 2026-07-20): GET /v1/models returns capabilities = { flags?, controls, extra } — a per-model, dynamic-UI descriptor. controls lists first-class request fields (with type/label/range/default); extra lists provider-specific keys to send under the request extra object. 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.
  • count 1–8 → grid of results.
  • seed for 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 }
  • initImageUrl accepts a data: URL, an https URL, or a same-origin /v1/files/raw/* URL (from the uploader, §9).
  • strength 0→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…" }
  • model must be multimodal or you get 400. target ∈ image|video|audio|text tunes 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/image

7. 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 featureextra key(s)
Negative prompt (video)negative_prompt
Prompt adherencecfg_scale
Standard/Professional modemode: "std" | "pro"
End frame (start+end)image_tail (with seedImageUrl as the start frame)
Camera movementcamera_control: { type, config }
Motion brushdynamic_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 in extra with 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.

  1. POST /v1/files/init — { "kind": "SEED_IMAGE", "mimeType": "image/png", "sizeBytes": 812345 } → { id, uploadUrl, method: "PUT", storageKey, publicUrl }.
  2. PUT <uploadUrl> with the raw bytes → marks the file READY.
  3. Pass the returned publicUrl (a /v1/files/raw/<key> URL) as initImageUrl / 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 includes extra → re-run with the same advanced settings), model, isFavorite, visibility, tags.
  • PATCH /v1/library/generations/:id — rename, favorite, tag, or flip visibility.
  • 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:

HTTPcodeMeaningUI
402NO_ACTIVE_SUBSCRIPTIONNo active planPrompt to subscribe
403MODEL_NOT_IN_PLANPlan doesn't include this modelUpsell / disable model
429QUOTA_EXCEEDEDOut of credits (text/image/video bucket)"Top up or upgrade"; show which bucket
503PROVIDER_UNAVAILABLEAll provider accounts failed/over budget"Try again shortly"
400BAD_REQUESTUpstream user error / content filterShow 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 featureStatus
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)

  • extra passthrough (structural unlock). POST /v1/ai/image, /video, /audio, /text now accept an extra object, 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. extra is also persisted into Generation.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/models now returns capabilities = { 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, endImageUrl are now typed VideoBody/VideoGenerateInput fields, 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-sync and /extend, new optional adapter methods (startLipSyncJob/startVideoExtendJob), Kling implementations, and poll-routing by Job.operation. All reuse the existing job/credit/refund/worker path via a shared startVideoLikeJob helper. Marked // VERIFY — request/query shapes must be checked against the live Kling spec before enabling in production.
  • Gap D — image inpaint/mask ✅. maskUrl added to ImageBody/ImageGenerateInput; Stability adapter branches to its inpaint endpoint when initImageUrl + maskUrl are present. // VERIFY against 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 the ProviderAdapter contract for lip-sync/extend and can be followed for these.
  • Provider // VERIFY items. 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.
  • extra capability coverage. capabilities.extra is populated for Kling and MiniMax video; extend the per-provider maps in capabilities.ts as more providers gain controls.

15. Suggested build order

  1. Model picker (/v1/models/me) + text-to-image (/v1/ai/image) — fastest visible win, fully sync.
  2. Uploader (/v1/files/init) + image-to-image and image enhance.
  3. Prompt enhance button.
  4. Text-to-video + job tray (/v1/ai/video + /v1/jobs/:id/stream) — get the async UX right early.
  5. Image-to-video (seed frame) + advanced controls via extra (start with Kling: negative prompt, cfg, mode, end frame, camera).
  6. Assets/library (list, re-run from params, favorite, publish).
  7. Credits/quota surfacing everywhere (disable buttons on empty buckets; handle 402/403/429/503).
  8. 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)
                 ↘ CANCELED

Endpoints

MethodPathNotes
GET/v1/jobsCaller's jobs, filterable by status
GET/v1/jobs/:idSnapshot: status, progress 0-100, output when done
GET/v1/jobs/:id/streamSSE — live status pushes
POST/v1/jobs/:id/cancelBest-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

MethodPathNotes
GET/v1/library/generationsCursor-paginated list of past generations
GET/v1/library/generations/:idFetch one generation
PATCH/v1/library/generations/:idUpdate title / favorite / visibility / tags
DELETE/v1/library/generations/:idPermanently delete a generation
GET/v1/library/collectionsList collections (with item counts)
POST/v1/library/collectionsCreate a collection
PATCH/v1/library/collections/:idRename / edit a collection
DELETE/v1/library/collections/:idDelete a collection (its items are not deleted)
GET/v1/library/collections/:id/itemsList items in a collection
POST/v1/library/collections/:id/itemsAdd a generation to a collection
DELETE/v1/library/collections/:id/items/:itemIdRemove an item from a collection
GET/v1/library/promptsList saved prompts (+ system templates)
POST/v1/library/promptsSave a prompt template
PATCH/v1/library/prompts/:idEdit a saved prompt
DELETE/v1/library/prompts/:idDelete a saved prompt
POST/v1/library/prompts/:id/useBump 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 only IMAGE/VIDEO/AUDIO and excludes TEXT, so text generations don't show up as file cards. Text is still stored as data: request it explicitly with ?kind=TEXT to build a flat history view (each row's output is { "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 — not favorite. 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 sorting

Community 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: null means 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.

SurfaceModelShapeUse for
Works /v1/community/worksCommunityWorkKling envelope ({ type, userWork:{…} }), verbatimThe main Kling-style gallery grid — imported (Kling) + native works in one feed
Posts /v1/community/*Post + GenerationNative 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 inner userProfile.handle links to the creator's /u/:handle profile.

1. Page-by-page map to Kling.ai

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 (type tells you, or just probe the three).
  • Media: resource.resource = full video/image URL. cover.resource = poster/thumb. Use width/height for aspect-ratio masonry cells (avoid layout shift). duration is ms.
  • Likes: starNum is the single rolled-up number (imported + local likes). favored/starred = viewer's like state (false when anonymous). Send the Bearer token on the GET so these come back correct — no second call needed.
  • Card identity: use _magic.id for all interaction calls below (not workId).
  • 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 raw payload 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.

MethodPathReturns
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.
  • followingThem on 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.output shape depends on kind: TEXT → { text }, IMAGE → { images:[{url}] }, VIDEO → { videos:[{url,durationSeconds}] }, AUDIO → { audio:{url} }.
  • likedByMe reflects the viewer (Bearer token); false anonymous.
  • 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 — bumps viewCount, 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. parentId set = 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 handle has 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"? }. 409 on handle collision.
  • Community-readiness check: GET /v1/users/me now returns handle, avatarUrl, bio on the user object — read handle == null to decide whether to run handle onboarding before the publish action.
  • Avatar upload: 2-step — POST /v1/files/init then PUT the returned URL (see /reference → Files & media), then set avatarUrl.

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_URL is 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) or generation.output dims to reserve cell size and kill layout shift.
  • Videos: preload="none", poster from cover.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

SurfaceMethod · PathAuth
Gallery gridGET /v1/community/worksoptional
Work detailGET /v1/community/works/:idoptional
Work like / unlikePOST · DELETE /v1/community/works/:id/likerequired
Work viewPOST /v1/community/works/:id/viewnone
Recent feedGET /v1/community/feed/recentoptional
Trending feedGET /v1/community/feed/trendingoptional
Following feedGET /v1/community/feed/followingrequired
Publish postPOST /v1/community/postsrequired
Post detailGET /v1/community/posts/:idoptional
Unpublish postDELETE /v1/community/posts/:idrequired
Post like / unlikePOST · DELETE /v1/community/posts/:id/likerequired
Comments list / add / deleteGET · POST /v1/community/posts/:id/comments · DELETE /v1/community/comments/:idmixed
Public profileGET /v1/community/users/:handleoptional
User's postsGET /v1/community/users/:handle/postsoptional
Follow / unfollowPOST · DELETE /v1/community/users/:handle/followrequired
ReportPOST /v1/community/reportsrequired
Publish picker (own generations)GET /v1/library/generationsrequired
Set handle/avatar/bioPATCH /v1/account/profilerequired

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.

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

  1. Gallery grid from /works (landing) — highest visible parity, zero transform.
  2. Work detail + like + view lightbox.
  3. Profiles + follow (/users/:handle, follow buttons) — requires handle onboarding (PATCH /account/profile).
  4. Publish flow from Library picker (/library/generations → POST /posts).
  5. 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 / starred reflect the current viewer's like state when the request carries a Bearer token; always false anonymous.
  • _magic.id is the platform id — use it for the endpoints below.

Interactions

MethodPathAuthEffect
POST/v1/community/works/:id/likerequiredIdempotent like → { ok, starNum, favored: true }
DELETE/v1/community/works/:id/likerequiredUnlike
POST/v1/community/works/:id/viewnoneCount a view — call once when a card enters the viewport
GET/v1/community/works/:idoptionalSingle 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.

MethodPathNotes
GET/v1/plansActive plans, sorted by sortOrder then priceCents
GET/v1/plans/:slugOne 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.

MethodPathNotes
GET/v1/subscriptionsCaller's subscriptions, newest first
POST/v1/subscriptions/:id/cancelAccess continues until endsAt
POST/v1/subscriptions/:id/upgradeTo a higher plan, effective now, prorated as credits
POST/v1/subscriptions/:id/downgradeScheduled for next renewal
POST/v1/subscriptions/trialOne 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.

MethodPathNotes
GET/v1/subscriptions/credits/packsOne-time credit top-ups (public)
GET/v1/subscriptions/credits/balanceCurrent add-on balance
GET/v1/subscriptions/credits/transactionsLast 50 ledger entries, newest first
POST/v1/subscriptions/coupons/previewCompute 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.

MethodPathNotes
POST/v1/payments/checkoutReturns { 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.

ProviderRegionextra requiredGateway webhook
stripeGlobal—POST (HMAC signature)
paypalGlobal—POST (signature verify)
sslcommerzBDT—POST IPN (val_id re-validation)
bkashBDT—POST (status re-query)
nagadBDT—POST (RSA-signed)
dcbBDT onlypaymentProvider + msisdnGET, 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.

MethodPathUsed by
POST/v1/payments/webhook/:providerStripe, PayPal, SSLCommerz, bKash, Nagad
GET/v1/payments/webhook/:providerDCB 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

MethodPathNotes
GET/v1/usagePer-request usage feed, cursor-paginated
GET/v1/usage/summaryUsage 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 the POST /v1/account/password/{forgot,verify-otp,reset} reset flow.
  • All timestamps are ISO-8601 UTC. Examples below use TOK=<accessToken>.

Profile

MethodPathNotes
GET/v1/users/meCurrent user + active subscription summary
PATCH/v1/users/meBody { name } — updates display name only
PATCH/v1/account/profilePublic fields: handle, avatarUrl, bio
POST/v1/account/verify-email/send(Re-)send verification link; { ok, alreadyVerified? }
POST/v1/account/verify-email/confirmNo 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).

MethodPathNotes
GET/v1/account/api-keysList keys (secrets never returned)
POST/v1/account/api-keysCreate a key — returns the raw secret once
DELETE/v1/account/api-keys/:idRevoke 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.
  • GET returns { items: [{ id, label, keyPrefix, scopes, lastUsedAt, expiresAt, createdAt }] } — only the last 4 chars (keyPrefix) of each key.
  • DELETE and 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 granted

Each /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

MethodPathNotes
POST/v1/account/password/changeAuth. Body { currentPassword, newPassword } (new ≥ 8)
POST/v1/account/password/forgotNo auth. { email } → 4-digit OTP emailed
POST/v1/account/password/verify-otpNo auth. { email, otp } → { resetToken }
POST/v1/account/password/resetNo 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 pw

The forgot/verify-otp/reset flow (OTP valid 10 min, 5 attempts, resetToken valid 15 min) is detailed in authentication.md.

Email

MethodPathNotes
POST/v1/account/email/changeAuth. Body { newEmail } → confirm link sent to the new address
POST/v1/account/email/confirmNo auth. Body { token } → swaps email, marks verified

Change requests expire in 24 h. 409 if the address is already in use.

Sessions & security

MethodPathNotes
GET/v1/account/sessionsActive logins, one row per device
DELETE/v1/account/sessions/:idSign out one device
POST/v1/account/sessions/revoke-othersSign out everywhere except the caller
GET/v1/account/security/eventsRecent 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)

MethodPathNotes
POST/v1/account/2fa/setupBegin enrollment → { secret, otpauthUrl }
POST/v1/account/2fa/enableBody { code } (6-digit) → enables, returns recoveryCodes once
POST/v1/account/2fa/disableBody { password } → turns 2FA off
GET/v1/account/2fa/status{ enabled, since, recoveryCodesRemaining }
POST/v1/account/2fa/recovery-codes/regenerateBody { 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 again

Recovery codes are single-use. Regenerating (and, per the login flow, 2FA at sign-in) requires a fresh authenticator code.

Preferences

MethodPathNotes
GET/v1/account/preferencesLocale, timezone, theme, visibility
PATCH/v1/account/preferencesPartial update (all fields optional)
GET/v1/account/notification-prefsEmail / in-app channel toggles
PATCH/v1/account/notification-prefsPartial update (all boolean, optional)
  • Preferences: locale (2–10), timezone (2–64), theme light|dark|system, profileVisibility PUBLIC|FOLLOWERS_ONLY|PRIVATE, allowDmFromAnyone (bool), defaultGenerationVisibility PRIVATE|UNLISTED|PUBLIC. Defaults: en / UTC / system / PUBLIC / true / PRIVATE.
  • Notification prefs (all default true except emailMarketing): emailJobUpdates, emailBillingAlerts, emailSecurityAlerts, emailMarketing, inAppCommunityActivity, inAppQuotaWarnings.

Blocks

MethodPathNotes
GET/v1/account/blocksList blocked / muted users
POST/v1/account/blocksBody { handle, kind?, reason? }
DELETE/v1/account/blocks/:handleUnblock / 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

MethodPathNotes
POST/v1/account/data-exportQueue an async export → returns the request row
GET/v1/account/data-exportList your last 10 export requests
POST/v1/account/deleteSoft-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 keys

Notifications API

The authenticated user's notification inbox: list, mark one read, mark all read.

Endpoints

MethodPathNotes
GET/v1/notificationsCursor-paginated inbox, includes unreadCount
POST/v1/notifications/:id/readMark one notification as read
POST/v1/notifications/read-allMark 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 read

Outgoing 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

MethodPathNotes
GET/v1/webhooks/eventsPublished event list
POST/v1/webhooks{ url, events, description? } → returns cleartext secret exactly once
GET/v1/webhooksList (secret masked whsec_••••1234)
PATCH/v1/webhooks/:idUpdate url/events/status; status=ACTIVE resets failure count
POST/v1/webhooks/:id/rotate-secretNew secret, no grace period
GET/v1/webhooks/:id/deliveriesRecent attempts with response codes
POST/v1/webhooks/:id/deliveries/:dId/resendReplay 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 2xx within 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

MethodPathAuth rule
GET/v1/workspacesany member — lists caller's workspaces with role + memberCount
POST/v1/workspaces{ name, slug? } — caller becomes OWNER
GET / PATCH / DELETE/v1/workspaces/:idmember / admin+ / owner
GET/v1/workspaces/:id/membersmember
PATCH/v1/workspaces/:id/members/:memberId{ role } — promoting to OWNER transfers ownership atomically (old owner → ADMIN)
DELETE/v1/workspaces/:id/members/:memberIdadmin+ removes, or self-leave. Owner must transfer first
POST/v1/workspaces/:id/invitationsadmin+ — { 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

  1. Admin calls POST /invitations → gets a single-use token (valid 7 days).
  2. Your app emails the invitee a link like https://app.example.com/invite?token=wsi_….
  3. Invitee signs in (or signs up with the same email), then your app calls POST /v1/workspaces/invitations/accept { token }.
  4. 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

MethodPathNotes
POST/v1/chat/conversationsStart an (empty) conversation
GET/v1/chat/conversationsList the caller's threads, newest activity first
GET/v1/chat/conversations/:idFetch a thread with its full message history
PATCH/v1/chat/conversations/:idRename a thread
DELETE/v1/chat/conversations/:idDelete a thread and its messages
POST/v1/chat/conversations/:id/messagesSend a message into an existing thread (needs ai:text)
POST/v1/chatOne-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.

MethodPathNotes
GET/v1/support/ticketsList the caller's tickets, newest activity first
POST/v1/support/ticketsOpen a new ticket — { subject, message }
GET/v1/support/tickets/:idFetch one ticket with its full message thread
POST/v1/support/tickets/:id/replyAppend 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.

MethodPathNotes
GET/v1/admin/support/ticketsList all tickets — optional ?status=OPEN|PENDING|CLOSED and ?q=<subject search> (max 200)
GET/v1/admin/support/tickets/:idFetch any ticket, with the requester's user joined in
POST/v1/admin/support/tickets/:id/replyReply as staff — { message }; bumps status → OPEN
POST/v1/admin/support/tickets/:id/closeClose the ticket → CLOSED
POST/v1/admin/support/tickets/:id/reopenReopen 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.

MethodPathNotes
GET/v1/announcementsActive platform announcements (banners). No auth required.
GET/v1/flagsFeature 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. slug is privacy, terms, or any other page an admin adds. 404 if 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.

Magic.ai — internal documentation