REST API reference

The Hubfluencer API.

Everything an agent (or your own code) needs to turn a prompt — or your own footage — into a finished MP4. Base URL is hubfluencer.com, all paths sit under /api, and you authenticate with a Bearer token.

Overview & authentication

Send every protected request with an Authorization header. The token is opaque (a session token or a Personal Access Token) — it is not a JWT, so don't decode it.

authorization: Bearer <token>
content-type: application/json

Scopes gate what a token can do: video:read for reads, video:generate for anything that spends credits, account:admin for account/token management. Agent tokens get video:generate + video:read only.

Prefer docs your agent can read directly? Download the Markdown mirror of this page: API.md . A machine-readable OpenAPI spec also lives at /api/openapi (Bearer-gated in production).

Response envelope

// Success — usually wrapped in `data` (a few endpoints return the object at top level)
{ "data": { "...": "..." }, "error": null, "meta": { "timestamp": "2026-06-03T10:00:00Z" } }

// Error — shapes vary; parse defensively
{ "error": "credits_insufficient", "message": "Not enough credits.", "required_credits": 15, "available_credits": 3 }
{ "errors": { "product_prompt": ["should be at least 10 character(s)"] } }

Access tokens

Personal Access Tokens are the recommended credential for agents. The token string is returned only once on creation. Managing tokens needs account:admin — so an agent token can't mint or revoke tokens; create one while signed in to the app (Settings → Access tokens), or run npx -y @hubfluencer/mcp@0.20.0 login.

POST /api/tokens account:admin

Create a Personal Access Token.

Body field Type Notes
name optional string Label shown in the app.
scopes optional string[] Defaults to video:generate + video:read.

Request

curl -X POST https://hubfluencer.com/api/tokens \
  -H "authorization: Bearer $SESSION_TOKEN" \
  -H "content-type: application/json" \
  -d '{"name":"Claude Code","scopes":["video:generate","video:read"]}'

Response

201 Created
{
  "data": {
    "id": 42,
    "name": "Claude Code",
    "scopes": ["video:generate", "video:read"],
    "token": "hf_pat_9f3c…ONCE_ONLY",
    "inserted_at": "2026-06-03T10:00:00Z"
  }
}
GET /api/tokens account:admin

List your tokens. A ["*"] scope listing means a full-access token.

200 OK
{
  "data": [
    { "id": 42, "name": "Claude Code",
      "scopes": ["video:generate","video:read"],
      "last_used_at": "2026-06-03T10:05:00Z",
      "inserted_at": "2026-06-03T10:00:00Z" }
  ]
}
DELETE /api/tokens/:id account:admin

Revoke a token. Returns 204 No Content.

Credits & voices

GET /api/studio/credits video:read

Current credit balance. Check it before generating; a short costs 15 credits, and a credit is $0.99 (less in bulk). Quote the dollar cost and get the user's go-ahead before any charging call.

200 OK
{ "data": { "credits": 120 } }
POST /api/studio/agent/turns video:generate AI-assist quota

Run one cloud Director turn for the local-first macOS Studio. Send messages, client-owned tool schemas, and an explicitly disclosed compact context (project digest, optional transcript, at most four validated 2 MB / 768 px frames). The response streams turn_start, text, tool_use, turn_end, and stream_error SSE events as they are produced; tools execute on the Mac and paid tools still require in-app human approval. Send a stable Idempotency-Key for safe retries. A turn reserves one daily AI assist before the provider call and never spends video credits. Set quality to deep for the separately configured deep-reasoning model.

POST /api/studio/agent/turns
Idempotency-Key: studio-turn:<stable-turn-id>
{"messages":[{"role":"user","content":"Tighten the intro"}],"tools":[...],"context":{"digest":{...}},"quality":"standard","max_tokens":4096}

event: tool_use
data: {"type":"tool_use","name":"ripple_delete_range","input":{"start":0,"end":1.8}}
GET /api/voices video:read

Narration voices. Each voice includes verified_languages with language/accent/locale-specific previews; choose one verified for the editor project's language and pass its voice_id to generate-voice. Shorts have no voice-over.

200 OK
{
  "data": {
    "voices": [
      { "voice_id": "rachel", "name": "Rachel",
        "verified_languages": [
          { "language": "en", "accent": "american", "locale": "en-US",
            "preview_url": "https://…/rachel-en.mp3" }
        ] }
    ],
    "has_more": false,
    "total_count": 1,
    "next_page_token": null
  }
}

Generation runs & recovery

Editor and Short product responses are the normal read surface. These endpoints inspect one authorized run or execute an exact needs-input action already advertised by generation_summary.available_actions. Never invent a recovery action from product projection fields. Re-read the product immediately before a command and echo its run_id, revision, and action step_key; a stale fence fails without touching newer work.

GET /api/generation-runs/:run_id video:read

Inspect one server-owned run by UUID. Returns {data:{generation_summary}} using the same canonical schema embedded in product reads. Ownership is checked after scope authorization; an unknown or foreign run returns 404, so a UUID is never a bearer capability.

Method & path Advertised action and body
POST /api/editor/:slug/generation/input edit_required_input — generation_run_id, generation_revision, step_key, input
POST /api/shorts/:slug/generation/input edit_required_input — generation_run_id, generation_revision, step_key, input
POST /api/editor/:slug/generation/spend-cap review_spend_cap — generation_run_id, generation_revision, step_key, max_credits (1..1000000)
POST /api/shorts/:slug/generation/spend-cap review_spend_cap — generation_run_id, generation_revision, step_key, max_credits (1..1000000)

All four recovery commands require video:generate and an Idempotency-Key. Reuse one key only for transport retries of the same fenced body; a deliberate new approval/input command gets a fresh key. The input object must be non-empty. A spend-cap approval is append-only and does not rewrite the frozen run policy. Success returns {data:{generation_summary}}; re-read it before the next action. A missing key returns 422 idempotency_key_required.

POST /api/editor/:slug/generation/input
Idempotency-Key: recovery:<stable-command-id>
{"generation_run_id":"<uuid>","generation_revision":7,"step_key":"brief","input":{"product_name":"Acme"}}

Shorts

A short is a single-prompt vertical ad composed from two connected clips. Three requests: create a draft (free), start generation (15 credits), then follow the required generation_summary until it stops advertising a polling cadence. A generation may pause at a visual-review gate between clips; invoke only the advertised recovery action to continue. Send an Idempotency-Key on generate so a retry doesn't double-charge.

Every Editor and Short product read includes a non-null generation_summary. Treat it as the sole lifecycle authority even when stage, autopilot_status, batch_generation_status, child statuses, or latest_render disagree. Poll only while generation_summary.timing.poll_after_ms is a positive integer, exactly at that server cadence; null means stop polling. Read terminality from generation_summary.status, invoke only generation_summary.available_actions, and download only when download_result is advertised for a verified delivery artifact. Product projection fields are display metadata or explicit route fences only; never derive polling, retry, cancel, or download availability from them.

POST /api/shorts 0 credits

Create a short draft.

Body field Type Notes
product_prompt required string ≥ 10 characters — what the ad is about.
language optional string Closed set of bare ISO-639-1 codes (default "en"): en, fr, es, de, it, nl, pl, pt, ru, zh, ja, ko, ar. Region/script subtags like "en-US" or "pt-BR" are rejected (422).
headline optional string On-screen TITLE overlay (≤80 hard cap; ≤40 chars ≈ 4 words is the render sweet spot). Leave headline, subheadline, AND text_beats blank to have generate write the copy for you (free).
subheadline optional string SECONDARY title / supporting line (≤200).
theme optional string Deprecated for shorts: legacy fallback only when visual_language is unset. Use visual_language for new shorts.
creative_format optional string Optional structure: problem_solution, mistake_fix, myth_vs_reality, before_after, proof_demo, product_reveal. Omit for Auto.
visual_language optional string Visual direction + render look: kinetic_creator, premium_editorial, cinematic_product, ugc_realism, startup_explainer, luxury_minimal. Bare creates (no visual_language, no theme) default to kinetic_creator; send an explicit null for the plain neutral look.
music_vibe optional string Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz. When unset, the soundtrack follows the visual_language (e.g. luxury_minimal → Luxury, cinematic_product → Cinematic).
short_text_position, short_text_animation, short_font_family, music_instruments optional string / array Overlay position (top/center/bottom). Animation: auto (default) / reveal / typewriter / fade_in / pop / bounce / word_stagger / word_spotlight. Font: auto (default) or one of the 15 bundled faces — ShortFontSpaceGrotesk, ShortFontMontserrat, ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontAnton, ShortFontBebasNeue, ShortFontOswald, ShortFontArchivoBlack, ShortFontPoppins, ShortFontInter, ShortFontTikTokSans, ShortFontBangers, ShortFontDMSerif, ShortFontPermanentMarker. "auto" follows the visual_language vibe's typography; every concrete value — font or animation, including ShortFontSpaceGrotesk / fade_in — is an EXPLICIT pick that always wins. music_instruments: up to 10 hints, each ≤ 40 chars.
headline_color, subheadline_color, accent_color, text_beats optional string / array Overlay colors (headline/subheadline accept #rgb–#rrggbbaa hex; accent_color is strictly 6-digit #rrggbb), and text_beats — up to 8 caption lines (≤120 chars each) shown one at a time in place of the static subheadline. On PATCH, send [] to clear the beats.
offer_text, cta_text, badge_text, star_rating optional string / number Conversion graphics: offer chip (≤16), CTA pill (≤24), badge stamp (≤24), and a 0–5 star rating. Offer/badge/stars are strictly user-supplied and OFF unless set — only claim what you can substantiate: badge_text and star_rating render as endorsements, so send a real rating/badge you actually hold, never an invented one. cta_text is the one exception: your value always wins, but when blank the render falls back to your brand profile's default_cta, then a fixed claim-free localized preset ("Shop now" for product formats, "Learn more" otherwise) — a neutral localized ask is ALWAYS rendered; a truly CTA-less short is not currently supported. Read the resolved value back as resolved_cta_text. On PATCH, send "" (or 0 for star_rating) to remove one.
closing_claim, brand_name, end_card, poster_includes_lockup optional string / boolean End-card controls. By default every short closes on a 2s end card (14s total): your poster image, or — when none is set — a generated brand lockup slate (brand_name big, closing_claim or the headline, CTA pill). closing_claim (≤80): a rephrase of the promise so the end card doesn't recycle the hook; the copy assist may fill it. brand_name (≤40): display name (falls back to your brand profile's name). end_card: "auto" (default) or "none" for a 12s loop-style short with no end card. poster_includes_lockup (boolean, also accepted on poster/confirm): set true when your poster already carries its own name/CTA so the overlay is suppressed. The derived mode is returned as end_card_mode (poster | lockup | none).
brand_profile_id optional integer Id of one of your own brand profiles (GET /api/brand-profiles). On create, the brand's defaults (creative format, visual language, font, CTA, accent color) seed any blank short fields and a snapshot is frozen onto the short — the fastest way to brand a short. A foreign/unknown id → 422 invalid_brand_profile.

Request

curl -X POST https://hubfluencer.com/api/shorts \
  -H "authorization: Bearer $HF" -H "content-type: application/json" \
  -d '{"product_prompt":"a 15s ad for my soy candle brand","language":"en","creative_format":"proof_demo","visual_language":"kinetic_creator"}'

Response

201 Created
{
  "data": {
    "slug": "amber-candle-9x2",
    "stage": "draft",
    "generation_summary": {
      "run_id": null, "workflow": null, "workflow_version": null, "revision": null,
      "status": "idle", "settling": false, "phase": "idle", "label": "Ready to generate",
      "progress": {"completed": 0, "total": 0, "percent": 0}, "current_step": null,
      "blockers": [],
      "available_actions": [{"type":"start_generation","label":"Start generation","destructive":false,"step_key":null}],
      "failure": null,
      "cost": {"quoted_credits":0,"charged_credits":0,"refunded_credits":0,"net_credits":0,"max_credits":0},
      "timing": {"started_at":null,"updated_at":null,"deadline_at":null,"poll_after_ms":null}
    },
    "language": "en",
    "latest_render": null,
    "inserted_at": "2026-06-03T10:00:00Z"
  }
}
POST /api/shorts/:slug/text/generate 1 AI assist

Generate editable headline, subheadline, and caption beats from the saved short draft (it also fills blank brand_name / closing_claim — extraction and rephrase only, never new claims). No video credits are spent and no render starts. You usually don't need this before generating: generate auto-writes blank copy for free.

curl -X POST https://hubfluencer.com/api/shorts/amber-candle-9x2/text/generate \
  -H "authorization: Bearer $HF"
POST /api/shorts/:slug/generate 15 credits video:generate

Render the short. Idempotent per slug. Then poll GET /api/shorts/:slug. Auto-copy: when headline, subheadline, AND text_beats are all blank, the server writes the overlay copy inline first (free — it never touches the daily AI-assist quota) and persists it BEFORE charging, so retries never regenerate it; pass {"skip_auto_text": true} in the body for a deliberately text-free clip. If the copy generator is down → 503 copy_generation_unavailable, nothing charged — just retry. Auto-describe: a product image attached without a description is described inline at generate time (also free — the standalone product-description/generate endpoint stays metered) so the AI segments carry the product grounding; a describer failure just proceeds without one. The prompt (including auto-written copy) is content-compliance screened BEFORE any charge: a prohibited prompt → 422 prompt_rejected (code CONTENT_COMPLIANCE, with a category); screening down → 503 compliance_unavailable (fail-closed) — neither is charged. Out of credits → 402 credits_insufficient with required_credits + available_credits.

curl -X POST https://hubfluencer.com/api/shorts/amber-candle-9x2/generate \
  -H "authorization: Bearer $HF" \
  -H "idempotency-key: gen-short:amber-candle-9x2"
POST /api/shorts/:slug/rerender 0 credits video:generate

Re-render is FREE, forever — credits only ever buy new AI footage. Re-composites the short's CURRENT overlay/style state over the already-generated (pinned) segments + music: edit headline, subheadline, text_beats, cta_text, offer_text, badge_text, star_rating, colors, short_font_family, short_text_position, short_text_animation, accent_color, closing_claim, brand_name, poster (+ poster_includes_lockup), logo overlay (image via /logo/confirm, position, treatment), or end_card via PATCH, then call this (no body). Returns the same Short payload as generate — follow generation_summary on GET /api/shorts/:slug. Unchanged composition → the existing render is returned as-is (idempotent). The pinned music bed is reused even when an end-card edit changed the runtime — a shorter bed crossfades into a repeated continuation and fades at the export end instead of failing the render. Footage/music-affecting fields (prompt, product image, creative_format, visual_language, theme, music_vibe, music_instruments, language) need the paid generate instead; changing one returns 422 short_paid_regeneration_required. A FAILED free re-render never breaks the delivered short: the canonical summary reports the failed attempt while the product projection stage stays video_ready, latest_render falls back to the latest completed render, and last_free_rerender_failed: true is set (latest_render_free flags any newest free re-render row). Re-read the summary and invoke only an advertised action. 409 short_generation_in_progress while any work is active; 422 short_not_ready unless the short has its 2 completed segments, completed music, and a completed render. Never 402.

# edit any free field first (0 credits), then re-render — free, no body
curl -X PATCH https://hubfluencer.com/api/shorts/amber-candle-9x2 \
  -H "authorization: Bearer $HF" -H "content-type: application/json" \
  -d '{"cta_text": "Order today"}'
curl -X POST https://hubfluencer.com/api/shorts/amber-candle-9x2/rerender \
  -H "authorization: Bearer $HF" \
  -H "idempotency-key: rerender-short:amber-candle-9x2:v2"
GET /api/shorts/:slug video:read

Read the required generation_summary. Poll only while timing.poll_after_ms is a positive integer and use that exact server cadence; null means stop. status owns terminality, blockers/progress/failure/cost come from the summary, and only available_actions may enable retry, cancel, or download. download_result is advertised only for a verified ready delivery artifact. generation_summary.run_id is the explicit route fence echoed to /cancel. Product projection fields such as stage, child statuses, segments_completed / segments_total / active_segment_positions, latest_render, and last_free_rerender_failed are display data only; they never override the summary.

200 OK  — poll only while generation_summary.timing.poll_after_ms is positive
{
  "data": {
    "slug": "amber-candle-9x2",
    "generation_summary": {
      "run_id": "5d379ddd-6687-41cb-a1be-d2684875335f",
      "workflow": "short.generation", "workflow_version": 1, "revision": 14,
      "status": "completed", "settling": false, "phase": "completed", "label": "Video ready",
      "progress": {"completed": 4, "total": 4, "percent": 100}, "current_step": null,
      "blockers": [],
      "available_actions": [{"type":"download_result","label":"Download video","destructive":false,"step_key":null}],
      "failure": null,
      "cost": {"quoted_credits":15,"charged_credits":15,"refunded_credits":0,"net_credits":15,"max_credits":15},
      "timing": {"started_at":"2026-06-03T10:00:00Z","updated_at":"2026-06-03T10:03:00Z","deadline_at":null,"poll_after_ms":null}
    },
    "stage": "video_ready",
    "failed_stage": null,
    "failure_code": null,
    "failure_details": null,
    "failure_source": null,
    "failure_attempt": null,
    "segments_completed": 2,
    "segments_total": 2,
    "active_segment_positions": [],
    "error_message": null,
    "latest_render": {
      "status": "completed",
      "video_url": "https://…/amber-candle-9x2.mp4?X-Amz-Expires=86400"
    }
  }
}
GET /api/shorts/:slug/cost video:read

Cost preflight — returns total and available_credits before you spend.

PATCH /api/shorts/:slug 0 credits video:generate

Edit a draft before rendering: any of the creative fields from POST /api/shorts (product_prompt, headline, subheadline, creative_format, visual_language, music_vibe, the opt-in conversion graphics, …). Present-aware — only the fields you send change. To REMOVE an opt-in graphic, send it empty: offer_text/cta_text/badge_text "", star_rating 0, text_beats []. Also accepts the AI-disclosure controls ai_disclosure_mode / ai_disclosure_position / ai_disclosure_ack (mode "none" requires the ack in the same request → else 422 ai_disclosure_ack_required; see AI disclosure & compliance). 0 credits, no render. While the short is generating (processing), edits are locked → 409 short_generation_in_progress; wait for it to finish or fail.

Branding images — product, logo & end-card poster

All are 0 credits and use a presign → PUT raw bytes → confirm flow. On the single PUT, send Content-Type matching the presigned mime. Product images accept jpeg/png up to 20 MB and are woven into generated footage. Logos accept jpeg/png up to 5 MiB and are composited deterministically by Forge, never sent to the video model. End-card posters additionally accept webp and are limited to 20 MB. A never-uploaded, oversized, or corrupt image is rejected at confirm (422).

Method & path Purpose
POST /api/shorts/:slug/product/presign · /product/confirm Attach a product image woven into the footage. presign {mime_type, size_bytes} → { data: { presigned_url, s3_key } }; confirm {s3_key, product_description?}.
POST /api/shorts/:slug/logo/presign · /logo/confirm Attach an exact deterministic logo overlay. presign {mime_type, size_bytes} → { data: { presigned_url, s3_key } }; confirm {s3_key, position?, treatment?}, where position is top-left|top-right|bottom-left|bottom-right. Omit treatment to inspect the image automatically: transparent marks default to throughout and opaque square lockups default to end_card; an explicit throughout|end_card|none wins.
POST /api/shorts/:slug/poster/presign · /poster/confirm Set the end-card poster (the closing still; without one the short falls back to the generated brand lockup). presign {content_type?} → { upload_url, s3_key }; confirm {s3_key, poster_includes_lockup?} — set poster_includes_lockup true when the poster already carries its own brand name/CTA so the claim/CTA overlay is suppressed.
POST /api/shorts/:slug/product/from-asset · /poster/from-asset Skip the upload — reuse a ready catalog image (asset_id) you own as the product or poster. Subscription-gated; 0 credits.
GET /api/shorts lists your in-progress/failed shorts in bounded pages. Add ?include_completed=true to also list finished ones (handy for recovering a slug), or ?include_drafts=true to also surface unrendered draft/editing shorts. Use ?page=1&per_page=25 (maximum 100); the response includes meta.page, meta.per_page, meta.total, and meta.total_pages.

Tracking overlay

Upload your own video and get it back with a computer-vision tracking overlay burned on top (convex-hull polygons hugging each moving subject, HUD brackets, a crosshair, and per-subject telemetry). Source clip must be 60s or shorter and 1080p or lower. Create a draft (free), set the source clip — either upload it via the editor upload routes with the tracking slug, or reuse a library video via /source/from-asset — then render (1 credit). The source clip is deleted once the render completes.

POST /api/tracking 0 credits

Create a tracking draft. Optional body: language. Returns the slug; upload the source clip via POST /api/editor/:slug/uploads/* (presign or multipart, then confirm) using the returned slug.

GET /api/tracking video:read

List your in-progress tracking projects (the Studio work-in-progress list) — finished ones aren't included. Each entry is the same shape as GET /api/tracking/:slug.

GET /api/tracking/:slug video:read

Poll the project. The payload carries source (the latest source upload) and result. source.status is pending → processing → ready (or failed); wait for "ready" before rendering, and treat a non-ready, non-failed source as still uploading/processing. Once rendered, read result.status ("completed") + result.video_url; on a failed render read result.error_message.

POST /api/tracking/:slug/source/from-asset 0 credits video:generate

Reuse a video already in your asset library as the source clip instead of uploading a new one. Body: asset_id (a ready catalog video asset you own). The asset object is copied into the project and normalized (your library asset is left untouched), so poll GET /api/tracking/:slug until source.status is "ready", then render. 422 asset_not_video / asset_not_ready if the asset isn't a ready video; 409 tracking_render_in_progress while a render is active.

POST /api/tracking/:slug/render 1 credit video:generate

Burn the overlay. Idempotent per slug (send an Idempotency-Key). Optional body: classes (COCO-80 ids to track, e.g. [0]=person, [16]=dog; omit to track all) and color ([r,g,b,a] overlay). An identical re-render returns the existing result for 0 credits. Single-flight: a render started while one is still processing returns 409 tracking_render_in_progress (no extra charge). Out of credits returns 402 credits_insufficient with required_credits + available_credits. Then poll GET /api/tracking/:slug. The source clip is deleted once the render completes (you keep the output); set a new source to render again.

DELETE /api/tracking/:slug

Delete a tracking project and its uploaded clip + result. Deleting mid-render refunds the 1 credit. Requires the video:generate scope (like all write methods).

Sliders (image carousels)

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 — save the images and copy the text. Create a draft (free), then generate (1 credit per slide ⇒ 3–10 credits; default 5 slides = 5 credits). Generation is async: poll GET /api/sliders/:slug until status is completed or failed. Editing slide text, or the template/accent/logo, re-renders for free.

Rate limits: generate 10/min; restyle and the per-slide edit 20/min each.
POST /api/sliders 0 credits

Create a carousel draft.

Body field Type Notes
prompt optional string What the carousel is about. ≤2000 chars. Optional at draft, required (≥10 non-blank chars) before generate.
mode optional string creative (storytelling) or ad_driven (product facts). Default creative.
template optional string boldStatement / editorialStory / scrapbook (creative); featureGrid / offerCard / comparison (ad-driven). Defaults to the mode default.
language optional string Language the generated slide copy + caption are written in: en (default) / fr / es / de / it / pt / nl / pl. Latin-script only.
slide_count, aspect_ratio, accent_color optional integer / string slide_count integer 3–10 (default 5); aspect 4:5 (default) / 1:1 / 9:16; accent a hex matching ^#[0-9a-fA-F]{6}$ like #09EFBE.
text_position optional string top / middle / bottom — vertical placement of the on-image copy across all slides. Omit to use the template's natural placement.
caption, hashtags optional string / array<string> Post copy: caption ≤3000 chars; hashtags an array of strings. Usually authored by generate, but editable via PATCH.
Per-slide on-image text (set/edited via the slide PATCH below): headline ≤120, body ≤600, kicker ≤40 chars. kicker is the small eyebrow/label line shown above the headline.
POST /api/sliders/:slug/generate 1 credit / slide (3–10) video:generate

Render the carousel. Costs 1 credit per slide ⇒ 3–10 credits (default 5 slides = 5 credits). Send a fresh Idempotency-Key per attempt (a stable per-slug key replays the first response for 24h and blocks 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. Rate-limited to 10/min. Then poll GET /api/sliders/:slug until status is completed or failed.

GET /api/sliders/:slug video:read

Poll for status until terminal: draft → processing → completed (each slides[].image_url is a downloadable still; read caption + hashtags) | failed (read error_message; credits refunded). Each slides[] entry is { position, headline, body, kicker, status, image_url, background_url }; per-slide status is pending → processing → completed | failed (a not-yet-rendered slide is pending, not draft). image_url is the final composited slide (presigned, 24h TTL); background_url is the raw AI background preview (presigned, 1h TTL).

PATCH /api/sliders/:slug/slides/:position 0 credits video:generate

Edit one slide's on-image text: headline (≤120), body (≤600), kicker (≤40). Re-composites just that slide for free (reuses the AI background — no new image cost). The slider must already be completed (else 409 conflict); it runs async, flipping the slide back to processing — poll GET /api/sliders/:slug until it is completed again. Rate-limited to 20/min.

POST /api/sliders/:slug/restyle 0 credits video:generate

Restyle a completed carousel: body accepts template, accent_color (^#[0-9a-fA-F]{6}$), text_position (top/middle/bottom), and logo_s3_key — and re-composites EVERY slide for free (reuses the AI backgrounds). The slider must already be completed (else 409). Runs async (every slide → processing); poll GET /api/sliders/:slug until completed. Rate-limited to 20/min. (Confirming a logo via /logo/confirm on an already-completed carousel routes through restyle, so it likewise triggers a full re-composite of every slide, free.)

GET /api/sliders

List your carousels (newest first). GET /api/sliders/:slug/cost returns total + available_credits. PATCH /api/sliders/:slug always edits the copy fields (caption, hashtags); the prompt and the render-shaping fields (language, mode, template, slide_count, aspect_ratio, accent_color, text_position, logo_s3_key) are editable here only while the slider is draft or failed (a failed render reopens them) — once processing/completed they are silently ignored (the prompt is locked post-generation; use restyle / the slide PATCH instead). DELETE /api/sliders/:slug removes a carousel and its images; it returns 409 slider_generation_in_progress while a render is in flight (a failed render refunds its own credits, so wait for the render to settle). POST /api/sliders/:slug/logo/presign then /logo/confirm attach an optional brand logo, or POST /api/sliders/:slug/logo/from-asset {asset_id} reuses a ready catalog image you own (subscription-gated, 0 credits).

Editor ads — autopilot

An editor ad is a multi-scene, story-driven video. Autopilot runs the whole pipeline server-side (scenario → scenes → narration → voice → music → render). Create the project with an exact 3–10 scene target, start autopilot, then follow generation_summary; download only when its available_actions advertises download_result. A 3-scene run is ~20 credits and a 5-scene run is ~28 (batch scene rate + voice + music); preflight the exact figure with GET /api/editor/:slug/autopilot/cost.

POST /api/editor/:slug/autopilot transparently selects the inverted topology editor.autopilot.voicefirst@1 when the project is in voice_first pacing (see Voice spine — pacing modes). Its order is scenario.compile → scenario.review → scenario.apply → narration → voice → pacing.derive → previews → scenes (generated at the derived durations) → scene reviews → music → render → delivery. The request and response contract is unchanged: the caller never picks a workflow, it only sets the pacing mode.

Every Editor and Short product read includes a non-null generation_summary. Treat it as the sole lifecycle authority even when stage, autopilot_status, batch_generation_status, child statuses, or latest_render disagree. Poll only while generation_summary.timing.poll_after_ms is a positive integer, exactly at that server cadence; null means stop polling. Read terminality from generation_summary.status, invoke only generation_summary.available_actions, and download only when download_result is advertised for a verified delivery artifact. Product projection fields are display metadata or explicit route fences only; never derive polling, retry, cancel, or download availability from them.

POST /api/editor 0 credits

Create an editor project. Free accounts (0 credits) may hold up to 100 editor projects — a 101st returns 402 editor_factory_limit_reached; delete one or add credits. Accounts with credits are uncapped. Send an Idempotency-Key header so a retry doesn't fork a second draft.

Body field Type Notes
language required string Supported code: en, fr, es, de, it, nl, pl, pt, ru, zh, ja, ko, or ar. Drives the WHOLE video: scenario text, narration script, narration voice, and captions.
product_prompt string Empty OR 10–5000 chars. Autopilot needs a usable brief: a structured scenario brief, product_prompt ≥10 chars, OR product_subject ≥3 chars.
product_subject optional string ≤ 300 chars. The concrete product or main subject the video must be about (e.g. "Bordeaux red wine, dark green bottle with a gold label") — distinct from the freeform product_prompt brief. When set, the AI scenario + narration are hard-grounded on it so they can't invent an unrelated product (the common failure mode for ad videos). Recommended for project_intent social_ad. Omit/blank to leave the AI free.
segments_count optional integer 3–10. Exact structural target for scenario, quote, and Autopilot launch. It does not create placeholder timeline rows.
export_aspect_ratio optional enum 9:16 (default), 16:9, or 1:1.
creative_format, visual_language optional enum Creative controls (same value lists as Shorts): narrative arc + render look. Omit for Auto.
theme, voice_id, project_intent optional string Visual theme / genre overlay (when visual_language is set it drives the look; "none" = no imposed style), narration voice, social_ad | creative_story.

Request (create, then start autopilot)

curl -X POST https://hubfluencer.com/api/editor \
  -H "authorization: Bearer $HF" -H "content-type: application/json" \
  -d '{"language":"en","product_prompt":"a cinematic ad for my candle brand","segments_count":3,"export_aspect_ratio":"9:16"}'
# then start autopilot:
ATTEMPT_ID="$(uuidgen)"
curl -X POST https://hubfluencer.com/api/editor/$SLUG/autopilot \
  -H "authorization: Bearer $HF" -H "idempotency-key: autopilot:$SLUG:$ATTEMPT_ID"
POST /api/editor/:slug/autopilot credits video:generate

Run the full pipeline. Send an Idempotency-Key: reuse it only for transport retries of this same logical launch, and mint a fresh key for a deliberate resume, restart, override change, or changed cap (successful responses replay for 24h under the old key). Preflight the cost with GET /api/editor/:slug/autopilot/cost using the exact launch overrides. Accepts an OPTIONAL JSON body: language (one of the supported project codes), product_subject (3–300 chars when non-blank), product_prompt (10–5000 chars when non-blank), segments_count (3–10 exact structural target), max_credits (non-negative integer), and restart (boolean). A structured scenario brief, product_prompt ≥10, or product_subject ≥3 satisfies the brief requirement. For a completed project, send restart=true to both the cost request and start body to retire the current AI timeline and build a fresh creative run; omit it to resume only pending edits. The cap is persisted for this run and atomically enforced again at every scene, voice, and music charge; if it would be exceeded, no further charge starts. Omit product_subject/product_prompt to leave them unchanged; send null or blank to clear either field atomically with launch. Empty query values quote those clears without persisting them.

Request (body optional — pre-launch overrides)

ATTEMPT_ID="$(uuidgen)"
curl -X POST https://hubfluencer.com/api/editor/$SLUG/autopilot \
  -H "authorization: Bearer $HF" -H "content-type: application/json" \
  -H "idempotency-key: autopilot:$SLUG:$ATTEMPT_ID" \
  -d '{"language":"fr","product_subject":"Bordeaux red wine, dark green bottle with a gold label","product_prompt":"a concise cinematic social ad for my wine","segments_count":3,"max_credits":20}'

Response (poll GET /api/editor/:slug)

200 OK  — generation_summary owns lifecycle, cadence, and action availability
{
  "data": {
    "slug": "candle-story-7k1",
    "generation_summary": {
      "run_id": "60d15a8b-b987-4412-838e-3441b89eeb6d",
      "workflow": "editor.autopilot", "workflow_version": 1, "revision": 42,
      "status": "completed", "settling": false, "phase": "completed", "label": "Video ready",
      "progress": {"completed": 8, "total": 8, "percent": 100}, "current_step": null,
      "blockers": [],
      "available_actions": [{"type":"download_result","label":"Download video","destructive":false,"step_key":null}],
      "failure": null,
      "cost": {"quoted_credits":20,"charged_credits":20,"refunded_credits":0,"net_credits":20,"max_credits":20},
      "timing": {"started_at":"2026-06-03T10:00:00Z","updated_at":"2026-06-03T10:08:00Z","deadline_at":null,"poll_after_ms":null}
    },
    "autopilot_status": "completed",
    "autopilot_contract_satisfied": true,
    "autopilot_error_message": null,
    "scenario_prompt": "Warm, handcrafted… ",
    "segments_count": 5,
    "segments": [
      { "id": 81, "position": 1, "prompt": "Macro of wax pouring…", "status": "completed" },
      { "id": 82, "position": 2, "prompt": "Hands trimming the wick…", "status": "completed" }
    ],
    "narration_script": "Made by hand, poured in small batches…",
    "narration_script_source": "manual",
    "narration_status": "ready",
    "current_audio": { "status": "completed" },
    "current_music": { "status": "completed" },
    "latest_render": {
      "status": "completed",
      "video_url": "https://…/candle-story-7k1.mp4?X-Amz-Expires=86400",
      "is_stale": false
    }
  }
}
generation_summary.status owns terminality, generation_summary.timing.poll_after_ms owns cadence, and generation_summary.available_actions owns every command. In particular, return a video only when download_result is advertised. autopilot_status, autopilot_contract_satisfied, and latest_render are display details and never override the summary.

Narration uses narration_status (none → generating → ready | failed). Production prompts are visual directions; a manually supplied narration_script is read verbatim and must contain only the exact spoken words. Selected generated media is current_audio and current_music — not audio/music — and each selected version is pending → processing → completed | failed. A completed latest_render may be historical after edits; treat it as current only when is_stale is false.

POST /api/editor/:slug/autopilot/cancel 0 credits video:generate

Invoke only when generation_summary.available_actions advertises cancel_generation. Idempotency-Key is required; reuse it only for transport retries of this exact observed run. Send {generation_run_id: generation_summary.run_id}. The fence prevents a delayed predecessor request from cancelling a successor. Stops only that observed chain while keeping already-charged and completed work; re-read generation_summary after a stale conflict.

Granular editor pipeline

Same editor project, driven step by step instead of autopilot — write the scenario, set the scene count, hand-write each scene prompt, then generate. Reads need video:read; anything that generates needs video:generate. Each row is free (no credit, no assist), assist (1 AI assist), or credits.

Method & path Body Cost
GET /api/editor/:slug — full project state free
PATCH /api/editor/:slug narration_script? (authoritative manual override; empty = silence), product_subject?, creative_format?, visual_language?, theme?, ai_disclosure_mode?, ai_disclosure_position?, ai_disclosure_ack? (required with mode "none" — see AI disclosure & compliance) free
POST /api/editor/:slug/generate-scenario segments_count? (3–10), product_prompt?, product_subject?, language?, creative_format?, visual_language?, theme? assist
PATCH /api/editor/:slug/scenario scenario_prompt (1–50000) — style fields not accepted here free
POST /api/editor/:slug/apply-scenario segments_count (0 or 3..10), language?, product_subject? free
POST /api/editor/:slug/segments prompt (1–2000) free
PATCH /api/editor/:slug/segments/:id prompt (1–2000), model, narration_text, use_original_audio — model and use_original_audio are writable on pending/failed AI scenes only (409 editor_segment_model_locked / editor_segment_audio_locked once a take exists; regenerate instead). An upload's use_original_audio stays writable. free
DELETE /api/editor/:slug/segments/:id free
PUT /api/editor/:slug/segments/reorder order: [ids] free
POST /api/editor/:slug/segments/:id/generate model?, generation_options? {duration_seconds, native_audio, reference_s3_keys}, max_generation_credits?, generation_quote_fingerprint? — render one scene; Idempotency-Key is required model-priced
POST /api/editor/:slug/segments/:id/retry generation_run_id, generation_revision, step_key — retry the exact failed Kernel scene run; Idempotency-Key is required advertised action price
POST /api/editor/:slug/segments/:id/cancel expected_generation_claim_token — echo the processing scene's token; Idempotency-Key is required; stop it and refund the paid attempt free
POST /api/editor/:slug/segments/:id/regenerate prompt? (1–2000), model?, generation_options? {duration_seconds, native_audio, reference_s3_keys}, max_generation_credits?, generation_quote_fingerprint?; Idempotency-Key is required model-priced
POST /api/editor/:slug/segments/:id/finalize model, max_credits, generation_quote_fingerprint (ALL required — a finalize always changes the model), generation_options? {duration_seconds, native_audio, reference_s3_keys}; Idempotency-Key is required. Re-shoots a draft-tier take on a standard or premium model, copying the take's prompt, reference_s3_keys, authored duration and native_audio setting verbatim. 422 editor_finalize_requires_draft_take when the current take is not draft-tier; 422 editor_finalize_target_tier_invalid when the target model is itself draft-tier. target model generation price
POST /api/editor/:slug/segments/:id/variants count (2–4), max_credits, generation_quote_fingerprint (all required), model?, generation_options? {duration_seconds, native_audio, reference_s3_keys}; Idempotency-Key is required. Generates N concurrent takes of the frontier scene, then parks on variant_select until you pick one. count × model price
POST /api/editor/:slug/segments/:group_id/versions/:version/select No body, no Idempotency-Key. Makes an earlier COMPLETED take of a scene current again — every regenerate and every variant seat is kept as a version of the same segment group, and this is how you go back to one. group_id is the scene's segment_group_id and version is that take's version number, both read from the segment. Free and unlimited: the pixels already exist. Only the editable frontier can switch takes — a scene a later scene was generated from answers 409 scene_locked, and a scene that is not the frontier answers 409 scene_not_frontier (restart from that scene instead). 409 editor_variant_selection_pending while a variants batch on that scene is still running: answer variant_select first. 409 incompatible_take_parent for a take generated from a different preceding scene, and 422 version_not_completed for one that never finished. free
POST /api/editor/:slug/segments/:id/references/presign mime_type (image/jpeg|image/png|image/webp), size_bytes (max 8MB). Returns presigned_url + s3_key for one per-scene IDENTITY reference image. Allowed on any AI scene, including one that already has a take: presigning writes nothing. An uploaded clip has no generation to condition, so it answers 422 editor_segment_references_unsupported. free
POST /api/editor/:slug/segments/:id/references/confirm s3_key. Appends the uploaded image to the scene's ordered reference_s3_keys and returns the updated segment. Idempotency-Key is required. Capped by the scene model's capabilities.reference_images_max. 409 editor_segment_references_locked once the scene has a take. free
POST /api/editor/:slug/segments/:id/references/from-asset asset_id (subscription-gated). Idempotency-Key is required. Returns {staged, s3_key, segment}. staged false: the catalog image was appended to reference_s3_keys. staged true: the scene already has a take, so nothing was written — carry the s3_key in generation_options.reference_s3_keys when you regenerate. Either way the resulting set is held to the model's reference_images_max, so a set that would not fit is 422 editor_reference_images_exceed_model_max here rather than at the paid regenerate. The catalog object is referenced, never copied. free
PUT /api/editor/:slug/segments/:id/references references: an ordered array of exactly-one-key objects, each {s3_key} or {asset_id}; [] clears. Idempotency-Key is required. Resolves ownership, validates actual JPEG/PNG/WebP bytes at max 8MB, enforces reference_images_max, then replaces the complete set in one row-locked write. Any invalid item leaves the old set untouched. Catalog asset items require a subscription. free
DELETE /api/editor/:slug/segments/:id/references/:index Requires expected_s3_key query parameter equal to the observed reference_s3_keys[index]. Idempotency-Key is required. Removes the 0-based reference slot and closes the gap, preserving the order of the rest. Storage is never deleted. A shifted/replaced slot is 409 editor_reference_slot_changed without a write; an index that names no slot is 404; a real index on a scene that already has a take is 409 editor_segment_references_locked. free
POST /api/editor/:slug/generation/variants/cancel generation_run_id, variant_batch_id (both required); Idempotency-Key is required. Takes not yet handed to the provider are refunded; a take already generating is not — it finishes and stays in the scene's take history. free
POST /api/editor/:slug/batch-generate max_generation_credits (REQUIRED — 422 editor_batch_credit_cap_required without it), generation_quote_fingerprint? — all pending scenes; Idempotency-Key is required model-priced
POST /api/editor/:slug/generate-narration — from the timeline; Idempotency-Key is required assist
POST /api/editor/:slug/segments/:id/regenerate-narration — one scene; Idempotency-Key is required assist
POST /api/editor/:slug/pacing mode: "scene_first" | "voice_first" — which side of the narration/duration contract is authoritative. No Idempotency-Key. 409 while any generation owns the project; 422 editor_pacing_mode_invalid for an unknown mode. free
POST /api/editor/:slug/voice-plan voice_id (required), style?, tone? — plan the voice spine on a voice_first project: narration → voice → pacing.derive. Idempotency-Key is required. The scenes do NOT have to be generated yet. 3 cr
POST /api/editor/:slug/generate-voice voice_id (required); Idempotency-Key is required; scene_first ONLY — a voice_first project returns 422 editor_pacing_plan_required and must use voice-plan 3 cr
POST /api/editor/:slug/generate-music prompt? (≤1200), mood?, genre?, tempo?, instruments? (string[≤10], each ≤80); Idempotency-Key is required 5 cr
POST /api/editor/:slug/render max_generation_credits?, generation_quote_fingerprint?, apply_visual_finish? — final MP4; Idempotency-Key is required 0 cr*
POST /api/editor/:slug/segments/:id/enhance-prompt assist
POST /api/editor/:slug/suggest-next-scene assist
POST /api/editor/:slug/suggest-music-prompt assist

* render auto-charges every still-missing scene at its selected model's generation price for 1–2 missing scenes, or batch_discounted price for 3+ — including previously FAILED AI scenes, which are revived and charged as missing work (failed uploads still block the render) — so a fully-generated project renders for 0. Historical seedance-2.0 scenes are selectable again and are quoted and auto-charged at the model's current catalog price; they used to hard-fail as an unavailable model. Before spending, read the live Editor video_models and current scenes, quote the exact ordered segment/model/pricing-version cohort, obtain approval, and send max_generation_credits plus generation_quote_fingerprint; a stale quote returns 409 generation_credit_quote_changed without starting. voice_id matches ^[A-Za-z0-9_-]+$ (≤64). Render waits for active narration/voice/music generation (409 narration_generation_in_progress / voice_generation_busy / music_generation_busy). A timeline longer than the 600s ceiling returns 422 editor_total_duration_exceeded; when a just-failed scene's refund is still settling, render returns a retryable 409 editor_render_settlement_pending (try again shortly). Editing the scenario or narration after generating voice/music marks them stale — render returns 422 editor_voice_stale / editor_music_stale / editor_narration_stale until you regenerate.

apply-scenario materializes the saved scenario into the timeline: existing AI scenes are replaced with scene prompts generated from the scenario, uploads are preserved, and segments_count 0 removes the AI scenes. It is free — no credits and no AI assist (generate-scenario already spent the assist) — and runs async: poll GET /api/editor/:slug until scenario_apply_status leaves "applying". When segments_count is omitted, apply uses the saved scenario brief's scene count, then defaults to 5. Read video_models from the Editor response before spending: it is the whole selectable fleet, each entry carrying tier, lifecycle, capabilities (including durations_seconds and native_audio), continuity implications, notices, and the pricing basis. generate/regenerate accept an optional model id and an optional generation_options object carrying duration_seconds (one of that model's capabilities.durations_seconds; omit it for the 8s model default) and native_audio. credits.generation/regeneration/batch_discounted are the price at that default; any other duration costs ceil(pricing.basis_micros_per_second * duration_seconds / 400000) + 1, with regeneration priced like generation and the 3+-scene batch rate one credit lower (minimum 1). An unsupported length returns 422 unsupported_generation_duration and any other option name returns 422 invalid_generation_options — neither creates a run or charges. DIALOGUE SCENES: native_audio true keeps the model's own sound for that scene and persists use_original_audio on the segment (PATCH /api/editor/:slug/segments/:id accepts the same flag directly). It is gated on the model's capabilities.native_audio.mode: optional takes both values (default false), always takes only true (422 native_audio_required otherwise — wan-2.7, happy-horse-1.1 and minimax-h3 return sound whatever you ask and are priced for it), and unavailable takes only false (422 native_audio_not_available). A dialogue scene is priced from the model's with-audio basis (pricing.audio_micros_per_second), which on Veo 3.1 Standard is double the silent rate: an 8s veo3 scene is 5 credits silent and 9 credits with audio. When native audio resolves TRUE the quote fingerprint gains a literal audio marker before the pricing version — six parts, segment_id:model:<operation>:duration_seconds:audio:pricing_version, for example 7:veo3:generation:8:audio:2026-08-09-unified-v1 at 9 credits. The marker follows the RESOLVED value, not whether your request carried the option, so an always-mode model and a scene already patched to use_original_audio: true both require the marked form even with no generation_options in the body; the silent five-part form and both duration-free forms are then refused with 409 generation_credit_quote_changed because they encode a price you were never shown. Variants fence the same way (segment_id:model:variants:count:duration_seconds:audio:pricing_version); the batch cohort fingerprint is unchanged and audio-blind, though a batch still prices each scene from its own persisted flag. A dialogue scene never speaks: it is excluded from narration, the voice run lays digital silence under it, the render hard-mutes narration across its window, and in voice_first pacing it is a fixed narration gap whose length the spine never resizes (toggling the flag sets pacing_stale). Every segment also carries a derived audio_role, computed and never sent: dialogue whenever the scene plays its OWN audio (use_original_audio true — the same answer for an AI take keeping model sound and an upload keeping its recorded sound; tell them apart with source_type), narrated when a voice-over line lands over it (its own narration_text under narration_script_source "segments", and every non-dialogue scene under "manual", where the one project script is split across the speaking set at voice time), and ambient otherwise. Fence an approved single-scene quote with max_generation_credits and generation_quote_fingerprint in the form segment_id:model:<operation>:duration_seconds:pricing_version, where operation is generation or regeneration; the duration-free segment_id:model:<operation>:pricing_version form is also accepted at the model default duration, for exactly two pricing_version values: the current pricing_version on the entry you just read, and the historical 2026-07-31 — the latter only for veo3, veo3-fast, and kling-o3-pro, and only while their prices are still the ones that version set (5/4/4, 3/3/3, 4/3/3). A stale model, duration, or pricing version returns 409 generation_credit_quote_changed before launch. batch-generate generates every scene at the SAME duration it would be generated at individually: the model default in scene_first, and the scene's authored_duration_seconds in voice_first — and it is now priced there too, so a batch and the equivalent one-by-one generations quote the same per-scene duration (the 3+ batch rate still applies on top). An explicit generation_options.duration_seconds is still accepted on single-scene generate/regenerate only. Voice-first Autopilot freezes the selectable-duration ceiling as the maximum approved spend, then resolves each exact scene charge from its immutable duration-price table after pacing.derive; scene-first Autopilot remains model-default priced. regenerate creates a NEW version of one scene; pass prompt and model to author the new version, or omit either to reuse the current selection. A completed scene cannot be re-run through generate (422 editor_segment_completed) — regenerate is the path to a fresh take. In voice_first pacing this all still holds, with one addition: when a scene has an authored_duration_seconds, THAT authored duration is the priced duration. It is what the credit formula, the generation_quote_fingerprint, and max_generation_credits all resolve to when the request carries no generation_options, so build the five-part v2 fingerprint segment_id:model:<operation>:duration_seconds:pricing_version from the served authored_duration_seconds (a 6s-paced scene fingerprints at 6, not at the 8s model default). The duration-free four-part segment_id:model:<operation>:pricing_version form is rejected for a scene paced away from the model default — it would understate the price — and stays accepted only at the model default duration. An explicit generation_options.duration_seconds still wins, but it must EQUAL the authored duration or the call is refused 422 editor_pacing_duration_mismatch (with the authored and requested values in details), so what the spine authored and what actually gets generated can never silently diverge. A project in which EVERY scene keeps its own audio has nothing left to speak: narration, generate-voice, voice-plan and autopilot all refuse it up front with 422 editor_no_speaking_segments, before any scene is charged. And because a take's audio is fixed when it is generated, PATCHing use_original_audio on an AI scene that already has a take returns 409 editor_segment_audio_locked — regenerate with generation_options.native_audio to change it. REFERENCE SLOTS (identity): references pin identity, last-frame chaining pins continuity, and a reference generation sends both — the scene's identity images first, the previous scene's visible boundary frame appended last — within the model's slot budget; filling every slot drops the continuity frame and sets continuity_limited: true. Every segment carries reference_s3_keys (ordered storage keys) and reference_urls (the same slots presigned for display, POSITIONALLY ALIGNED: reference_urls[i] shows reference_s3_keys[i], and an unsignable slot holds null rather than collapsing — send keys back, never URLs). A non-empty set REPLACES the project product photo for that scene rather than stacking on it, because slots are scarce and stacking would push the continuity frame out of the request. How many a model takes is capabilities.reference_images_max on its video_models entry — an integer alongside the existing boolean capabilities.reference_images, which is exactly reference_images_max > 0: veo3 and veo3-fast take 3, seedance-2.0 and seedance-2.0-fast take 9, and every other model takes 0. Exceeding it is 422 editor_reference_images_exceed_model_max, whose details name the model and its exact max_images, and it is refused at generation admission as well as at attach time. References never change the price and never enter the quote fingerprint: the reference route is the same fal endpoint family as the model's other routes and bills at the same per-second basis (the reference_route_same_price notice says so on the models where it is newly wired). Attach on a scene with no take yet through references/presign then references/confirm (image/jpeg, image/png or image/webp, at most 8MB), or references/from-asset for a catalog image (subscription-gated); remove with DELETE .../references/:index. All four are AI-scene-only: an uploaded clip's pixels already exist, so it answers 422 editor_segment_references_unsupported. Once the scene HAS a take those two writes return 409 editor_segment_references_locked — the images are the input that produced its pixels, so rewriting the column would make the row claim an identity the media never had. Stage the change instead: references/presign is always allowed (it writes nothing), PUT the image, then send generation_options.reference_s3_keys on regenerate and the new set lands on the new version row. references/from-asset degrades to stage-only there, returning staged: true with the asset's s3_key and the unchanged segment, leaving the column untouched — and it still refuses a staged set that would not fit the model, with the same 422 editor_reference_images_exceed_model_max. In generation_options the key is absent-means-inherit: omitting it keeps the scene's persisted references, an explicit [] clears them, every entry must be a key minted by this project's references/presign or the s3_key of a ready catalog image you own (anything else is 422 editor_reference_image_not_owned, because every staged key gets presigned), and the same per-model ceiling applies. A saved reference whose object has since disappeared fails the generation closed with 422 segment_reference_required rather than quietly producing a lookalike. PATCHing a scene's model is held to the same ceiling: a switch that would strand more references than the new model's reference_images_max is refused 422 editor_reference_images_exceed_model_max and writes nothing. And apply-scenario RE-AUTHORS the timeline, so its fresh scenes carry no reference_s3_keys — re-attach identity images after applying a scenario.

variants explores the SAME scene N ways at once instead of one at a time. Send count (2–4), the model, and both fences: max_credits must equal count × the per-take price exactly, and generation_quote_fingerprint takes the six-part form segment_id:model:variants:count:duration_seconds:pricing_version. Per-take price is the generation price while the scene has no completed take and the regeneration price once it does. Only the frontier scene can be explored (409 scene_not_frontier / scene_locked). Each take is staged as a candidate version of the same scene — the timeline is untouched — and the run then parks needs_input with available_actions advertising variant_select and blockers[].details.candidate_segment_ids listing the takes that finished. Answer it at POST /api/editor/:slug/generation/input with the ordinary fenced body and input {"segment_id": <chosen take id>}; the body never names the action. The chosen take becomes the scene, the rest stay in its take history with media and prompts intact (reachable through the versions/select route). A take that fails is refunded on its own and simply does not appear among the choices; if every take fails the run fails and nothing on the timeline changed. Cancel a live batch with POST /api/editor/:slug/generation/variants/cancel {generation_run_id, variant_batch_id} — the batch id fences out a successor fan-out on the same scene. Cancellation refunds only the takes that were not yet handed to the provider: a take already generating has been billed per attempt, so it runs to completion and is kept as an ordinary superseded take in the scene's history, media and prompt intact.

Finalize — draft → final

A model whose video_models entry carries tier "draft" (veo3-lite, seedance-2.0-fast) is the cheapest exploration tier: shoot the scene there, look at it, and commit the take you kept ONCE onto a standard or premium model with POST /api/editor/:slug/segments/:id/finalize. The draft take's prompt, identity reference_s3_keys, authored duration, and dialogue (native_audio) setting are copied verbatim — the model is the ONLY variable — and anything you pass in generation_options overrides the copied value for that one field. Mechanically it is a regeneration: a forward-only candidate take, previewed and reviewed, with the existing take staying selected until the new one passes, and the response is regenerate's exactly (a bare EditorSegment under data, carrying generation_summary). The workflow is editor.scene.finalize@1, and cancel and retry work exactly as for a scene regeneration — generation_summary.available_actions advertises cancel_generation (POST .../segments/:id/cancel) and, on failure, retry_generation (POST .../segments/:id/retry).

It is priced as a GENERATION on the TARGET model, not as a regeneration: a draft take is not a failed attempt at the target model, it is the first shot the scene has ever taken there. So the fingerprint's operation word is generation and the credits are the target model's credits.generation. The form is segment_id:model:generation:duration_seconds:pricing_version, where segment_id is the DRAFT take's id, model is the TARGET model, and duration_seconds is the resolved duration — the scene's authored/voice-first duration when it has one and the target model supports it, else the model default 8; a dialogue scene takes the six-part …:duration_seconds:audio:pricing_version form, exactly as on generate/regenerate. Both fences are mandatory here, unlike single generate/regenerate where they are optional, because a finalize always changes the model — you can never be of no opinion about the price. The promoted take reports draft_of_segment_id (integer, nullable) on the segment wire, naming the draft take it came from; to decide whether a take IS a draft (and should offer Finalize), read tier on its model's video_models entry — draft_of_segment_id is the result of a promotion, not the marker of a draft.

Two refusals are specific to this route, both 422: editor_finalize_requires_draft_take when the current take's model is not draft-tier (an unknown or retired model key counts), details naming model and tier; and editor_finalize_target_tier_invalid when the requested target model is itself draft-tier, details naming model and tier. Everything else it can return is the set regenerate returns: 402 credits_insufficient quoting the GENERATION price, 409 generation_credit_quote_changed, 422 invalid_max_generation_credits / invalid_generation_quote_fingerprint / editor_reference_images_exceed_model_max (checked against the TARGET model's capabilities.reference_images_max — it refuses, it never silently drops references) / editor_pacing_duration_mismatch / native_audio_*, 422 editor_segment_not_completed, 409 editor_segment_busy, and the 503 admission refusal. The admission gate for editor.scene.finalize@1 is closed by default in production until it is opened, so a fresh deploy answers 503 generation_admission_closed.

# The scene's current take was shot on a draft-tier model — its video_models entry says tier "draft"
# (veo3-lite, seedance-2.0-fast). Explore there, then commit the take you kept ONCE onto a
# quality model. Priced as a GENERATION on the TARGET model: veo3 is 5 credits at the 8s default.
curl -X POST https://hubfluencer.com/api/editor/$SLUG/segments/81/finalize \
  -H "authorization: Bearer $HF" -H "idempotency-key: finalize-seg:$SLUG:81" \
  -H "content-type: application/json" \
  -d '{"model":"veo3","max_credits":5,"generation_quote_fingerprint":"81:veo3:generation:8:2026-08-09-unified-v1"}'

# The response is regenerate's exactly: the EditorSegment under data — a candidate take carrying
#   "draft_of_segment_id": 81
# plus a generation_summary for editor.scene.finalize@1. The draft take stays selected until the
# new one passes. Prompt, reference_s3_keys, authored duration and native_audio were copied verbatim.

# generation_options overrides ONE copied field — here, keep the model's own sound (9 credits):
#   {"model":"veo3","max_credits":9,
#    "generation_quote_fingerprint":"81:veo3:generation:8:audio:2026-08-09-unified-v1",
#    "generation_options":{"native_audio":true}}

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

Retry a failed Kernel-owned scene only when generation_summary.available_actions advertises retry_generation for editor.scene.generate or editor.scene.regenerate: quote that action's exact credits, obtain approval, then POST /api/editor/:slug/segments/:id/retry with generation_run_id, generation_revision, and step_key from the same action. Idempotency-Key is required. The failed provider attempt was refunded; the retried provider step is a new balance-gated charge at the immutable price frozen into the run. The route retries the exact failed run in place and rejects stale run/revision/step/materialization fences.

Cancel one in-flight scene with POST /api/editor/:slug/segments/:id/cancel (video:generate). Idempotency-Key is required; reuse it only for transport retries of this exact token-fenced command. Send {"expected_generation_claim_token": "<generation_claim_token from the processing scene>"} so a delayed command cannot cancel a successor attempt. The token is required and must be a non-empty string. It stops that individual generation, fences a racing provider completion (first writer wins), refunds the paid attempt idempotently, and returns the scene as failed with error_code user_cancelled — repeating the cancel with the same token and key returns that scene. A refunded cancel does not prepay the next attempt; the next generation or advertised frozen-price retry is separately paid. Batch or autopilot scenes are not individually cancellable (409 batch_generation_active / autopilot_active — use the batch or autopilot cancel commands); 409 editor_segment_not_processing when the scene has no active individual generation; 409 editor_segment_cancel_stale when the token targets an older attempt; 422 invalid_generation_claim_token when the required field is omitted or invalid; 404 for an unknown factory/segment; 503 editor_segment_cancel_failed if cancellation could not be settled — retry with the same key.

Voice spine — pacing modes

Every editor project has one authoritative pacing mode, served as pacing_mode on the editor payload. "scene_first" is the default and what every existing project uses: scene durations are authoritative and the voice-over is fitted to them with Forge's 0.80–1.25x playback-rate clamp — unchanged behavior. "voice_first" inverts the contract: the narration script is the project's spine. The voice is generated FIRST, before any scene is generated, each line is measured, and every scene's duration is derived from its line. A narration line can therefore never overrun its scene or collide with the next line, because the scene was sized from the line.

Switch with POST /api/editor/:slug/pacing, body mode: "scene_first" or "voice_first". It is free (0 credits), needs video:generate, and takes NO Idempotency-Key. It is refused 409 while any generation owns the project — a run planned under one contract must never have the other applied to it halfway through. Switching TO voice_first flags every scene that has no authored_duration_seconds yet as pacing_stale: true; switching TO scene_first clears every pacing_stale flag and KEEPS any authored durations (they stay an honest record of what was requested). The response is the ordinary editor factory payload plus a pacing_impact object carrying marked_stale, cleared_stale, and scenes_without_authored_duration. An unknown mode returns 422 editor_pacing_mode_invalid.

POST /api/editor/:slug/voice-plan plans the spine — body voice_id (required), style?, tone?; Idempotency-Key is REQUIRED; video:generate. It starts the workflow editor.voice.plan@1: narration.generate (free of credits, spending the AI-assist quota exactly like the ordinary narration endpoint), then voice.generate (3 credits, ElevenLabs with-timestamps), then pacing.derive (free, 0 credits) — 3 credits total. It requires the project to be in voice_first pacing (422 editor_pacing_mode_required otherwise) and at least one scene that does not keep its original audio. The scenes do NOT need to be generated yet; that is the whole point of the mode. A 201 returns the editor factory payload, and the generation_summary key is OMITTED entirely when there is nothing to report — never present-but-null.

The derivation is authored_duration_seconds = snap_up_to_model_grid(line_duration + 0.4s lead + 0.5s tail + continuity_head_trim). line_duration is the MEASURED spoken length taken from the ElevenLabs character timestamps. The lead and tail default to 0.4s and 0.5s and are server-tunable per deployment. continuity_head_trim is 0.2s (6 frames at 30fps) for every scene after the first whose use_previous_frame is true — the render trims those conditioning frames off the scene's head, so the spine budgets them up front. Snapping up to the model grid uses the scene model's capabilities.durations_seconds: Veo accepts 4/6/8, so a 5.1s requirement becomes a 6s scene and the surplus is planned tail air, never a clipped word. The authored duration is CAPPED at the catalog's default duration (8s) — for EVERY scene, including a model with no published duration grid — so a derived spine can never cost more than the price the user approved. A line that does NOT FIT that cap is refused before any credit moves: voice-plan (and the voice step of a voice-first Autopilot) returns 422 editor_narration_line_too_long with details.scenes naming each offending scene (segment_id, position, estimated_line_seconds, max_authored_seconds). It is neither clipped nor silently over-bought — a scene generated at the cap while the voice run is laid at its full measured length produces a canvas the picture cannot cover. Shorten the line, or split it across more scenes, and plan again. Uploads are never resized by the spine: an uploaded scene is a fixed gap at its measured length (a 30s upload stays 30s whether or not narration is spoken over it), and a line too long to fit inside one is refused by the same 422. Every authored duration is snapped onto the render's 1/30s frame grid — up for a derived scene, down for a fixed upload — so the render's seconds-to-frames conversion is exact.

Two fields carry the spine on every read. The editor factory payload gains pacing_mode ("scene_first" or "voice_first"). Each segment gains authored_duration_seconds (float or null) — what the scene was ASKED for, the spine's derivation — where duration_seconds remains what the generated media MEASURES, and null means "model default"; and pacing_stale (boolean), true when this scene's narration line changed after its authored duration was derived. A pacing_stale scene is still renderable; it is simply no longer proven to fit its line.

# 1. Make the narration script this project's spine (free, 0 credits, no Idempotency-Key)
curl -X POST https://hubfluencer.com/api/editor/$SLUG/pacing \
  -H "authorization: Bearer $HF" -H "content-type: application/json" \
  -d '{"mode":"voice_first"}'

# 200 OK — the ordinary editor payload plus what the switch did to the timeline:
#   "pacing_mode": "voice_first",
#   "pacing_impact": {"marked_stale": 5, "cleared_stale": 0, "scenes_without_authored_duration": 5}

# 2. Plan the spine — 3 credits total, Idempotency-Key REQUIRED.
# The scenes do NOT have to be generated yet: the voice is what sizes them.
curl -X POST https://hubfluencer.com/api/editor/$SLUG/voice-plan \
  -H "authorization: Bearer $HF" -H "idempotency-key: voice-plan:$SLUG" \
  -H "content-type: application/json" \
  -d '{"voice_id":"rachel","tone":"warm"}'

# 3. Poll GET /api/editor/$SLUG. Every scene now carries what it was ASKED for:
#   { "id": 81, "duration_seconds": null, "authored_duration_seconds": 6.0, "pacing_stale": false }

# 4. Generate at the derived duration. Omit generation_options and the AUTHORED
# duration is the priced duration — the fingerprint must carry it (6, not 8).
curl -X POST https://hubfluencer.com/api/editor/$SLUG/segments/81/generate \
  -H "authorization: Bearer $HF" -H "idempotency-key: gen-seg:$SLUG:81" \
  -H "content-type: application/json" \
  -d '{"model":"veo3-fast","max_generation_credits":3,"generation_quote_fingerprint":"81:veo3-fast:generation:6:2026-08-09-unified-v1"}'

Batch recovery

Method & path Semantics
POST /api/editor/:slug/generation/batch/retry Only when generation_summary advertises retry_generation for editor.batch. Send generation_run_id and generation_revision from that same summary. Idempotency-Key is required. The command atomically retries the exact failed canonical step and returns an updated generation_summary; 0 additional credits. When current_step identifies a scene, you may first repair only that scene through PATCH /api/editor/:slug/segments/:id (prompt / use_previous_frame). Scene retries re-screen the edited prompt and are capped by the workflow plan. A stale run, revision, or failed-step identity returns 409; re-read and invoke the newly advertised action.
POST /api/editor/:slug/generation/batch/cancel Only when generation_summary advertises cancel_generation for editor.batch, including an active batch or a failed in-place batch parked for retry. Send generation_run_id from that same summary. Idempotency-Key is required; reuse one key only for transport retries of this exact observed run. Cancellation keeps completed work, refunds non-delivered scene allocations, unlocks authoring without requiring another provider retry, and returns the updated generation_summary. A stale or already settled run returns 409 or 422; re-read before deciding what to do next.

The retry body is {generation_run_id, generation_revision}; the cancel body is {generation_run_id}. Copy these fields from the generation_summary that advertises the action. Missing or malformed fences return 422 batch_command_required, while fences that were valid but moved on return 409 generation_authority_changed. Re-poll and issue a new command with a new Idempotency-Key only when the current summary still advertises the action.

generation_summary.available_actions is the only source of retry/cancel availability. batch_generation_status, batch_generation_run_id, and batch_generation_failed_position are product display projections; they do not authorize commands, select polling cadence, or determine terminality. While a retry action is advertised, the rest of the timeline stays mutation-locked except the failed scene identified by current_step. An insufficient-credit blocker is resolved only through the action advertised by generation_summary after a top-up or scope reduction.

# Write your own scenario instead of generating it (free, no assist)
curl -X PATCH https://hubfluencer.com/api/editor/$SLUG/scenario \
  -H "authorization: Bearer $HF" -H "content-type: application/json" \
  -d '{"scenario_prompt":"Scene 1 … Scene 2 … Scene 3 …"}'

# Read GET /api/editor/$SLUG first, choose from data.video_models, and obtain approval for its exact credits/notices
# Generate one scene with Veo 3.1 Fast (3 credits at the 8s default in the 2026-08-09-unified-v1 catalog); poll until completed
curl -X POST https://hubfluencer.com/api/editor/$SLUG/segments/81/generate \
  -H "authorization: Bearer $HF" -H "idempotency-key: gen-seg:$SLUG:81" \
  -H "content-type: application/json"   -d '{"model":"veo3-fast","max_generation_credits":3,"generation_quote_fingerprint":"81:veo3-fast:generation:8:2026-08-09-unified-v1"}'

# Author a non-default scene length: pick a value from the model's capabilities.durations_seconds
# and price it as ceil(pricing.basis_micros_per_second * duration_seconds / 400000) + 1
curl -X POST https://hubfluencer.com/api/editor/$SLUG/segments/81/generate \
  -H "authorization: Bearer $HF" -H "idempotency-key: gen-seg-12s:$SLUG:81" \
  -H "content-type: application/json"   -d '{"model":"kling-o3-pro","generation_options":{"duration_seconds":12},"max_generation_credits":5,"generation_quote_fingerprint":"81:kling-o3-pro:generation:12:2026-08-09-unified-v1"}'

# Voice-over (3 credits), then render. This 0-credit fence succeeds only after every scene is generated.
# If scenes are missing, GET the Editor again, quote the exact ordered cohort, obtain approval, and send that cap/fingerprint.
curl -X POST https://hubfluencer.com/api/editor/$SLUG/generate-voice \
  -H "authorization: Bearer $HF" -H "content-type: application/json" -d '{"voice_id":"rachel"}'
curl -X POST https://hubfluencer.com/api/editor/$SLUG/render   -H "authorization: Bearer $HF" -H "idempotency-key: render:$SLUG"   -H "content-type: application/json"   -d '{"max_generation_credits":0,"generation_quote_fingerprint":""}'

Uploads & local assets

Bring your own media into an editor project — your own footage as scenes, plus a product image, closing card, and brand logo. All of this is free (0 credits). Uploads use a presign → PUT-to-storage → confirm flow; the file then processes asynchronously before it can go on the timeline.

POST /api/editor/:slug/uploads/presign 0 credits

Get a presigned PUT URL for a video upload.

Body field Type Notes
filename required string Original file name.
mime_type required string video/mp4, video/quicktime, video/webm, video/x-matroska.
size_bytes required integer ≤ 500 MB per file; video ≤ 5 min. Also counts against your per-user storage quota.
fit_mode optional string "cover" or "blur" (default).

Returns 422 upload_quota_exceeded (with quota.limit_bytes, used_bytes, pending_bytes, reserved_bytes, and remaining_bytes) if the upload would push your total live upload storage over the limit. There is no per-upload delete route: use a smaller file, wait for abandoned pending/failed upload cleanup, or delete an editor project you no longer need to release its uploads.

200 OK
{
  "data": {
    "upload_id": 510,
    "presigned_url": "https://r2…/editor/$SLUG/uploads/uuid.mp4?X-Amz-Signature=…",
    "s3_key": "editor/$SLUG/uploads/uuid.mp4",
    "expires_in_seconds": 3600
  }
}
POST /api/editor/:slug/uploads/:upload_id/confirm 0 credits

Confirm the PUT. The server validates the object (Content-Type and size must match the presign) and queues processing.

GET /api/editor/:slug/uploads video:read

List uploads with their processing status. Poll until status is "ready" before placing the clip.

200 OK  — status flows pending → processing → ready | failed
{
  "data": [
    { "id": 510, "filename": "clip.mp4", "mime_type": "video/mp4",
      "status": "ready", "duration_seconds": 6.2, "width": 1080, "height": 1920,
      "error_message": null, "attached_segment_ids": [83] }
  ]
}
POST /api/editor/:slug/segments/from-upload 0 credits

Append a ready upload to the timeline as a finished scene. Body: upload_id. Rejects editor_upload_not_ready, batch_generation_active, autopilot_active (while Autopilot is running), editor_segment_limit (max 20 scenes).

POST /api/editor/:slug/segments/:id/use-asset 0 credits

Set one scene's video from an existing asset. Body: exactly one of upload_id or source_segment_id. Reuse a clip across scenes, or swap a generated scene for your footage.

The 200 data is the updated segment plus an impact object describing downstream continuity fallout — replacing a scene changes the frame the next continuity-enabled AI scene inherits. Keys: requires_regeneration (bool), stale_video_segment_ids / stale_video_positions (following completed AI scenes now stale), affected_segment_ids / affected_positions (the whole downstream chain), anchor_segment_id / anchor_position, stop_reason / stop_position, and action ("segment_asset_replace"). When requires_regeneration is true, re-generate the listed scenes before rendering — render rejects a stale timeline.

Large files use a resumable multipart flow (use it at ≥ 50 MiB / 52,428,800 bytes, 8 MiB parts): POST …/uploads/multipart/init, …/sign-part, …/complete, …/abort. init returns part_size + parts_count; sign each part, PUT its bytes, keep the ETag, then send the ordered parts list to complete (abort on failure).

The one header rule that breaks raw uploads: a single-object PUT — the small-file path above and every image — MUST send Content-Type matching the mime you presigned (confirm HEADs the object and 422s on a mismatch). A multipart part PUT must send NO Content-Type header at all — the part URL doesn't sign one, so an extra header breaks the S3 signature.

Image assets — product, closing card, logo

Same presign → PUT → confirm shape (image/jpeg or image/png). Product images are capped at 8 MiB, closing cards at 20 MiB, and logos at 5 MiB. Thread the s3_key from presign into confirm verbatim.

Method & path Purpose
POST /api/editor/:slug/product/presign · /product/confirm · /product/from-asset Attach a product photo and semantic description. The description always grounds scenario/narration when product_subject is blank. The photo's pixels guide generated product identity on scenes that carry no per-scene references of their own (a non-empty reference_s3_keys set REPLACES the photo for that scene), and only when the selected live catalog model advertises capabilities.product_reference: true; surface that model's notices and documented fallback when it is false. The exact photo is linked as the closing image unless a separate closing image was authored; fine generated label text can still vary. Replacing/removing it marks completed AI scenes for regeneration, except scenes with their own reference_s3_keys — they never consumed the photo, so they are left alone; a product change that races a generation or Autopilot start returns 409 editor_references_changed — re-read the project and retry. from-asset {asset_id} reuses a ready catalog image (subscription-gated).
POST /api/editor/:slug/closing-image/presign · /confirm · /from-asset Upload a closing-card image, or from-asset {asset_id} reuses a ready catalog image you own (subscription-gated).
POST /api/editor/:slug/closing-image/from-product Reuse the product image (0-credit copy); overwrite? to replace.
POST /api/editor/:slug/logo/presign · /confirm · PATCH /logo Overlay a brand logo (PNG/JPEG); PATCH sets treatment/position/duration.

The old compatibility routes have been removed entirely — POST product/apply-default-placement, scene-asset presign/confirm/impact, and reference-images/impact no longer exist in the router and now return a plain 404. Only the body-level fences on segment add/update survive: product_placement or product_excluded returns 410 product_placement_removed, scene_asset_role / scene_asset_prompt_adaptation_enabled / scene_asset_s3_key returns 410 scene_assets_disabled, and any other unknown field returns 422 unsupported_field. Author scene content through prompt and use_previous_frame.

Full flow

# 1. Presign (Content-Type MUST equal the mime_type you send here)
curl -X POST https://hubfluencer.com/api/editor/$SLUG/uploads/presign \
  -H "authorization: Bearer $HF" -H "content-type: application/json" \
  -d '{"filename":"clip.mp4","mime_type":"video/mp4","size_bytes":4821004}'

# 2. PUT the bytes to presigned_url with the SAME Content-Type
curl -X PUT "$PRESIGNED_URL" -H "content-type: video/mp4" --data-binary @clip.mp4

# 3. Confirm — queues processing (metadata + frame extraction)
curl -X POST https://hubfluencer.com/api/editor/$SLUG/uploads/510/confirm -H "authorization: Bearer $HF"

# 4. Poll GET /api/editor/$SLUG/uploads until the row's status is "ready"
# 5. Drop it on the timeline as a finished scene
curl -X POST https://hubfluencer.com/api/editor/$SLUG/segments/from-upload \
  -H "authorization: Bearer $HF" -H "content-type: application/json" -d '{"upload_id":510}'

Renders

GET /api/editor/:slug/renders video:read

Every render version with status, presigned video_url/thumbnail_url, retryable, combination_hash, and is_stale. Status is pending, processing, completed, or failed. Use it to recover a finished URL or find a failed render to retry; is_stale null means unknown, not current.

200 OK
{
  "data": [
    { "id": 9, "version": 2, "status": "completed",
      "video_url": "https://…/render.mp4?X-Amz-Expires=86400",
      "thumbnail_url": "https://…/render.jpg?X-Amz-Expires=86400",
      "duration_seconds": 18.0, "error_message": null, "retryable": false,
      "combination_hash": "v2:…", "is_stale": false,
      "inserted_at": "2026-06-03T10:20:00Z" },
    { "id": 8, "version": 1, "status": "failed",
      "video_url": null, "error_message": "forge_timeout", "retryable": true }
  ]
}
POST /api/editor/:slug/renders/:id/retry 0 credits

Re-run a failed render from its saved snapshot. Returns 202 with data.video_result_id, status, and is_duplicate; poll editor state for completion. Only failed renders are retryable (editor_render_not_retryable otherwise).

AI disclosure & compliance (EU AI Act Art. 50)

Every render — editor ad, short, and carousel slide — carries C2PA Content Credentials (a signed, machine-readable "this is AI-generated" marking with the generator model list) AND a visible AI-disclosure badge that is ON by default. Distribution platforms (TikTok, Meta, YouTube) read the C2PA manifest at upload and may auto-apply their own AI label from it — so turning the badge off does NOT prevent platform labels. Publishing the video makes YOU the deployer under Art. 50(4): the disclosure assessment is your decision, which is why disabling the badge requires an explicit acknowledgement that is stamped and logged.

Badge controls live on the project and are set via the normal update endpoints (PATCH /api/editor/:slug and PATCH /api/shorts/:slug): ai_disclosure_mode ("generated" default | "modified" | "none") and ai_disclosure_position ("auto" default — the corner opposite your logo, inside the platform-safe area — or an explicit corner). Setting mode "none" without ai_disclosure_ack: true in the same request returns 422 ai_disclosure_ack_required. A successful disable stamps ai_disclosure_ack_at / ai_disclosure_ack_by_user_id (returned on the project and in the compliance record) and writes an audit log entry; switching back on never clears the historical stamp. Carousels are always fully AI-generated and are always marked + labelled (no per-slider toggle).

// Turning the label off is an explicit, acknowledged decision:
PATCH /api/editor/:slug   (or /api/shorts/:slug)
{ "ai_disclosure_mode": "none", "ai_disclosure_ack": true }

// Without the ack in the SAME request:
422 { "error": "ai_disclosure_ack_required", "message": "…" }
GET /api/video-factories/:slug/compliance video:read

Audit-ready Article 50 evidence record for one project (works for both editor and short slugs): the project kind, the disclosure state with its ack stamp, provenance facts (whether the current timeline contains user footage, the generator model list, and the machine-readable marking actually applied to that kind — "c2pa" for the Forge-rendered editor and short, "none" for a tracking project, which is your own clip with a CV overlay burned on and is never signed), and the completed renders. Hand it to an agency client or a regulator information request as-is.

200 OK
{
  "data": {
    "kind": "editor",
    "ai_disclosure": {
      "mode": "generated", "position": "auto",
      "ack_at": null, "ack_by_user_id": null
    },
    "provenance": {
      "contains_user_footage": false,
      "models": ["veo3", "elevenlabs-tts", "elevenlabs-music"],
      "machine_readable_marking": "c2pa"
    },
    "renders": [
      { "video_result_id": 42, "status": "completed",
        "generated_at": "2026-08-01T12:00:00Z" }
    ]
  }
}

Product sources

The start of the "delegate my content" loop: turn a product page into facts and imagery. Every fetch is SERVER-SIDE and SSRF-guarded (https only, no redirects to internal ranges). Extraction is stateless; import stores a catalog asset. All three spend 0 credits, need video:generate, and are rate-limited to 30/min.

POST /api/product-sources/extract 0 credits video:generate

Fetch a public product page (HTML only) and return parsed facts: name, description, benefits, price, brand, availability, plus candidate product image URLs and a site/brand logo URL. URLs only — images are NOT downloaded here. Body: {url} (absolute http(s), ≤2048 chars). 422 unsupported_content_type for a non-HTML page, too_large for an oversized one.

POST /api/product-sources/preview-image 0 credits video:generate

Fetch an image at a public URL (JPEG/PNG, verified by magic-number sniffing — not just the Content-Type — and capped to a small preview) and return an inline data URL for review. Writes nothing, stores no asset. Body: {image_url}. 422 for an unsafe or non-image URL.

POST /api/product-sources/import-image 0 credits video:generate

Fetch an image at a public URL (JPEG/PNG magic-number verified, size-capped at the catalog image limit) and store it as a ready CatalogAsset. Body: {image_url, product_profile_id?, link_as?}. With product_profile_id it links the asset to that profile as link_as "primary_image" (default) or "logo". 404 for an unknown/foreign profile; 422 for an unsafe or non-image URL.

Product & brand profiles

Reusable, user-owned identity so every campaign draft inherits it instead of re-describing the brand. A product profile grounds the AI (benefits, offer, proof points, audience, CTA, compliance); a brand profile steers tone/format and carries the logo. Plain CRUD; reads need video:read, writes need video:generate; all 0 credits. Only name is required on each.

GET /api/product-profiles video:read

List / read your product profiles. GET /api/product-profiles/:id returns one. Pass an id to plan / hook-variations / campaign-pack create so the campaign is planned from a saved source of truth.

POST /api/product-profiles 0 credits video:generate

Create a product fact sheet. Body: {name (required), description?, benefits?[], offer?, proof_points?[], audience?, cta?, banned_claims?[], brand_tone?, source_url?}. PATCH /api/product-profiles/:id patches any of these (0 credits); DELETE /api/product-profiles/:id removes it (204). Attach imagery via product-sources/import-image. 404 for an unknown/foreign id.

GET /api/brand-profiles video:read

List / read your brand profiles (GET /api/brand-profiles/:id for one). Pass an id as brand_profile_id to POST /api/shorts / hook-variations / campaign-pack create / series so drafts inherit the brand + logo.

POST /api/brand-profiles 0 credits video:generate

Create a brand identity. Body: {name (required), logo_s3_key?, primary_color?, secondary_color?, font_family?, tone_words?[], default_cta?, default_audience?, banned_claims?[], preferred_visual_language?, preferred_creative_format?}. Colors are hex (#09EFBE); preferred_visual_language / preferred_creative_format take the same value lists as Shorts/editor. PATCH /api/brand-profiles/:id patches any field; DELETE removes it (204).

POST /api/brand-profiles/:id/assets 0 credits video:generate

Attach one of your catalog assets to a brand profile. Body: {catalog_asset_id, role} where role is logo | product_image | other. DELETE /api/brand-profiles/:id/assets/:asset_id removes a link — :asset_id is the LINK id (the profile's assets[].id), not the catalog_asset_id. 422 if the catalog asset isn't yours; 404 for an unknown/foreign profile.

Campaign packs

The planning container: plan a campaign, persist it as a pack, and materialize each item into an editable DRAFT (an editor/short video project or an image slider). Planning (plan + hook-variations) generates no media and consumes the FREE AI-assist quota — NOT credits. Persisting a pack and materializing items are 0 credits. Generating/rendering each draft is a SEPARATE, explicit, PAID step (autopilot / generate). Reads need video:read, writes need video:generate.

POST /api/campaign-packs/plan 0 credits video:generate

Turn product facts into a campaign PLAN: hooks, recommended formats, a positioning line, and a per-item credit ESTIMATE. Persists nothing. Body: {product_profile_id | product{name,...}, goal?, platform?, language?}. Spends 0 credits but consumes 1 free AI assist (429 ai_assist_quota_exceeded when the daily quota is spent). Rate-limited to 30/min.

POST /api/campaign-packs/hook-variations 0 credits video:generate

Turn product facts + an optional brand_profile_id into 3–6 distinct hook variations, each with a hook, angle, suggested output format (editor_ad/short/carousel), a draft script, a caption, hashtags, and a per-variation credit estimate. Persists nothing. Body: {product_profile_id | product{...}, brand_profile_id?, count? 3..6, goal?, platform?, language?}. 0 credits, 1 free AI assist. Rate-limited to 30/min.

POST /api/campaign-packs 0 credits video:generate

Persist a pack plus any inline hook/caption TEXT items. editor_ad/short/carousel items are added via /variations (below), not here. Body: {title?, product_profile_id?, brand_profile_id?, brief?, source_type?, source_url?, language?, items?:[{kind:"hook"|"caption", ...}]}. Link a product/brand profile so materialized drafts inherit the image + logo. GET /api/campaign-packs lists your packs; GET /api/campaign-packs/:id returns one with its items.

POST /api/campaign-packs/:id/variations 0 credits video:generate

Persist selected hook variations as items under the pack and, by default (materialize:true), turn each into a DRAFT. Body: {variations:[{suggested_format:"editor_ad"|"short"|"carousel", hook?, angle?, draft_script?, caption?, hashtags?, credit_estimate?}], materialize?}. At most 12 variations per call; rate-limited to 15/min. Per-variation best-effort: one that can't be materialized stays a "planned" item with a recorded reason while the rest still materialize — the response summary lists created/materialized/planned + skipped_reasons.

POST /api/campaign-packs/:id/items/:item_id/materialize 0 credits video:generate

Materialize one planned editor_ad/short/carousel item into a DRAFT and link it back to the item. Idempotent — re-calling a just-materialized item returns the existing draft (no duplicate). Rate-limited to 60/min. 422 for a hook/caption item, an already-materialized item, or when you hit a draft limit. Each item carries its status (planned → draft → generating → completed | failed), the draft slug + deep-link route (editor_ad/short → the video project, carousel → the slider), and a credit estimate. Generate each draft EXPLICITLY next: POST /api/editor/:slug/autopilot for videos, POST /api/sliders/:slug/generate for carousels.

Series & episodes

A series is a reusable recurring-content show template ("Myth vs Fact", "Founder Tip") bound to an optional brand + product profile. The planner drafts upcoming episode IDEAS (free AI-assist quota); materialize turns an idea into a 0-credit draft grouped under the series' rolling campaign pack. Reads need video:read, writes need video:generate; all 0 credits.

GET /api/series video:read

List / read your series (GET /api/series/:id for one). GET /api/series/:id/dashboard returns the series plus its episodes grouped into lanes — upcoming (ideas), drafted, published — with per-item performance notes.

POST /api/series 0 credits video:generate

Create a show template. Body: {name (required), template (required), brand_profile_id?, product_profile_id?, cadence?, tone?, default_format?, status?}. default_format is editor_ad/short/carousel; status toggles active/paused/archived (a paused series stops surfacing new ideas). A supplied brand/product profile must be your own (422 invalid_brand_profile / invalid_product_profile). PATCH /api/series/:id patches any field; DELETE removes it (204).

POST /api/series/:id/episodes/plan 0 credits video:generate

Draft 3–5 upcoming episode ideas (status "idea") for the series, grounded on its product profile and steered by its brand/tone/cadence. Body: {count? 3..5, language?}. Persists them as episodes. 0 credits but consumes 1 free AI assist (429 when the quota is spent). Rate-limited to 30/min. GET /api/series/:id/episodes lists episodes (optional ?status filter).

POST /api/series/:id/episodes/:episode_id/materialize 0 credits video:generate

Turn an "idea" episode into an editable 0-credit DRAFT under the series' rolling pack, inheriting the series' brand. Idempotent — re-calling a just-materialized episode returns the existing draft. Rate-limited to 60/min. 422 for an already-materialized / non-idea episode or a draft limit. The episode carries its draft slug + route; generate it explicitly next.

POST /api/series/:id/episodes/:episode_id/mark-posted 0 credits video:generate

Record that you posted a DRAFTED episode manually (drafted → published, stamps posted_at) — there is no platform posting API. Idempotent: re-marking a published episode returns 200 with the original posted_at. 422 invalid_status for an idea/archived episode (materialize it first); 404 for an unknown/foreign series or episode.

Performance

Attach lightweight performance snapshots + creative-attribute tags to a campaign-pack ITEM (item_id from GET /api/campaign-packs/:id), then read a CAUTIOUS aggregate over your own items. Reads need video:read, writes need video:generate; all 0 credits (only make-more consumes the free AI-assist quota).

POST /api/campaign-pack-items/:item_id/performance 0 credits video:generate

Record one performance snapshot. Body: {platform, views?, likes?, comments?, shares?, clicks?, conversions?, revenue?, watch_time_seconds?, source?, captured_at?, metadata?}. Append-only — many snapshots per item (captured_at, defaulting to now, distinguishes them), NOT idempotency-keyed, so don't blindly retry. Rate-limited to 30/min. GET /api/campaign-pack-items/:item_id/performance lists them (newest first) with a small summary. 404 for a foreign/unknown item.

PUT /api/campaign-pack-items/:item_id/attributes 0 credits video:generate

Insert-or-update the SINGLE creative-attributes row the recommendation loop correlates with performance. Body: {hook_type?, creative_format?, visual_language?, product_category?, cta?, length?, caption_style?, metadata?}. Atomic upsert — calling it again updates the same row. GET /api/campaign-pack-items/:item_id/attributes returns the row or null. 404 for a foreign/unknown item.

GET /api/performance/recommendations video:read

Aggregate your items that have BOTH attributes AND ≥1 snapshot into a cautious recommendation: the top hook_type / creative_format / visual_language by average views, your best items, and an HONEST confidence ("none" / "low" / "moderate", a function of sample size) plus a non-causal note. It NEVER claims causation, ROAS, or attribution — these are correlations in your own data, a hint to test. Present the note + confidence as-is. No LLM, no quota, no credits.

POST /api/campaign-pack-items/:item_id/make-more 0 credits video:generate

Generate 3 follow-up hook variations from a winning item, threading its angle/format/hook while keeping its brand + product profile. 0 credits but consumes 1 free AI assist (429 when spent). Rate-limited to 30/min. 404 for a foreign/unknown item; 422 when the item has no usable product context. Feed a chosen variation into /variations to draft it.

Asset catalog

Your reusable asset library (images + videos). Uploading here lets an asset be reused across projects via the from-asset routes on shorts / sliders / editor / tracking. Reads need video:read, writes need video:generate; all 0 credits. Subscription-gated: these routes return 402 subscription_required without an active subscription.

GET /api/assets video:read

List your catalog (newest first), each with media_type, mime, size, description, status, and a presigned asset_url; meta.quota carries your storage quota. Optional ?limit / ?offset (offset = page * limit). GET /api/assets/quota returns just the quota (limit / used / pending / reserved / remaining bytes).

POST /api/assets/presign 0 credits video:generate

Presign an upload. Body: {filename, mime_type, size_bytes} → { data: {asset_id, presigned_url, s3_key} }. PUT the raw bytes to the presigned_url with header Content-Type: <the presigned mime>, then POST /api/assets/:id/confirm {duration_seconds?, width?, height?, description?} — confirm HEADs the object and validates size/type/duration (422 on mismatch). Images ≤ 20 MiB; videos ≤ 500 MB and REQUIRE duration_seconds ≤ 60. Rate-limited to 30/min.

Scheduled posts (content calendar)

Schedule a COMPLETED render for a future posting time. A reminder-only schedule (no social account attached) fires a push notification at the scheduled time — you post manually, then close the loop with mark-posted. An agent token (video:generate) can create, reschedule, cancel, and mark these reminder-only schedules, so it can run the whole content calendar. Attaching a social account turns a schedule into an AUTO-PUBLISH: that additionally requires account:admin and is app-only (do it signed in to the app), and auto-posting to TikTok/Instagram is gated on posting access besides. Reads need video:read.

GET /api/scheduled-posts video:read

List your scheduled posts, ordered by scheduled_at ascending. Optional query params: ?from= / ?to= (ISO-8601 UTC bounds on scheduled_at) and ?status= (scheduled | reminded | posted | publishing | published | failed | canceled).

GET /api/scheduled-posts/:id video:read

Fetch one of your scheduled posts. 404 for unknown/foreign ids.

POST /api/scheduled-posts 0 credits video:generate

Schedule one of your COMPLETED renders for a future UTC instant (scheduled_at) on a target platform. WITHOUT a social account id this is a reminder-only schedule — an agent token (video:generate) can create it. WITH an account id (tiktok_account_id for "tiktok", instagram_account_id for "instagram", one of your own accounts) it is an AUTO-PUBLISH that additionally requires account:admin (403 insufficient_scope otherwise) AND that platform's posting feature (403 feature_disabled). Optionally grouped under a campaign pack item. Persists the schedule only; spends NO credits. At most 500 pending posts per user (422 scheduled_post_limit_reached beyond).

PATCH /api/scheduled-posts/:id 0 credits video:generate

Reschedule a "scheduled" or "reminded" post to a new FUTURE scheduled_at (a reminded post is re-armed to "scheduled"). video:generate manages a reminder-only post; a post that carries a social account (or an update attaching one) additionally requires account:admin (403 insufficient_scope). 422 not_reschedulable / scheduled_in_past / invalid_scheduled_at; 404 for unknown/foreign ids.

DELETE /api/scheduled-posts/:id 0 credits video:generate

Cancel a "scheduled", "reminded", or "failed" post (status -> "canceled"). video:generate cancels a reminder-only post; canceling a post that carries a social account additionally requires account:admin (403 insufficient_scope). 422 not_cancelable for an already posted/publishing/published/canceled post; 404 for unknown/foreign ids.

POST /api/scheduled-posts/:id/mark-posted 0 credits video:generate

Confirm you posted this content manually: transitions a "scheduled" or "reminded" post to "posted" and stamps posted_at. Atomic — a post in any other status (including an already-posted one, so a second call) returns 422 not_markable. 404 for unknown/foreign ids. Touches no platform API; spends NO credits. Lets an agent close the reminder loop after posting.

An agent token (video:generate) can create and manage REMINDER-ONLY scheduled posts, so it can run the content calendar end to end. Attaching a social account (auto-publish) additionally requires account:admin, which agent tokens never carry — do that signed in to the app.

AI assists

AI helper calls draw from a free daily quota of 20, account-wide — separate from credits. This covers every content-drafting endpoint: the editor's generate-scenario, generate-narration, enhance-prompt, and suggest-* (music-prompt / next-scene); shorts text/generate; campaign-packs plan and hook-variations; series episodes/plan; and make-more. When it runs out you either unlock more (1 credit → +10) or write the content yourself with the free PATCH endpoints.

GET /api/ai-assists video:read

The current quota.

200 OK
{
  "data": {
    "used": 4, "limit": 20, "bonus": 0, "remaining": 16,
    "resets_at": "2026-06-04T00:00:00Z",
    "unlock_cost": 1, "unlock_batch_size": 10
  }
}
POST /api/ai-assists/unlock 1 credit video:generate

Spend 1 credit for +10 assists. It's a repeatable purchase — do NOT send a stable Idempotency-Key (a reused key replays the first unlock for 24h, silently skipping later purchases).

Errors, rate limits & polling

HTTP error What to do
401 Unauthorized Bad/expired/missing token — re-auth.
402 credits_insufficient Out of credits (body has required_credits/available_credits). Stop, don't loop.
403 insufficient_scope Token lacks the scope (e.g. video:generate).
409 *_in_progress Already running — keep polling, don't re-POST.
422 idempotency_key_required Send Idempotency-Key. For input, spend-cap, retry, or cancellation commands, reuse it only for transport retries of the exact same fenced body.
422 editor_finalize_requires_draft_take Finalize ran on a scene whose current take is not on a draft-tier model (details carry model and tier). Only a draft take can be promoted — regenerate instead.
422 editor_finalize_target_tier_invalid Finalize named a target model that is itself draft-tier (details carry model and tier). Pick a standard or premium entry from video_models.
422 validation Fix fields; body is errors: field → messages.
429 ai_assist_quota_exceeded Daily assist quota used up — unlock once or write content yourself. NOT a credit error.

402

402 Payment Required
{ "error": "credits_insufficient", "required_credits": 15, "available_credits": 3 }

429

429 Too Many Requests
{ "error": "ai_assist_quota_exceeded", "remaining": 0,
  "can_unlock": true, "unlock_cost": 1, "unlock_batch_size": 10 }

Rate limits

Where a route is rate-limited, the per-user cap is listed below; requests over it are rejected — back off for a minute and retry. Everything else is governed only by credits and the AI-assist quota.

Routes Cap
Editor create · scene generate/regenerate/finalize · batch/render/render retry 10/min each bucket
Editor batch recovery retry · cancel 3/min retry · 5/min cancel
Editor autopilot start 5/min
Editor voice · narration/assist 15/min (music 10/min)
Editor voice-plan (voice spine) · pacing mode switch 10/min voice-plan · 20/min pacing
Slider generate 10/min
Slider restyle · per-slide edit 20/min each
Product sources (extract / preview / import) · campaign plan · hook variations · make-more · series episode planning · performance snapshots · asset presign 30/min
Editor upload presign / confirm / multipart init·complete·abort / metadata (the per-part multipart sign is 300/min) 30/min (sign-part 300/min)
Editor product/closing/logo presign · confirm · impact 30/min
Campaign-pack variations 15/min
Materialize (pack item · series episode) 60/min
Result URLs (video_url) are presigned MP4s with a ~24h TTL — download promptly, they're not permalinks. For Editor and Short, poll only while generation_summary.timing.poll_after_ms is a positive integer, waiting that exact server cadence; null means stop. Generation takes a few minutes (autopilot can run longer). A caller may enforce a stated max wait, but must report the last authoritative summary. Publishing to TikTok/Instagram needs a human-linked account — return the MP4 + a caption instead.