# ClipMaxxing Public API v1

This document is the integration spec for the ClipMaxxing HTTP API. Give it to an AI or a developer that needs to generate vertical shorts (voiceover, captions, or AI video) from a bot, Discord app, or backend.

- **Human-readable page:** https://clipmaxxing.app/docs
- **Raw markdown (this file):** https://clipmaxxing.app/docs/api.md
- **Product:** https://clipmaxxing.app

ClipMaxxing turns a story (or a prompt) plus a background clip into a 1080x1920 MP4 with optional AI voiceover and word-synced captions. Billing uses the same credit wallet as the website. There is no separate API plan.

## 1. Base URL and authentication

| Environment | Base URL |
| --- | --- |
| Production | `https://clipmaxxing.app/api` |
| Local API (direct) | `http://localhost:3000` |

All v1 paths in this document are relative to the **API origin**. In production, that origin is `https://clipmaxxing.app/api` (the site reverse-proxies `/api` to the Fastify server). Locally, call port 3000 with no `/api` prefix.

Authenticate every request with the header:

```
x-api-key: cmx_<48 hex characters>
```

Example:

```
GET /v1/projects HTTP/1.1
Host: clipmaxxing.app
x-api-key: cmx_...
```

Rules:

- Create and revoke keys in the website: Account → API keys (`https://clipmaxxing.app/account`).
- The full key is shown **once** at creation. Store it like a password.
- A key is bound to one user. Credits, library, and projects are that user's.
- An API key cannot create or list other API keys. That requires a website session cookie.
- Send JSON with `Content-Type: application/json` on POST bodies.
- Do not send a session cookie from a bot. Header auth is enough.

## 2. Typical bot flow

Generation is **asynchronous**. One POST starts a job. Poll until `READY` or `FAILED`, then download the MP4.

```
1. (optional) GET /v1/library/videos   → pick libraryVideoId
2. (optional) GET /v1/voices           → pick voiceId (storytelling only)
3. POST /v1/generate/<flow>            → { projectId, status: "GENERATING" }
4. GET  /v1/projects/{id}              → poll every 3–5 seconds
5. GET  /v1/projects/{id}/download     → MP4 (ZIP for carousels) when status is READY
```

Rate limit on generate POSTs: **10 requests per minute** per IP. Other v1 GETs use the global IP limit.

Recommended poll interval: 3 to 5 seconds. Jobs can take from about 30 seconds to several minutes (queue + TTS + FFmpeg, or AI video).

## 3. Endpoints

### POST `/v1/generate/storytelling`

Creates a project and starts the pipeline: background video + spoken story + karaoke captions.

Provide **either** `story` (final voiceover script) **or** `prompt` (ClipMaxxing writes the script with the LLM, billed separately). Provide **either** `libraryVideoId` **or** `videoUrl` **or** `brandId` for the B-roll.

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `story` | string | one of story/prompt | | Final voiceover script. |
| `prompt` | string | one of story/prompt | | Brief, 3–4096 characters. Ignored if `story` is set. |
| `libraryVideoId` | cuid | one of libraryVideoId/videoUrl/brandId | | Id from `GET /v1/library/videos`. |
| `videoUrl` | URL | one of libraryVideoId/videoUrl/brandId | | Direct HTTPS MP4. ClipMaxxing downloads it in a stream. Do not send both this and `libraryVideoId`. |
| `brandId` | cuid | one of libraryVideoId/videoUrl/brandId | | Template id. Inherits kit + default B-roll tags. |
| `lang` | enum | no | `fr` | `fr`, `en`, `es`, `de`, `it`, `pt` |
| `targetDurationSec` | int | no | `60` | `10`, `15`, `30`, `45`, `60`, `90` |
| `title` | string | no | | Max 200 characters. |
| `voiceId` | string | no | language default | Id from `GET /v1/voices`. |
| `captionStyle` | enum | no | `yellow-highlight` | See caption styles below. |
| `captionPosition` | enum | no | `bottom` | `top`, `center`, `bottom`, `none` |
| `storyPreset` | enum | no | `standard` | `standard` or `horror` |
| `libraryMusicId` | cuid | no | | Optional bed music from the music library. |

Success `200`:

```json
{ "projectId": "clxxxxxxxxxxxxxxxxxxxxxxxxx", "status": "GENERATING" }
```

### POST `/v1/generate/text-scene`

Same B-roll source rules as storytelling (`libraryVideoId` **or** `videoUrl`, `story` **or** `prompt`). No voiceover. Overlay text on the clip.

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `story` / `prompt` | string | one of them | | Overlay copy. Keep it short (best under 400 characters). |
| `libraryVideoId` / `videoUrl` / `brandId` | | one of them | | Same as storytelling. |
| `lang` | enum | no | `fr` | |
| `targetDurationSec` | int | no | `60` | Same duration set as storytelling. |
| `title` | string | no | | |
| `captionPosition` | enum | no | `bottom` | |
| `libraryMusicId` | cuid | no | | |

Response: same `{ projectId, status }`.

### POST `/v1/generate/video`

AI video. No background clip required. Default: silent 9:16 540p text-to-video.

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `prompt` | string | yes | | 3–5000 characters. |
| `lang` | enum | no | `en` | |
| `targetDurationSec` | int | no | `10` | Integer from **4 to 15**. |
| `title` | string | no | | |
| `generationSettings` | object | no | see below | |

Default `generationSettings`:

```json
{
  "mode": "text-to-video",
  "quality": "540p",
  "aspectRatio": "9:16",
  "generateAudio": false,
  "multiClip": false,
  "imageIds": []
}
```

`mode` values: `text-to-video`, `image-to-video`, `reference-to-video`.  
`quality`: `540p` or `720p`.  
`aspectRatio`: `16:9` or `9:16` (required for text-to-video and reference-to-video).  
Public v1 does not upload reference images. Stay on `text-to-video` unless you already have `imageIds` from the website.

Response: same `{ projectId, status }`.

### POST `/v1/generate/carousel`

Photo carousel (swipeable image post). No background clip, no voiceover. The deliverable is a **ZIP of cards**, not an MP4.

This is the endpoint to **replay a template with a different brief**: pass `brandId` plus the brief, and the template supplies the language, the number of images and the visual template. Anything you pass in the body wins over the template.

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `brief` | string | yes | | What the carousel is about, 12–5000 characters. Internal instruction: it is never published as the caption. |
| `brandId` | cuid | no | | Template id. Supplies `lang`, `slideCount`, `referenceImageId`, the kit and the publishing accounts. |
| `lang` | enum | no | template, else `fr` | `fr`, `en`, `es`, `de`, `it`, `pt` |
| `title` | string | no | | Max 200 characters. |
| `slideCount` | int | no | template, else auto | Integer from **2 to 10**. Auto lets the model pick 4 to 8. |
| `referenceImageId` | cuid | no | template | Visual template photo. Ids come from the website upload. |

Response: same `{ projectId, status }`. `GET /v1/projects/{id}/download` then returns `application/zip`.

### POST `/v1/generate/carousel-manual`

Same ZIP of vertical cards as `/v1/generate/carousel`, but **you write each card**. No LLM plans the text. Each slide is 50% photo / 50% HTML (sanitized: no scripts, no images, no `url()` in CSS).

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `slides` | object[] | yes | | 2–10 cards. See below. |
| `brandId` | cuid | no | | Template id. Supplies `lang`, visual template photo, and the accent color used on the category chip. |
| `lang` | enum | no | template, else `fr` | `fr`, `en`, `es`, `de`, `it`, `pt` |
| `title` | string | no | | Max 200 characters. |
| `referenceImageId` | cuid | no | template | Visual template photo for AI slides. Ids come from `POST /images`. |

Each `slides[]` item:

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `html` | string | yes | Card layout you write. Tags: `p br hr strong b em i u s small mark sub sup ul ol li h1` to `h6`, `span div section article header footer aside main blockquote table thead tbody tfoot tr th td caption`. Inline `style` may set layout and type (`display` / flex / grid / `position` except `fixed`, sizes, padding, colors hex/`rgb`/`rgba`/`hsl`). No scripts, images, `url()`, `content`, or event handlers. Max 4000 characters. |
| `imagePrompt` | string | one of | English prompt for the photo (min 3 chars). Required unless `sourceImageId` is set. |
| `sourceImageId` | cuid | one of | Uploaded JPEG/PNG/WebP (`POST /images`). Mutually exclusive with `imagePrompt`. |
| `category` | string | no | Plain text, max 32. Drawn as a chip on the right, on the photo/text split. Color comes from the template accent. |

Response: same `{ projectId, status }`. Download is `application/zip`. Billing uses the same `CREDIT_CAROUSEL_IMAGE` as the classic carousel, only for slides without an upload.

### POST `/v1/generate/carousel-custom`

Same ZIP of vertical cards, but **you write the whole card layout**, not just its text half. Each card is your HTML rendered at 1080×2400 (9:20, mobile). Exactly one element must carry `id="placeholder"`: the generated (or uploaded) photo is inserted into it, scaled to cover the slot, and cropped to that slot's rendered shape (`border-radius`, including a circle, or `clip-path`). Flat margins on the source are trimmed first, so a square photo does not leave pale bars inside the visible area. No `src` is ever accepted from you.

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `slides` | object[] | yes | | 2–10 cards. See below. |
| `brandId` | cuid | no | | Template id. Supplies `lang` and the visual template photo. |
| `lang` | enum | no | template, else `fr` | `fr`, `en`, `es`, `de`, `it`, `pt` |
| `title` | string | no | | Max 200 characters. |
| `referenceImageId` | cuid | no | template | Visual template photo. Ids come from `POST /images`. |

Each `slides[]` item:

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `html` | string | yes | Full card layout. Same tag and inline-style allowlist as `carousel-manual`, plus `figure figcaption dl dt dd` and gradient `background-image`. The only attributes kept are the filtered `style` and a single `id="placeholder"`. No scripts, images, `url()`, or event handlers. Max 12000 characters. |
| `imagePrompt` | string | one of | English prompt for the photo (min 3 chars). Required unless `sourceImageId` is set. |
| `imageAspect` | enum | no | `9:16` (default), `3:4`, `1:1`, `4:3`, `16:9`. Pick the ratio closest to your slot. |
| `sourceImageId` | cuid | one of | Uploaded JPEG/PNG/WebP (`POST /images`). Mutually exclusive with `imagePrompt`. |

Fonts available in the card: `Montserrat`, `Inter`, `Anton`, `Playfair Display`, `Space Mono`. Keep text clear of the TikTok UI: the top 400 px, the bottom 560 px and ~160 px on each side.

Response: same `{ projectId, status }`. Download is `application/zip`. Billing uses the same `CREDIT_CAROUSEL_IMAGE` as the classic carousel, only for slides without an upload.

### GET `/v1/projects?limit=10`

Lists the caller's latest projects, newest first. `limit` is an integer from 1 to 10 (default 10).

```json
[
  {
    "id": "clxxx...",
    "title": "My short",
    "videoStyle": "storytelling",
    "status": "READY",
    "createdAt": "2026-08-23T12:00:00.000Z",
    "downloadUrl": "https://clipmaxxing.app/api/v1/projects/clxxx.../download"
  }
]
```

`downloadUrl` is `null` until the latest version is `READY`. Prefer `GET /v1/projects/{id}/download` with the same `x-api-key` header.

### GET `/v1/projects/:id`

```json
{
  "id": "clxxx...",
  "status": "GENERATING",
  "steps": [
    { "label": "Script", "status": "DONE" },
    { "label": "Voice", "status": "RUNNING" },
    { "label": "Video ready", "status": "PENDING" }
  ],
  "queuePosition": 1,
  "downloadUrl": null,
  "error": null
}
```

- `status` on the project: `DRAFT`, `GENERATING`, `READY`, `FAILED`.
- Each step `status`: `PENDING`, `RUNNING`, `DONE`, `FAILED`.
- `queuePosition` is `0` unless the job is waiting in the queue (`QUEUED`). Then it is 1-based.
- `error` is a string when the latest version failed, otherwise `null`.
- Stop polling when `status` is `READY` (download) or `FAILED` (read `error`). Do not retry a failed project automatically without a new generate call.

### GET `/v1/projects/:id/download`

Streams the MP4 of the latest **READY** version.

- Headers: `x-api-key` (required).
- Response: `Content-Type: video/mp4`, `Content-Disposition: attachment`.
- `404` if the project is missing, not yours, or not ready yet.

### GET `/v1/library/videos`

Published B-roll clips you can pass as `libraryVideoId`.

```json
{
  "enabled": true,
  "videos": [
    { "id": "clxxx...", "title": "City night", "durationSec": 12.4, "tags": ["urban"] }
  ]
}
```

If `enabled` is `false`, the library is not configured on that instance. Use `videoUrl` instead.

### GET `/v1/voices`

Voice catalog for `voiceId` (storytelling). Each item: `{ id, name, languages }` where `languages` is a subset of `fr`, `en`, `es`, `de`, `it`, `pt`.

### GET `/v1/brands`

Templates of the key's user. Response is a **JSON array** (not wrapped). The webhook secret is never returned (`webhookConfigured: true|false` instead). Each template includes attached accounts (`id`, `platform`, `alias`, `label`, `state`).

Pass `brandId` on generate endpoints to inherit voice, captions, illustration style, B-roll tags, and narration tone. The applied kit is **frozen** on the `ProjectVersion` (`brandKit`). Replaying a version does not re-read the current template. Editing a template does not change jobs already generated.

### GET `/v1/brands/:id`

One template, same object as a list item. `404` if the id is unknown or belongs to another user.

### POST `/v1/brands`

Creates a template. Rate limit: **10 requests per minute** per IP.

Only `name` is required. Everything else is optional and can be filled later with PATCH. Linking YouTube / TikTok / Instagram accounts is done on the website (OAuth). The `accounts` array on the response is empty until then.

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `name` | string | yes | | 1–60 characters. Unique per user. `409` if taken. |
| `color` | hex | no | `#6750A4` | Accent, `#RRGGBB`. |
| `sortOrder` | int | no | `0` | 0–999. List order. |
| `description` | string | no | | Max 500 characters. |
| `lang` | enum | no | | `fr`, `en`, `es`, `de`, `it`, `pt`. |
| `videoStyle` | enum | no | | `storytelling`, `text-scene`, `video-generation`, `carousel`, `carousel_manual`, `carousel_custom`. |
| `storyPreset` | enum | no | | `standard` or `horror` (storytelling script only). |
| `targetDurationSec` | int | no | | `10`, `15`, `30`, `45`, `60`, `90`. |
| `voiceId` | string | no | | Id from `GET /v1/voices`. |
| `captionStyle` | enum | no | | See caption styles on generate. |
| `captionPosition` | enum | no | | `top`, `center`, `bottom`, `none`. |
| `promptAddon` | string | no | | Brand voice clause injected into the LLM. Max 2000. Stay **positive**. |
| `illustrationStyle` | string | no | | Positive image-style suffix. Max 400. |
| `slidesDirectives` | string | no | | Max 500. |
| `palette` | hex[] | no | | Up to 8 `#RRGGBB`. |
| `settingHint` | string | no | | Time of day, weather, light. Max 220. |
| `cast` | object[] | no | | Up to 8 `{ name, lock }`. |
| `titleTemplate` | string | no | | Publish title. `{title}` is replaced. Max 100. |
| `captionTemplate` | string | no | | Publish caption. `{story}` is a short excerpt. Max 2200. |
| `hashtags` | string | no | | Appended to the caption. Max 400. |
| `carouselSlideCount` | int or null | no | | 2–10. `null` clears: Auto (4–8). |
| `carouselReferenceImageId` | cuid or null | no | | Visual template photo. Upload with `POST /images` (same `x-api-key`, multipart field `file`). `null` clears. `404` if the image is not yours. |
| `reviewRequired` | bool | no | `true` | |
| `libraryVideoTags` | string[] | no | `[]` | Up to 12 tags. B-roll fallback when generate has no clip. |
| `defaultLibraryVideoId` | cuid or null | no | | Preferred library clip. Id from `GET /v1/library/videos`. |
| `musicCatalogId` | string or null | no | | Built-in bed music catalog id. |
| `sourceAudioId` | cuid or null | no | | User-uploaded bed. |
| `webhookUrl` | URL or null | no | | See webhooks below. Max 500. |
| `webhookSecret` | string or null | no | | Write-only, 8–128 characters. Never returned. |

Success `200`: the created template (same shape as `GET /v1/brands/:id`).

### PATCH `/v1/brands/:id`

Partial update. Send at least one field (same keys as POST). Empty body is `400`. Rate limit: **10 / minute**. `404` if unknown. `409` if the new `name` is taken.

Changing the kit does **not** rewrite `brandKit` on versions already generated.

### DELETE `/v1/brands/:id`

Deletes the template. Linked social accounts are not deleted: they become ungrouped. Existing projects keep their frozen kit. `404` if unknown. Returns `{ "ok": true }`.

### GET `/v1/review`

Projects in `READY` + `reviewStatus: PENDING`. Filter later client-side by `brandName`.

### POST `/v1/projects/:id/approve`

Body (all optional): `{ "mode": "queue", "scheduledAt": "...", "accountIds": ["..."] }`.

Default `mode` is `queue` (next posting slot). `now` publishes immediately (TikTok still drops in the inbox unless Direct Post is enabled). Omit `accountIds` to use every **ACTIVE** YouTube / TikTok / Instagram account on the project's template.

Title and caption come from the frozen kit templates (`{title}`, `{story}`, hashtags).

### POST `/v1/projects/:id/reject`

Marks the project `REJECTED`. It leaves the review queue. Does not delete the MP4.

### POST `/v1/posts`

Same body as the website composer: `{ versionId, targets: [{ accountId }], mode, title?, caption?, scheduledAt? }`. `mode`: `now` | `queue` | `at` | `draft`.

### POST `/v1/generate/batch`

`{ "brandId": "...", "prompts": ["...", "..."] }` (1 to 10 prompts). Each prompt starts a storytelling job with the template kit and B-roll. Returns `{ projectIds, status: "GENERATING" }`. Stops on the first failure (credits, missing B-roll).

### POST `/v1/ingest`

Finished MP4, no AI pipeline. `{ "brandId": "...", "videoUrl": "https://...", "title": "optional" }`. Creates a `READY` project in the review queue. Used by Discord `/drop`.

### GET `/v1/inbox`

TikTok drops waiting in the creator inbox (`uploadToInbox`). Open the TikTok app to publish. Public TikTok posting is not automated.

### GET `/v1/projects?limit=10&brandId=&review=`

`review` is `PENDING` | `APPROVED` | `REJECTED`. `brandId` filters by template.

## 3b. Webhooks (per template)

Set `webhookUrl` (and optionally `webhookSecret`) on the template. ClipMaxxing POSTs JSON:

```json
{
  "event": "project.ready",
  "projectId": "clxxx...",
  "brandId": "clxxx...",
  "brandName": "Horror FR",
  "title": "...",
  "status": "READY",
  "reviewStatus": "PENDING",
  "downloadUrl": "https://clipmaxxing.app/api/v1/projects/clxxx.../download"
}
```

Events: `project.ready`, `project.failed`, `project.approved`, `project.rejected`.

If a secret is set, header `X-ClipMaxxing-Signature: sha256=<hex>` (HMAC-SHA256 of the raw body). A failing webhook never fails the pipeline.

Point `webhookUrl` at a Discord incoming webhook for a ping in `#review-horror`, or at your own bot. **Never put `CLIPMAXXING_API_KEY` in a Discord message.**

## 3c. Discord bot (`apps/discord`)

Two audiences, one process. Env: `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` (OAuth), `DISCORD_BOT_SECRET` (API impersonation), `CLIPMAXXING_API_URL`, `CLIPMAXXING_API_KEY` (editorial only). Optional: `DISCORD_STAFF_GUILD_ID`, `DISCORD_ADMIN_IDS`. Run `npm run dev:discord`.

Link Discord on **Account → Connect Discord**. The bot DMs you; if that is blocked, open a DM and type `/start`. Credits are billed to the linked ClipMaxxing user (`x-discord-bot-secret` + `x-discord-user-id`). Never put `CLIPMAXXING_API_KEY` in a Discord message.

### Client commands (DM / user install)

| Command | Action |
| --- | --- |
| `/start` | Wizard: voice-over short, text on B-roll, or AI video |
| `/short flow: text:` | One-shot generate (optional video / image attachments) |
| `/status` | Latest projects and credit balance |
| `/cancel` | Abort the wizard (not a running worker job) |

When the render is ready, the bot attaches the MP4 (if small) or a 1-hour download link (`/d/:token`).

### Editorial commands

With `DISCORD_STAFF_GUILD_ID` they are guild-only. `CLIPMAXXING_API_KEY` is still the editorial auth; staff does not need to link Discord.

| Command | Action |
| --- | --- |
| `/generate template: prompt:` | Storytelling with the template kit |
| `/drop template: video:` | Ingest a finished MP4 into review |
| `/review` | Queue + Approve / Reject buttons |
| `/schedule template:` | Approve latest pending into the posting queue |
| `/inbox` | TikTok inbox drops |
| `/templates` | List templates |
| `/lines` | Alias of `/templates` |

TikTok public posting is never automated. Approve still drops to the TikTok inbox.

## 4. Caption styles

For `captionStyle` on storytelling:

- `yellow-highlight` (default)
- `kinetic`
- `neon-outline`
- `boxed`
- `karaoke`
- `red-marker`
- `mono-terminal`
- `luxury-serif`
- `impact-stroke`

`captionPosition`: `top`, `center`, `bottom` (default), `none`.

## 5. Credits and errors

Every generation holds credits on the user wallet (same as the website), then settles the real cost when the pipeline finishes. If the balance is too low, the call fails **before** the job is queued.

Error body (all endpoints):

```json
{ "error": "Human-readable message" }
```

| HTTP | Meaning |
| --- | --- |
| `400` | Invalid JSON / Zod validation / missing source or story. |
| `401` | Missing, unknown, or revoked API key. |
| `402` | Not enough credits. Body may include `required`, `balance`, `code`. |
| `404` | Project, clip, template, or file not found. |
| `409` | A generation is already running, or a template already has that name. |
| `422` | LLM refused the prompt (policy). |
| `429` | Rate limit. Wait about one minute. |
| `502` | Upstream (download, library cache, model) failed. Retry later. |
| `503` | Queue, billing, library, or provider not configured. |

Validation messages look like `field : message` plus an `issues` array.

## 6. Copy-paste examples

Replace `BASE` and `KEY`.

### Storytelling from a library clip + prompt

```bash
curl -sS -X POST "$BASE/v1/generate/storytelling" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A 60-second story about a baker who finds a golden coin in the dough.",
    "libraryVideoId": "REPLACE_WITH_CLIP_ID",
    "lang": "en",
    "targetDurationSec": 60
  }'
```

### Storytelling from a public MP4 URL + finished script

```bash
curl -sS -X POST "$BASE/v1/generate/storytelling" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "story": "You will not believe what happened in that bakery tonight. ...",
    "videoUrl": "https://example.com/broll.mp4",
    "lang": "fr",
    "targetDurationSec": 45,
    "voiceId": "REPLACE_WITH_VOICE_ID",
    "captionStyle": "karaoke"
  }'
```

### Poll then download

```bash
PROJECT_ID="clxxx..."

while true; do
  BODY=$(curl -sS -H "x-api-key: $KEY" "$BASE/v1/projects/$PROJECT_ID")
  STATUS=$(printf '%s' "$BODY" | sed -n 's/.*"status":"\([^"]*\)".*/\1/p' | head -n1)
  echo "$BODY"
  if [ "$STATUS" = "READY" ]; then
    curl -sS -H "x-api-key: $KEY" \
      -o "short.mp4" \
      "$BASE/v1/projects/$PROJECT_ID/download"
    break
  fi
  if [ "$STATUS" = "FAILED" ]; then
    echo "Generation failed"
    exit 1
  fi
  sleep 4
done
```

### Text-scene

```bash
curl -sS -X POST "$BASE/v1/generate/text-scene" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "story": "Open tonight. Last table.",
    "libraryVideoId": "REPLACE_WITH_CLIP_ID",
    "targetDurationSec": 10,
    "lang": "en"
  }'
```

### Create a template then generate a carousel

```bash
BRAND=$(curl -sS -X POST "$BASE/v1/brands" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Breakfast carousel",
    "lang": "en",
    "videoStyle": "carousel",
    "carouselSlideCount": 5,
    "titleTemplate": "{title}",
    "captionTemplate": "{story}",
    "hashtags": "#breakfast #highprotein"
  }')
echo "$BRAND"
BRAND_ID=$(printf '%s' "$BRAND" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -n1)

curl -sS -X POST "$BASE/v1/generate/carousel" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"brandId\": \"$BRAND_ID\",
    \"brief\": \"5 quick high-protein breakfasts, one dish photo per card with the macros under it\"
  }"
```

`PATCH /v1/brands/$BRAND_ID` with `{ "carouselSlideCount": 6 }` updates later jobs only.

To set a visual template photo, upload first (`POST /images`, multipart `file`), then PATCH `carouselReferenceImageId` with the returned `id`.

### Carousel from a template (same template, new brief)

```bash
curl -sS -X POST "$BASE/v1/generate/carousel" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "brandId": "REPLACE_WITH_TEMPLATE_ID",
    "brief": "5 quick high-protein breakfasts, one dish photo per card with the macros under it"
  }'
```

The same call with another `brief` reuses the template's visual template, image count and language.

### Manual carousel (HTML per card)

```bash
curl -sS -X POST "$BASE/v1/generate/carousel-manual" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "brandId": "REPLACE_WITH_TEMPLATE_ID",
    "lang": "en",
    "slides": [
      {
        "html": "<h1>Overnight oats</h1><p>150 g oats</p>",
        "imagePrompt": "overhead photo of overnight oats in a glass jar",
        "category": "Breakfast"
      },
      {
        "html": "<h1>Eggs</h1><p>2 eggs, 1 tsp oil</p>",
        "imagePrompt": "sunny-side eggs in a small skillet"
      }
    ]
  }'
```

`imagePrompt` and `sourceImageId` are mutually exclusive per slide. The template `color` is the category chip background.

### Custom carousel (full card layout)

```bash
curl -sS -X POST "$BASE/v1/generate/carousel-custom" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "lang": "en",
    "slides": [
      {
        "html": "<div style=\"display:flex;flex-direction:column;height:100%;background-color:#101018\"><div id=\"placeholder\" style=\"height:1400px\"></div><h1 style=\"font-family:Montserrat;font-size:86px;color:#fff;padding:0 160px\">Overnight oats</h1></div>",
        "imagePrompt": "overhead photo of overnight oats in a glass jar",
        "imageAspect": "1:1"
      },
      {
        "html": "<section style=\"position:relative;height:100%\"><div id=\"placeholder\" style=\"position:absolute;inset:0\"></div><p style=\"position:absolute;bottom:560px;left:160px;font-size:48px;color:#fff\">2 eggs, 1 tsp oil</p></section>",
        "imagePrompt": "sunny-side eggs in a small skillet"
      }
    ]
  }'
```

The photo goes inside `id="placeholder"`; anything else you put in that element stays on top of it.

### AI video (text-to-video)

```bash
curl -sS -X POST "$BASE/v1/generate/video" \
  -H "x-api-key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Silent vertical shot of rain on neon pavement, cinematic, loopable, no text",
    "lang": "en",
    "targetDurationSec": 8
  }'
```

## 7. Integration notes for AIs and bots

- Treat this file as the source of truth. Do not invent extra v1 endpoints.
- One-shot generate endpoints already create the project and enqueue the worker. Do **not** call the website's `/projects` then `/projects/:id/generate` from a bot.
- Always send `x-api-key` on the download request. A bare URL without the header returns `401`.
- `videoUrl` must be a publicly reachable MP4 (h264/hevc/vp8/vp9/av1/mpeg4). Huge files may fail the instance upload cap.
- If you pass `prompt` without `story`, ClipMaxxing bills a story-generation LLM call first, then the pipeline.
- Horror preset (`storyPreset: "horror"`) only applies to storytelling script generation. No gore.
- `brandId` on generate endpoints inherits the template kit (voice, captions, illustration style, B-roll tags). The applied kit is frozen on the version.
- Provide **either** `libraryVideoId` **or** `videoUrl` **or** `brandId` (brand B-roll from default clip / library tags).
- `GET /v1/brands`, `GET /v1/brands/:id`, `POST /v1/brands`, `PATCH /v1/brands/:id`, `DELETE /v1/brands/:id`, `GET /v1/review`, `POST /v1/projects/:id/approve`, `POST /v1/projects/:id/reject`, `POST /v1/posts`, `POST /v1/generate/batch`, `POST /v1/ingest`, `GET /v1/inbox` exist for bots. `GET /v1/brands` returns a raw array. Do not call the website's `/account-groups` from a bot.
- Approve queues YouTube / Instagram and drops TikTok in the inbox. There is no public TikTok post without Direct Post audit.
- Illustration prompts stay **positive**. Do not send “no comic strip” style negatives; z-image has no `negative_prompt`.
- Projects expire after the product retention window (default 5 days on the website). Download promptly.
- Do not store the API key in client-side Discord messages. Use a server-side bot token store.

## 8. Key management (website only, not v1)

These routes require a logged-in browser session, not `x-api-key`:

- `GET /api-keys` : list `{ id, name, prefix, createdAt, lastUsedAt }`
- `POST /api-keys` `{ "name": "Discord bot" }` : returns `{ id, key, prefix }` once
- `DELETE /api-keys/:id` : revoke (soft)

Key format: `cmx_` + 48 lowercase hex characters. Prefix (first 8 characters) is what the profile UI shows.
