# Hubfluencer API — agent cheat-sheet

Base URL: `$HUBFLUENCER_BASE_URL` (default `https://hubfluencer.com`), all paths under `/api`.

## Auth

Every protected call needs `Authorization: Bearer <token>`. The token is **opaque** (a session
token or a Personal Access Token) — **not a JWT**, do not decode it.

```bash
H="authorization: Bearer $HUBFLUENCER_API_TOKEN"
JSON="content-type: application/json"
```

### Personal Access Tokens (recommended for agents)

```
POST   /api/tokens         {name?, scopes?}   -> 201 { data: { id, name, scopes, token, inserted_at } }
GET    /api/tokens                            -> 200 { data: [ {id, name, scopes, last_used_at, inserted_at} ] }
DELETE /api/tokens/:id                         -> 204
```

- `token` is returned **once**. Default `scopes`: `["video:generate","video:read"]`.
- Managing tokens needs `account:admin` (so an agent token cannot create/revoke tokens — create
  one while signed in to the app). `["*"]` in a listing = a full-access (unscoped) token.

## Credits

```
GET /api/studio/credits   -> { data: { credits: <int>, ... } }
```
A short = **15 credits**. Editor autopilot cost: `GET /api/editor/:slug/autopilot/cost`.

## Shorts (single-prompt → rendered short)

A short is a 12s vertical (two 6s AI segments + poster text overlay + music; 14s when an end-card
poster image is set). Brand it on create — don't ship a bare clip.

```
POST /api/shorts                  {product_prompt, ...creative fields below}            -> 201 { data: <Short> }
GET  /api/shorts/:slug                                                                  -> 200 { data: <Short> }
GET  /api/shorts/:slug/cost                                                             -> { data: { total, available_credits } }
POST /api/shorts/:slug/generate   (Idempotency-Key header recommended)                  -> 200 { data: <Short> }
POST /api/shorts/:slug/text/generate                                                     -> 200 { data: <Short> }  [1 AI assist, no credits]
POST /api/shorts/:slug/product/presign  {mime_type, size_bytes}  -> { data: { presigned_url, s3_key } }
POST /api/shorts/:slug/product/confirm  {s3_key, product_description?}                   -> 200 { data: <Short> }
POST /api/shorts/:slug/poster/presign   {content_type?}          -> { upload_url, s3_key, content_type }
POST /api/shorts/:slug/poster/confirm   {s3_key}                                         -> 200 { data: <Short> }
```

`product_prompt` ≥ 10 chars. `create` + `generate` accept an `Idempotency-Key` header — send a
stable key (e.g. `gen-short:<slug>`) so a retried request doesn't double-charge.

**Creative fields on `POST /api/shorts` (all optional — populate them for a branded short):**

| field | meaning |
|---|---|
| `headline` (≤160) | the on-screen **title** (poster text overlay) |
| `subheadline` (≤200) | the **secondary title** / supporting line |
| `creative_format` | optional structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal. Omit for Auto |
| `visual_language` | visual direction + render look: kinetic_creator, premium_editorial, cinematic_product, ugc_realism, startup_explainer, luxury_minimal |
| `theme` | **deprecated for shorts**; legacy fallback only when `visual_language` is unset |
| `music_vibe` (≤80) | Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz |
| `music_instruments` | array of instrument hints, e.g. `["piano","strings"]` |
| `short_text_position` | top / center / **bottom** |
| `short_text_animation` | reveal / typewriter / **fade_in** / pop / bounce |
| `short_font_family` | ShortFontSpaceGrotesk (default), …Montserrat, …Impact, …Anton, …BebasNeue, …Oswald, …Poppins, …Inter, …TikTokSans, …Bangers, …TheBold, …Lato, …ArchivoBlack, …DMSerif, …PermanentMarker |

All creative fields are also settable later via `PATCH /api/shorts/:slug`.
`POST /api/shorts/:slug/text/generate` consumes 1 daily AI assist and fills editable `headline`, `subheadline`, and `text_beats`; it does not spend video credits or start a render.

**Branding images** (0 credits, jpeg + png only, ≤ 20 MB): the **product** (woven into the footage)
and the **end-card poster** (a closing still that extends the render to 14s) each use a
`presign → PUT raw bytes → confirm` flow (see *Uploads & local assets*). Product presign returns the
editor-style `{ data: { presigned_url, s3_key } }`; poster presign returns `{ upload_url, s3_key }`.
On the single PUT, send `Content-Type: <the presigned mime>`. Shorts have **no logo overlay** — that's
an editor-only feature.

`<Short>.stage` is the poll signal:

| stage | meaning |
|---|---|
| `draft` | created, not generating |
| `rendering` | in progress — keep polling |
| `video_ready` | **done** → read `latest_render.video_url` |
| `failed` | read `failed_stage` + `error_message` |

## Sliders (single-prompt → image carousel)

A slider is a social-media **carousel**: one prompt produces N still slides (an AI background +
a composited headline/body + optional logo) plus a ready-to-post caption and hashtags. No video —
the user saves the images and copies the text. Editing slide text or the template re-renders for
free (0 credits).

```
POST   /api/sliders                {prompt, language?, mode?, template?, slide_count?, aspect_ratio?, accent_color?, text_position?} -> 201 { data: <Slider> }
GET    /api/sliders                                                                       -> 200 { data: [<Slider>] }
GET    /api/sliders/:slug                                                                 -> 200 { data: <Slider> }
GET    /api/sliders/:slug/cost                                                            -> { data: { total, available_credits } }
PATCH  /api/sliders/:slug          {prompt?/language?/mode?/template?/slide_count?/aspect_ratio?/accent_color?/text_position?/caption?/hashtags?} -> 200 { data: <Slider> }
POST   /api/sliders/:slug/generate (Idempotency-Key header recommended)                   -> 200 { data: <Slider> }   (1 credit/slide)
PATCH  /api/sliders/:slug/slides/:position  {headline?, body?, kicker?}  (0 credits, re-renders that slide) -> 200 { data: <Slider> }
POST   /api/sliders/:slug/restyle  {template?, accent_color?, text_position?, logo_s3_key?}  (0 credits, re-renders all slides) -> 200 { data: <Slider> }
POST   /api/sliders/:slug/logo/presign  {mime_type, size_bytes}  -> { data: { presigned_url, s3_key } }
POST   /api/sliders/:slug/logo/confirm  {s3_key}                                          -> 200 { data: <Slider> }
DELETE /api/sliders/:slug                                                                 -> 204
```

`prompt` is optional at create (a draft) but required (≥10 chars) before `generate`. `generate`
costs **1 credit per slide** — so **3–10 credits** (default 5 slides = 5 credits) — and accepts an
`Idempotency-Key` header: send a **fresh** key per attempt (a stable per-slug key would replay
the first response for 24h and silently block an intentional re-generate). The prompt is
screened for content-policy compliance first — a violating prompt returns **422** `prompt_rejected`
(code `CONTENT_COMPLIANCE`, with a `category`) and is **not** charged; a 503 `compliance_unavailable`
means screening was down (fail-closed, nothing charged — retry). Render-shaping fields (`language`, `mode`, `slide_count`, `aspect_ratio`,
`accent_color`, `text_position`, `template`, `logo_s3_key`) — and the `prompt` — are editable via `PATCH` only while
the slider is a `draft` **or** `failed` (a failed render reopens them so you can fix it and
re-generate); once a render is `processing`/`completed`, `PATCH` silently ignores them and accepts
only the copy fields (`caption`, `hashtags`). The `prompt` is locked post-generation because it was
content-screened on the way out of draft. To change template/accent/logo on a **completed** slider,
use `restyle` (it re-composites for free); to change slide copy, use the per-slide `PATCH`.

**Field constraints:**

| field | constraint |
|---|---|
| `prompt` | string, ≤2000 chars; ≥10 (non-blank) required before `generate` |
| `language` | `en` (default) / `fr` / `es` / `de` / `it` / `pt` / `nl` / `pl` — language of the generated copy. Latin-script only. |
| `mode` | `creative` (storytelling) or `ad_driven` (product facts/benefits). Default creative. |
| `template` | `boldStatement` / `editorialStory` / `scrapbook` (creative); `featureGrid` / `offerCard` / `comparison` (ad-driven). Defaults to the mode default. |
| `slide_count` | integer 3–10 (default 5) |
| `aspect_ratio` | `4:5` (default) / `1:1` / `9:16` |
| `accent_color` | hex matching `^#[0-9a-fA-F]{6}$`, e.g. `#09EFBE` |
| `text_position` | `top` / `middle` / `bottom` — vertical placement of the on-image copy. Omit for the template's natural placement. |
| `caption` | string, ≤3000 chars |
| `hashtags` | array of strings |
| per-slide `headline` | string, ≤120 chars |
| per-slide `body` | string, ≤600 chars |
| per-slide `kicker` | string, ≤40 chars — the small eyebrow/label line shown **above** the headline |

**Free re-composite (0 credits).** Both editing endpoints require the slider to already be
`completed` (else **409**) and reuse the AI backgrounds — no new image is paid for:

- `PATCH /api/sliders/:slug/slides/:position` rewrites one slide's `headline`/`body`/`kicker` and
  re-renders **just that slide**.
- `POST /api/sliders/:slug/restyle` accepts `template`, `accent_color`, `text_position`, **and**
  `logo_s3_key`, and re-composites **every** slide. Note: confirming a logo (`/logo/confirm`) on an
  already-`completed` carousel routes through `restyle`, so it likewise triggers a **full**
  re-composite of every slide (free).

Both run **async**: they flip the affected slide(s) back to `processing`, so poll
`GET /api/sliders/:slug` until `status` is `completed` again (or `failed`).

**Rate limits:** `generate` is **10/min**; `restyle` and the per-slide edit are **20/min** each.

`<Slider>.status` is the poll signal — poll `GET /api/sliders/:slug` until it is `completed` or
`failed`:

| status | meaning |
|---|---|
| `draft` | created, not generating |
| `processing` | in progress — keep polling |
| `completed` | **done** → each `slides[].image_url` is ready; read `caption` + `hashtags` |
| `failed` | read `error_message` (credits are refunded) |

Each `slides[]` entry: `{ position, headline, body, kicker, status, image_url, background_url }`.
Per-slide `status` is `pending → processing → completed | failed` (note: a slide that hasn't been
rendered yet is `pending`, **not** `draft`). `image_url` is the final composited, downloadable
slide still (presigned, **24h** TTL); `background_url` is the raw AI background preview (presigned,
**1h** TTL — useful for an instant preview before the text composites). Download every slide, copy
the `caption` and `hashtags`, and post them as a carousel.

## Editor ads (multi-scene, autopilot)

```
POST /api/editor                       {language, product_prompt, creative_format?, visual_language?, theme?, export_aspect_ratio?}  -> 201 { data: <Editor> }
POST /api/editor/:slug/autopilot       (Idempotency-Key header recommended)                      -> 202 { data: <Editor> }
GET  /api/editor/:slug                                                                            -> 200 { data: <Editor> }
GET  /api/editor/:slug/autopilot/cost                                                             -> { data: { total, ... , available_credits } }
```

`language` is required; `product_prompt` ≥ 10 chars (it's the brief autopilot needs).
Optional creative controls (same value lists as Shorts): `creative_format` (narrative arc —
problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal),
`visual_language` (render look — kinetic_creator, premium_editorial, cinematic_product,
ugc_realism, startup_explainer, luxury_minimal), and `theme` (genre overlay; when
`visual_language` is set it drives the look and a `theme` of `none` is ignored).
Poll `GET /api/editor/:slug` and watch:

- `autopilot_status`: `running` → keep polling; `completed` → done; `failed`/`cancelled` → stop
  (read `autopilot_error_message`). While `running`, segment mutations (add scene, add from
  upload) return **409 `autopilot_active`** — autopilot owns the timeline; wait for it to finish.
- `latest_render`: when present with `status: "completed"`, `latest_render.video_url` is the
  finished MP4 (embedded right in this payload — no second call needed).

## Granular editor pipeline (step-by-step, no autopilot)

Same editor project, driven by hand. Reads need `video:read`; anything that generates or spends
credits needs `video:generate`. Each row is labelled **free** (no credit, no assist),
**assist** (1 AI assist), or **credits**.

```
POST   /api/editor                                  {language, product_prompt?, creative_format?, visual_language?, theme?, voice_id?, export_aspect_ratio?, project_intent?}  -> create, 0cr   [free]  (free/0-credit accounts capped at 100 projects -> 402 editor_factory_limit_reached)
GET    /api/editor/:slug                                                                                                                  -> full state    [free, video:read]
PATCH  /api/editor/:slug                            {narration_script?, target_duration_seconds?, creative_format?, visual_language?, theme?}  -> 0cr         [free]
POST   /api/editor/:slug/generate-scenario          {segments_count? 3..10, creative_format?, visual_language?, theme?}                    -> 0cr            [assist]
PATCH  /api/editor/:slug/scenario                   {scenario_prompt} 1..50000  (style fields NOT accepted here)                          -> 0cr            [free]
POST   /api/editor/:slug/apply-scenario             {segments_count ∈ {0,3,5,7,10}}                                                       -> 0cr            [free]
POST   /api/editor/:slug/segments                   {prompt} 1..2000                                                                      -> add, 0cr       [free]
PATCH  /api/editor/:slug/segments/:id               {prompt} 1..2000                                                                      -> 0cr            [free]
DELETE /api/editor/:slug/segments/:id                                                                                                     -> 0cr            [free]
PUT    /api/editor/:slug/segments/reorder           {order:[ids]}                                                                         -> 0cr            [free]
POST   /api/editor/:slug/segments/:id/generate      (Idempotency-Key recommended)                                                        -> 5cr            [credits, video:generate]
POST   /api/editor/:slug/segments/:id/regenerate    (Idempotency-Key recommended)                                                        -> 4cr            [credits, video:generate]
POST   /api/editor/:slug/batch-generate             (Idempotency-Key recommended)                                                        -> 4cr/seg (≥3)   [credits, video:generate]
POST   /api/editor/:slug/generate-narration                                                                                              -> 0cr            [assist] (free of CREDITS, NOT of quota)
POST   /api/editor/:slug/generate-voice             {voice_id} required, ^[A-Za-z0-9_-]+$, ≤64  (Idempotency-Key recommended)            -> 3cr            [credits, video:generate]
POST   /api/editor/:slug/generate-music             {prompt} ≤1200 (direction; silently truncated)  (Idempotency-Key recommended)        -> 5cr            [credits, video:generate]
POST   /api/editor/:slug/render                     (Idempotency-Key recommended)                                                        -> 0cr*           [credits, video:generate]
POST   /api/editor/:slug/segments/:id/enhance-prompt                                                                                     -> 0cr            [assist]
POST   /api/editor/:slug/suggest-next-scene                                                                                              -> 0cr            [assist]
POST   /api/editor/:slug/suggest-music-prompt                                                                                            -> 0cr            [assist]
```

\* render auto-charges only any still-ungenerated scenes (5cr each), so a fully-generated project
renders for 0.

Scene prompts are screened for content-policy compliance at every paid generation entry
(`generate`, `regenerate`, `batch-generate`, and `render`'s auto-charge): a violating prompt
returns **422** `{error: "prompt_rejected", code: "CONTENT_COMPLIANCE", category}` with nothing
charged. If screening is temporarily unavailable the call returns **503** `compliance_unavailable`
(fail-closed — nothing charged; retry shortly).

`segments_count` on `generate-scenario` is a **continuous 3..10** range; on `apply-scenario` it is an
**enum** `{0,3,5,7,10}` (422 `invalid_segments_count` otherwise). Scene count grows to **20** max via
adding segments. `narration_script` caps at **12000** chars (422 over). `product_prompt` on create is
**empty OR 10..5000**.

**Scene duration:** every **AI-generated** segment renders to a **fixed 8 seconds** — there's no
per-scene duration field, so an all-AI ad runs `scene_count × 8s` (e.g. 5 scenes ≈ 40s). To change the
total length, change the scene count (`set_scene_count` / `segments_count`), not the duration. An
**uploaded** clip keeps its own native length instead (≤ 5 min — see Uploads below).

## Uploads & local assets (bring your own footage / images)

Editor projects can use the agent's **own local media** — video clips dropped onto the timeline, or a
product / closing / logo image. All of it needs `video:generate` and costs **0 credits**. Every flow is
the same shape: **presign → PUT raw bytes → confirm**. Video then processes asynchronously and must
reach `status: "ready"` before you can place it.

> **The one rule that breaks raw `curl`:** on a **single-object** presign (small video + every image)
> the PUT **must** send `Content-Type: <the exact mime you presigned>` — confirm HEADs the object and
> 422s on a mismatch. On a **multipart part** PUT you must send **NO `Content-Type` header at all** —
> the part URL doesn't sign one, and an extra header breaks the S3 signature. Let your HTTP client set
> `Content-Length`; don't hand-roll it.

### Video — small files (single PUT)

```
POST /api/editor/:slug/uploads/presign   {filename, mime_type, size_bytes, fit_mode?, product_description?, checksum_sha256?}
                                          -> { data: {upload_id, presigned_url, s3_key, expires_in_seconds} }
                                          -> 422 {error:"upload_quota_exceeded", quota:{limit/used/reserved/remaining bytes}}  (≤500MB/file + per-user storage quota; delete unused uploads to free space)
PUT  <presigned_url>    (raw file bytes, header  Content-Type: <mime_type>)            -> 200
POST /api/editor/:slug/uploads/:upload_id/confirm                                      -> { data: <Upload> }   (queues processing)
GET  /api/editor/:slug/uploads                 -> { data: [<Upload>] }    poll until YOURS has status:"ready"
```

### Video — large files (resumable multipart, recommended ≳ 50 MB or flaky networks)

```
POST /api/editor/:slug/uploads/multipart/init       {filename, mime_type, size_bytes, fit_mode?, product_description?}
                                                    -> { data: {upload_id, s3_upload_id, s3_key, parts_count, part_size} }
# for each part_number in 1..parts_count:
POST /api/editor/:slug/uploads/multipart/sign-part  {upload_id, s3_upload_id, part_number}
                                                    -> { data: {presigned_url, part_number} }
PUT  <presigned_url>   (bytes [(n-1)*part_size .. min(n*part_size, size)], NO Content-Type)  -> capture the response ETag header
POST /api/editor/:slug/uploads/multipart/complete   {upload_id, s3_upload_id, parts:[{part_number, etag}, ...]}  -> { data: <Upload> }
# on ANY failure mid-upload, clean up the dangling S3 upload:
POST /api/editor/:slug/uploads/multipart/abort      {upload_id, s3_upload_id}
```

`part_size` and `parts_count` come back from `init` — slice the file by `part_size` (the last part is
the remainder). Sort `parts` ascending by `part_number` before `complete`. `part_number` is 1-based
(1..10000). `complete` runs the same validation + processing as the single-PUT `confirm`.

### Then place the ready upload on the timeline

```
POST /api/editor/:slug/segments/from-upload            {upload_id}     -> 201 (new scene appended)
POST /api/editor/:slug/segments/:segment_id/use-asset  {upload_id}     -> 200 (sets THAT scene's video)
```

`use-asset` also accepts `{source_segment_id}` to reuse a completed scene's video — send **exactly
one** of `upload_id` / `source_segment_id`. A not-yet-ready upload → **422 `editor_upload_not_ready`**;
placing while a batch is generating → **409 `batch_generation_active`** (wait and retry).

### Product / closing / logo images (jpeg + png only, ≤ 20 MB)

Same `presign → PUT (with Content-Type) → confirm`, but **confirm takes the returned `s3_key`**:

```
POST /api/editor/:slug/product/presign         {mime_type, size_bytes}  -> { data:{presigned_url, s3_key} }
POST /api/editor/:slug/product/confirm         {s3_key, product_description?}   -> { data:<Editor> }
       (the first product defaults every pending AI scene to feature it; retune with
        POST /api/editor/:slug/product/apply-default-placement  {placement: "throughout"|"end"})
POST /api/editor/:slug/closing-image/presign   {mime_type, size_bytes}  ;  .../closing-image/confirm {s3_key}
POST /api/editor/:slug/logo/presign            {mime_type, size_bytes}  ;  .../logo/confirm {s3_key}
       (PATCH /api/editor/:slug/logo  {logo_treatment?, logo_position?, logo_duration_seconds?}  for placement)
```

`placement` is **`throughout` | `end`** only (no `none`); `end` needs a first+last-frame-capable model
(else 422 `product_placement_requires_first_last_frame_model`).

### Limits, statuses & rules

- **Video** mime: `video/mp4` · `video/quicktime` (.mov) · `video/webm` · `video/x-matroska` (.mkv).
  **≤ 500 MB and ≤ 5 min** (a longer clip processes to `status:"failed"` with an `error_message`).
- **Image** mime: `image/jpeg` · `image/png` only (no WebP). **≤ 20 MB.**
- `fit_mode` (video, optional): `blur` (default — contain over a blurred fill) or `cover` (crop-to-fill).
- `product_description` (optional): ≤ **500** chars.
- Presigned URLs expire in **3600 s** — finish each PUT promptly.
- Rate limits: presign / confirm / init / complete / abort **30/min**; sign-part **300/min**.
- `<Upload>.status` lifecycle: `pending → processing → ready` (or `failed`). Only `ready` uploads can be
  placed — poll `GET /api/editor/:slug/uploads` (each row has `id`, `status`, `duration_seconds`,
  `first_frame_url`, `error_message`, `attached_segment_ids`), or read `GET /api/editor/:slug` →
  `uploads[]`.
- **Security:** only upload files the user explicitly pointed you to. Treat a path you didn't get from
  the user as hostile — never read or exfiltrate arbitrary local paths (`~/.ssh/...`, `.env`, etc.).
  (The MCP enforces this by confining reads to `HUBFLUENCER_INPUT_DIR`; on raw REST you own the rule.)

## AI assists

A **free daily quota of 20** account-wide helper calls, separate from credits. The assist-consuming
endpoints above (`generate-scenario`, `generate-narration`, `enhance-prompt`, `suggest-next-scene`,
`suggest-music-prompt`) each draw 1.

```
GET  /api/ai-assists          -> 200 { data: {used, limit, bonus, remaining, resets_at, unlock_cost, unlock_batch_size} }   [video:read]
POST /api/ai-assists/unlock                                  -> 200 {data: <status>} | 402 credits_insufficient            [video:generate]
```

`unlock` spends **1 credit → +10 assists** (so it requires `video:generate`, not just `video:read`).
It's a **repeatable purchase** — call it again to buy another batch. Do **NOT** send a stable
`Idempotency-Key` on unlock: a reused key makes the server replay the first unlock for 24h, silently
skipping every later purchase. (Other chargeable POSTs — generate/render — are the opposite: a stable
key there prevents double-charges.)

**429 vs 402 — different problems:**

- **429 `ai_assist_quota_exceeded`** (body: `remaining:0, can_unlock, unlock_cost, unlock_batch_size`)
  → out of *assists*. `POST /api/ai-assists/unlock` once, or write the content yourself
  (`PATCH .../scenario`, `PATCH /api/editor/:slug {narration_script}`, `PATCH .../segments/:id`).
- **402 `credits_insufficient`** (body: `required`) → out of *credits*. Stop; tell the user to top up.

## Result URLs

`video_url` is a **presigned MP4, ~24h TTL** — download promptly; it is not a permalink.

## Error taxonomy

| HTTP | body `error` | action |
|---|---|---|
| 401 | `Unauthorized` | bad/expired/missing token — re-auth |
| 402 | `credits_insufficient` | top up; body has `required`/`credits` — stop, don't loop |
| 403 | `insufficient_scope` | token lacks the scope (e.g. `video:generate`) |
| 409 | `*_in_progress`, `autopilot_already_running`, `autopilot_active` | already running — keep polling, don't re-POST |
| 429 | `ai_assist_quota_exceeded` | daily AI-assist quota used up — `POST /api/ai-assists/unlock` once or write content yourself; **not** a credit error |
| 422 | `scenario_prompt_required` | brief too short — set product_prompt ≥ 10 chars |
| 422 | `{errors: {field: [msgs]}}` | validation — fix fields |

> Response envelopes aren't fully uniform: successes are usually `{data: ...}` (a few endpoints
> return the object at top level), and error shapes vary (`{error}`, `{error,message}`,
> `{errors:{...}}`). Parse defensively.

## Polling guidance

Poll every ~15s. Generation takes a few minutes (autopilot can run longer). Treat a
still-`rendering`/`running` project as healthy; only give up at a stated max wait (~10 min) and
report the last status. Publishing to social platforms needs a human-linked account — return the
MP4 + a suggested caption instead of auto-posting.
