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