# Elyum API (v0)

Same endpoints the studio uses. Authenticate with `Authorization: Bearer ek_live_…` (create keys on `/account`; keys carry **scopes** `read` · `generate` · `spend` and an optional **daily credit cap** — `403 {code:"scope"}` / `429 {code:"key_cap"}` when exceeded). Agents: the same surface is available as MCP at `/mcp` — see docs/MCP.md and `/docs/mcp`. Credits are metered exactly like the app; a failed generation is refunded automatically. Async endpoints answer `202 {pending:true, id, model, genId, credits, balance}` — poll `GET /api/job/<id>` until it returns `{url, model, elapsedMs, genId}` (our `/media/...` mirror URL) or an error. All bodies are JSON.

## Creators
- `GET /api/creators` → `{house:[…], mine:[…]}` — `{id, name, imageUrl, images, traits}`. `images` is every photo of the person (different angles); `imageUrl` stays the primary for old clients. House creators are shared; yours are per workspace.
- `POST /api/creators {name, images, traits?}` — `images` = array of data URIs, our `/media/...` URLs, or http(s) image URLs (up to 30 per creator; several angles of the same person makes them consistent across renders). Legacy `image` (single string) still accepted. Max 50 creators per workspace.
- `PATCH /api/creators {id, name?, traits?, images?}` — edit one of yours (admins: house creators too). `images` is the FINAL ordered list: the creator's existing `/media/cr_…` URLs are kept (reorder freely — the first becomes `imageUrl`, the primary face), anything else (data URI, `/media/...`, http URL) is stored as a new photo, and a photo left out is removed from the creator (renders already made with it keep their files). Send only the fields you change; `images` must keep at least one photo (30 max). 404 for a creator that isn't yours.
- `DELETE /api/creators?id=cr_…` — soft delete; old generations still resolve the creator.
- In studio prompts, `@CreatorName` mentions attach the creator's photos and rewrite to the `@ImageN` contract below.

## Image — `POST /api/image`
`{prompt, creatorId?, productUrl?, imageUrls?, aspectRatio?, resolution?, model?, mode?}`
`mode` is inferred: any reference (creator / product / imageUrls) → edit family (product-preserving), else text-to-image. Creator lands at `@Image1`, product next, then `imageUrls`; the server writes the identity notes into the prompt.

## Video — `POST /api/video`
`{mode?, prompt, …}` — `mode` inferred when omitted: `creatorId|productUrl` → `ugc`, `imageUrl` → `i2v`, else `t2v`.
- `ugc` — creator + product → talking selfie clip, no reference video. `{creatorId?, productUrl?, imageUrl? (opening scene), imageUrls? (extra references), duration (4–15), aspectRatio, resolution, audio}`.
  **Reference numbering (a contract, same in every mode and in the studios' `@` menu):** `@Image1` = creator, then product, then the scene/look image, then `imageUrls` in order. Mention them in the prompt (`"she holds @Image2 next to @Image3"`) — the server appends the identity notes ("@Image1 is the creator…", "@Image3 is an additional reference — reproduce it exactly wherever the brief mentions it"). Reference models take up to `maxImages` (Seedance 2.5 reference: 30, Seedance 2.0: 9; see `GET /api/roles`).
- `ref` — two or more images are REFERENCES (never a frame): `{imageUrl (= @Image1), imageUrls (@Image2…), prompt, duration, aspectRatio, resolution, audio, model?}`. Any reference-capable route works (`seedance-2.5-reference` default, `veo-3.1-reference`, `grok-imagine-1.5-reference`, `gemini-omni-flash-reference`, `seedance-2-mini-reference`…). Write `@ImageN` in the prompt; the server translates to the model's own citation style ("Image 1" for Grok/Veo/Omni) and appends the "reproduce each exactly" note.
  Audio references in `ref` and `edit` use `audioUrls: string[]`, ordered as `@Audio1`, `@Audio2`, and so on. Seedance 2.5 accepts up to 10 tracks, each at least 1.8 seconds and at most 30.2 seconds combined, alongside an image or video. Check `specs[model].maxAudios` in `GET /api/roles` for the selected model (omitted means one). Legacy `audioUrl` is still accepted; when both fields are supplied, it comes first and duplicate URLs are removed. Over-limit references are rejected before reserving credits.
- `motion` — a character image + a reference video: the character performs the video's motion (Kling 3.0 Motion Control). `{imageUrl, videoUrl, prompt?}`; duration/ratio follow the video. **Held (2026-08-18):** the partner route returns upstream errors on every probe; the family is not listed until it renders.
- `clone` — `{videoUrl (2–30 s reference), prompt (the swap brief), creatorId?, productUrl?, imageUrls?, refSeconds?, keepAudio?, duration?, aspectRatio?, resolution?, audio?, model?}`.
- `i2v` / `t2v` — `{imageUrl?, endImageUrl?, prompt, aspectRatio, duration, resolution, audio, model}`. `endImageUrl` is used only by first-to-last-frame models such as the MiniMax H3 line. H3 Max (`minimax-h3-t2v` / `-i2v`), H3 Max Turbo (`minimax-h3-turbo-*`) and H3 (`minimax-h3-4k-*`, adds `2K` / `4K` upscaled from a 768P base) render 5–15 seconds at `480P` or `768P` with native audio; text-to-video supports `21:9`, `16:9`, `4:3`, `1:1`, `3:4`, and `9:16`, while image-to-video follows the first image's ratio. Their reference routes (`minimax-h3-reference`, `minimax-h3-4k-reference`) take up to 9 images, 3 clips (≤15 s combined) and 3 audio tracks, 12 files in all; the server cites them as "Image 1 / Video 1 / Audio 1".
- **Families (what the Video studio shows).** `GET /api/roles` returns `families[]` — `{id, label, vendor, tagline, featured, routes:{t2v?, i2v?, ref?, motion?}}` with public slugs per route. The studio lists a family ("Seedance 2.5") and picks the route from the attached media: nothing → `t2v`, one image → `i2v`, two+ → `ref`, image + video → `motion`. API callers pass the route slug directly as `model`.
- `extend` — `{videoUrl, sourceModel, prompt, duration?}`; `edit` — `{videoUrl, prompt}`; `captions` — `{videoUrl}` (karaoke subtitles burned in).
- `upscale` (2026-09-11) — `{imageUrl, target?, model?}`: one image → the same picture at `target` = `2K` | `4K` | `6K` | `8K` on its longest side (1920 / 3840 / 5760 / 7680 px; default: the smallest size above the source), no prompt (`seedvr2-upscale` default, `clarity-upscaler`, `real-esrgan`; `GET /api/roles` → `upscaleFamilies[]`). Reached by chained ×2 passes inside the one job (a 1080p-class source is one pass to 2K, two to 4K, 6K and 8K are three from 960 px; an overshooting last pass is fitted down to the exact target). **Plans only:** the Free plan gets `403 {code:"plan_required"}`. Free on paid plans for a limited time (the estimate answers `{credits:0, included:true, promo:"upscale", until}`), then per pass (`GET /api/pricing/estimate?model=&upscale_passes=`). Delivered **charged on render** — `{url, locked:false}`, no preview step. A source already at or above the target is refused (`400`). **Clips:** `{videoUrl, target?, model?}` — `seedvr2-upscale-video` (2K or 4K) or `topaz-video-upscale` (any target up to ×4 of the source); the target reaches the supplier directly, priced per second of the source at the target; clips up to 20 s; runs on the partner gateway (per-model lane row).

## Avatar — `POST /api/avatar`
`{photoUrl, script, voice, aspectRatio?, model?}` — talking head from a photo (use a creator's `imageUrl` for consistency).

## Autopilot / batches — `POST /api/batch`
`{product:{name, description, imageUrl?, audience?, voiceOfCustomer?[]}, variants (1–20), mode:"ugc"|"scene", creatorIds?[], captions?, seedAngle?, skipVideo?}` → `{id, genId, credits, balance}`.
`mode:"ugc"` (Autopilot): each angle → creator-fronted talking clip (hook + script spoken) → captions. `creatorIds` rotate across variants; empty = house creators shuffled. `seedAngle` = a variant's `angle` object from a previous result → "more like this".
- `GET /api/batch?id=` → `{status, log[], result{variants[…]}, verdicts}`; `GET /api/batch` → recent.
- `PATCH /api/batch {id, verdicts:{"0":"keep","1":"kill"}}` — review.
- `GET /api/batch/export?id=&keep=0,2` → zip (mp4 + sidecar txt per ad, pack.csv, README).

## Uploads, estimates, account
- `POST /api/upload` (multipart `file`: mp4/mov/webm ≤60 MB, png/jpg/webp) → `{url:"/media/up_…"}` — use as `videoUrl` / `productUrl` / `imageUrls`.
- `GET /api/pricing/estimate?model=&duration=&resolution=&num_images=` or `?batch=N&mode=ugc` → `{credits}`; when signed in and the model is included with the plan (Pro/Ultra image perk) → `{credits: 0, included: true, wouldBe}`. Video credits scale with `resolution` (480p/720p/1080p/4k…) and `duration`; images with `resolution` (1K/2K/4K) — see docs/PRICING-SHEET.md.
- **Model ids** are our public slugs (`nano-banana-2-edit`, `seedance-2.5-reference`, `veo-3.1-fast-i2v`, …) — list them with `GET /api/roles` (`roles.<role>.primary/fallbacks`, `specs[slug].label`). Pass a slug (or its label) as `model`; omit it to get the role's default. Every response reports the model as a slug.
- `GET /api/me` → user, balance, plan. `GET /api/library` → your generations.

## Pay only for what you keep
- **Only kept takes are inputs.** A locked preview, a locked original or a killed take can't be passed as `imageUrl` / `imageUrls` / `videoUrl` / `productUrl` to any endpoint — 409 `{code:"locked"}` ("Keep this take first"). Uploads and creators are always usable. The studios' media panel hides un-kept takes for the same reason.
Successful renders come back **locked**: `{url:"/media/g_…/pN.ext" (preview), locked:true, unlockCredits, genId}`. Nothing is charged until you unlock.
- `POST /api/unlock {genId, index?, action:"unlock"}` → `{url (original), credits, balance}`; `action:"release"` → refunded. `GET /api/unlock?genId=` → states.
- Batches: each variant carries `locked / unlockCredits / genId / out`; `PATCH /api/batch {verdicts}` unlocks kept ads and releases killed ones. Export contains unlocked ads only.
- Unreviewed previews never expire: the Credits stay on hold until the customer keeps or kills the take. There is no preview budget; kills are an allowance per billing period → `409 {code:"kill_allowance", limit, used, resetsAt}` on a kill past it. Keeping a killed take again charges its full price.

## Keys (session only — a key can't manage keys)
- `GET /api/keys` · `POST /api/keys {name, scopes?:["read","generate","spend"], dailyCap?:number|null}` → `{id, key (once), prefix}` · `PATCH /api/keys {id, name?, scopes?, dailyCap?}` · `DELETE /api/keys?id=`.
- Agent discovery (2026-09-07): `/.well-known/api-catalog` (RFC 9727 linkset, also announced by a `Link: rel="api-catalog"` header on `/`), `/auth.md` (how a bot gets credentials), `/.well-known/mcp/server-card.json` + `/.well-known/mcp.json` (MCP server card with the live tool list) and `/.well-known/mcp-server-card` (SEP-2127 registry shape); `robots.txt` carries `Content-Signal: search=yes, ai-input=yes, ai-train=no`. Code: `lib/server/agent-readiness.ts`.
- OAuth 2.1 (for MCP clients): `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`, `POST /oauth/register` (DCR), `GET /oauth/authorize` (consent), `POST /oauth/token` (code+PKCE, refresh), `POST /oauth/revoke`. Access tokens are ordinary keys (kind `oauth`, 7-day expiry, rotating refresh).

## Video modes (`POST /api/video`, `mode`)
- `t2v` text → video · `i2v` the image is **frame one** and gets animated (`imageUrl`) · **`ref`** the images are **references** — same people/products, new scene and action from the prompt (`imageUrl` + `imageUrls`; any reference route, default Seedance 2.5) · **`motion`** character image + reference video (Kling 3.0 Motion Control) · `ugc` creator/product slots → talking clip · `clone` reference clip → new take · `extend` · `edit` · `captions` · `upscale` (an image at ×2; plans only). Omit `mode` and it is inferred: image → `i2v`, none → `t2v`.

## Media (who can open a `/media/...` URL)
Every file belongs to the workspace that made it. `GET /media/<id>/<file>` answers only with that workspace's cookie or `Authorization: Bearer ek_live_…` (admins too); anyone else gets 401/403. To hand a file to someone or something without credentials, mint a signed link: `POST /api/media/link {url:"/media/g_…/p0.mp4", ttlSeconds?:86400}` → `{url, path, expiresAt}` (max 7 days; locked originals → `409 {code:"locked"}` — link the preview or keep first). Passing another workspace's `/media` URL as an input anywhere → `403 {code:"input_forbidden"}`.

## Account, plans, codes (session only)
- `GET /api/me` → user, balance, plan, `subscription{status,current_period_end,cancel_at_period_end,paid_until}`, policy.
- `POST /api/coupons/redeem {code}` → `{plan?, months?, credits, paidUntil, balance}` · `409 {code:"used"}` · `400 {code:"invalid|expired|exhausted"}`.
- `POST /api/billing/subscribe|topup` → `503 {code:"billing_unavailable"}` until card checkout ships. `POST /api/billing/cancel {cancel:boolean}`.

## Errors
`400` validation · `401` no auth · `402 {error:"Not enough credits…"}` · `403 {code:"scope"}` key lacks the scope · `409` model/role disabled · `429 {code:"concurrency", limit, inFlight}` · `429 {code:"key_cap", cap, spent, needed}` · `503 {code:"maintenance", error:"Supplier under maintenance…"}` every render refused while the admin maintenance switch is on (nothing held; `Retry-After: 600`) · `503` gateway down.
