Skip to content

API Endpoints

Sapari exposes a REST API at /api/v1/. All endpoints require authentication via session cookies (set by the auth flow). This page covers the main endpoints.

Authentication

Session-based auth with CSRF protection. Login sets an HTTP-only session cookie + CSRF token.

Signup

POST /api/v1/users/
Content-Type: application/json

{
    "name": "Jane Doe",
    "username": "janedoe",
    "email": "jane@example.com",
    "password": "SecurePass123!"
}

Returns 201 with the created user. Sends a verification email. User cannot login until email is verified. Rate limited: 5 per IP per 10 minutes.

Login

POST /api/v1/auth/login
Content-Type: application/x-www-form-urlencoded

username=jane@example.com&password=SecurePass123!

Sets the session cookie and returns { csrf_token, user, billing } — the same shape as /auth/check-auth. Bundling user + billing in the login response saves the frontend a follow-up check-auth round trip. Returns 403 if email not verified. Rate limited per IP and per username with exponential-backoff lockout — exceeding LOGIN_MAX_ATTEMPTS failed attempts within LOGIN_ATTEMPT_WINDOW_SECONDS triggers a lockout that doubles per round (LOGIN_LOCKOUT_BASE_SECONDS × 2^round, capped at LOGIN_LOCKOUT_MAX_SECONDS). Returns 429 with a Retry-After header in seconds, not 401. Successful login clears all lockout state.

Logout

POST /api/v1/auth/logout

Clears session cookie.

Logout All Sessions

POST /api/v1/auth/logout-all

Terminates all active sessions for the current user across all devices. Returns terminated_count. Also called automatically after password reset.

Email Verification

POST /api/v1/auth/send-verification
Content-Type: application/json

{"email": "jane@example.com"}

Sends verification email with a signed JWT link. Always returns 200 (prevents email enumeration). Rate limited: 3 per IP per 5 minutes.

POST /api/v1/auth/verify-email
Content-Type: application/json

{"token": "eyJ..."}

Verifies the token and sets email_verified=true. Sends welcome email on success.

Password Reset

POST /api/v1/auth/forgot-password
Content-Type: application/json

{"email": "jane@example.com"}

Sends password reset email. Always returns 200. Rate limited: 3 per IP per 5 minutes.

POST /api/v1/auth/reset-password
Content-Type: application/json

{"token": "eyJ...", "new_password": "NewSecurePass456!"}

Resets the password using the signed token. Token expires after 1 hour.

Google OAuth

GET /api/v1/auth/oauth/google

Returns {"url": "https://accounts.google.com/..."}. Frontend redirects user to this URL. Google calls back to /api/v1/auth/oauth/callback/google which creates/links the user and establishes a session. OAuth users skip email verification.

Check Auth

GET /api/v1/auth/check-auth

Used by frontend on page load to restore session. When authenticated, the response also bundles the CSRF token and the full billing-status payload so the frontend can skip follow-up calls to /auth/csrf and /users/me/billing:

{
  "authenticated": true,
  "user": { "...": "..." },
  "session": { "...": "..." },
  "oauth_providers": ["google"],
  "csrf_token": "<hex>",
  "billing": { "...same shape as /users/me/billing..." }
}

When unauthenticated, only {"authenticated": false, "message": "...", "oauth_providers": [...]} is returned. Response always carries Cache-Control: no-store.

The user object on both /auth/check-auth and /auth/login carries locale (a SUPPORTED_LOCALES value, default "en"); the SPA passes this to reconcileServerLocale() to flip i18next server-wins when the persisted value differs from the local LanguageDetector pick. See docs/development/frontend.md §Backend round-trip.

CSRF self-heal. check-auth validates the csrf_token cookie against Redis on every authenticated call and regenerates it if storage doesn't recognize it. For a normal session the csrf_token cookie's Max-Age (SESSION_COOKIE_MAX_AGE, 24h) is intentionally longer than the CSRF storage TTL (SESSION_TIMEOUT_MINUTES, default 30m), so an idle user's cookie can outlive its Redis entry. Without this check, the next mutation would 403 with "CSRF token not found" until the user explicitly logged out and back in; with it, the next check-auth round trip mints a fresh token transparently. For a remember-me session both the CSRF cookie Max-Age and the CSRF storage TTL are extended to the session's real lifetime (SESSION_REMEMBER_ME_DAYS) via auth.sessions.timeout_seconds_for(metadata), so the token doesn't expire ahead of the 30-day session.


Billing & Credits

Get Billing Status

GET /api/v1/users/me/billing

Returns the user's current tier, subscription status, trial info, credit balance, and usage limits. Used by the frontend to gate features, show usage indicators, and prevent actions when at tier limits.

{
    "tier": "creator",
    "subscription_status": "trialing",
    "trial_ends_at": "2026-03-24T00:00:00+00:00",
    "credits": {
        "ai_minutes": { "balance": 25, "reserved": 0 }
    },
    "has_active_plan": true,
    "trial_used": true,
    "billing_interval": "month",
    "discount_used": false,
    "is_beta": false,
    "beta_credits_used": null,
    "beta_credits_total": null,
    "beta_renewal_date": null,
    "storage_used_bytes": 1258291200,
    "storage_quota_bytes": 26214400000,
    "project_count": 3,
    "max_projects": 10,
    "can_use_ai_director": true,
    "can_watermark_free": false,
    "can_access_support": false
}

can_watermark_free mirrors the render worker's watermark decision so the frontend preview overlay matches the exported video — both consume tier_ctx.can_watermark_free. can_access_support gates the "Contact Support" form (true for paid subscribers + beta testers). is_beta and the beta_credits_* fields surface beta-program state for users granted access via POST /admin/beta/grant.

Activate Trial

POST /api/v1/users/me/activate-trial

Activates the 7-day free trial (30 AI minutes, Creator tier). Returns 409 if trial already used (even across deleted accounts with the same email). Trial is also auto-granted on email verification and OAuth signup.

Estimate Analysis Cost

GET /api/v1/projects/{project_uuid}/estimated-credits

Returns the estimated AI minutes an analysis will cost, the user's current balance, and whether they have enough.

{
    "estimated_minutes": 5,
    "balance": 25,
    "sufficient": true
}

Subscription Management

POST /api/v1/payments/subscription/confirm-upgrade
Content-Type: application/json

{"price_id": 5}

Executes a subscription upgrade with prorating. Charges the prorated difference to the card on file immediately via Subscription.modify(always_invoice). Revokes old tier entitlements and grants new ones.

POST /api/v1/payments/subscription/schedule-downgrade
Content-Type: application/json

{"price_id": 2}

Schedules a downgrade at the end of the current billing period. No immediate change — user keeps current tier until renewal.

POST /api/v1/payments/subscription/cancellation-feedback
Content-Type: application/json

{"reason": "too_expensive", "detail": null, "outcome": "cancelled"}

Records cancellation feedback and executes the chosen outcome. Outcomes: cancelled (immediate), reminder_set, discount_accepted, kept_plan.

POST /api/v1/payments/subscription/support-request
Content-Type: application/json

{"reason": "poor_quality", "message": "Audio cleanup isn't working on my videos"}

Sends priority support email to team (with user context: tier, credits, paid status) and confirmation to user (FAQ + Discord links). Used during cancellation flow for quality/features/competitor reasons.

Change Password

POST /api/v1/auth/change-password
Content-Type: application/json

{
  "current_password": "OldPass123!",
  "new_password": "NewPass456!"
}

Changes password for authenticated user. Rate limited (5/300s). Non-OAuth users must provide current_password. OAuth users can omit it (sets password for first time). All sessions terminated after change.

Request Email Change

POST /api/v1/auth/request-email-change
Content-Type: application/json

{
  "new_email": "newemail@example.com",
  "password": "CurrentPass123!"
}

Requires current password. Sends verification link to new email, security notification to old email. Rate limited (3/300s). Returns same message regardless of whether email is taken (anti-enumeration).

Confirm Email Change

POST /api/v1/auth/confirm-email-change
Content-Type: application/json

{
  "token": "eyJ..."
}

Public endpoint (no auth required). Token contains embedded new_email claim. Re-checks email uniqueness. Returns 409 if email was taken since request.

Verify Password

POST /api/v1/auth/verify-password
Content-Type: application/json

{
  "password": "CurrentPass123!"
}

Verifies the current user's password without side effects. Used before destructive actions (e.g., account deletion). Returns 400 if incorrect.

Credit Check on Analysis

POST /projects/{uuid}/analyze now checks credits before queuing. Returns 402 Payment Required with "Need X AI minutes, have Y." if insufficient. Credits are reserved before analysis and deducted on success (or released on failure).


Projects

Projects are the top-level container. All other resources (media, canvases, edits, exports) belong to a project.

Create Project

POST /api/v1/projects/
Content-Type: application/json

{
    "name": "My Video"
}

Returns the created project with status: "created".

List Projects

GET /api/v1/projects/?page=1&items_per_page=20

Returns paginated list of the user's projects, newest first.

Get Project

GET /api/v1/projects/{project_uuid}

Returns full project details including settings and transcript (if analyzed).

Trigger Analysis

POST /api/v1/projects/{project_uuid}/analyze
Content-Type: application/json

{
    "pacing_level": 50,
    "false_start_sensitivity": 50,
    "language": null,
    "director_notes": "Place the logo when the speaker introduces themselves"
}

Queues the analysis pipeline. Returns immediately with status: "analyzing". Use SSE or polling to track progress.

Parameters: - pacing_level (0-100): Higher = more aggressive silence removal - false_start_sensitivity (0-100): Higher = more false starts detected - language: ISO code like "en" or "pt-BR", or null for auto-detect - director_notes (optional): Free-text instructions for the AI Director when placing assets

Returns 422 ValidationError if every source in the project is silent (each canvas's CANVAS_FILL media has MediaFile.has_audio=false — iOS ReplayKit screen recordings without microphone, etc.) and analysis_mode is ai_edit or captions_only — Whisper / silence / false-start would no-op on synthesized silent tracks while still reserving credits. Mixed-silent projects (at least one source with audio) pass — the pipeline's per-canvas audio-skip filter handles silent sources internally.

Stream Events (SSE)

GET /api/v1/projects/{project_uuid}/events
Accept: text/event-stream

Opens a Server-Sent Events connection. Events include:

Event When
analysis_started Analysis pipeline queued/started
analysis_progress Analysis step completed
analysis_complete All edits created
analysis_failed Analysis pipeline failed
export_started Render started
export_progress Render step completed
export_complete Render finished
export_failed Render failed

The project stream carries analysis + export events only. Media-processing events (media_ready / media_failed) are user-scoped and arrive on the separate GET /api/v1/user-stream SSE connection, not the project stream.

Analysis Runs

Each analysis creates an AnalysisRun record that owns the resulting edits, captions, and transcript. Users can switch between runs.

List Analysis Runs

GET /api/v1/projects/{project_uuid}/analysis-runs?limit=20

Returns runs sorted newest-first. Uses lightweight schema (no transcript/cost data). Fields: uuid, status, pacing_level, false_start_sensitivity, language, edit_count, silence_count, false_start_count, credits_charged, duration_ms, created_at.

Activate Analysis Run

POST /api/v1/projects/{project_uuid}/analysis-runs/{run_uuid}/activate

Switches the project's active run. Copies transcript to project, sets active_run_id. Edit, caption, and draft list endpoints automatically return the new run's data. Returns 422 if run is not completed or doesn't belong to the project.

Media

Media is the unified upload surface over the MediaFile + MediaItem model — it is authoritative for every video/image/audio source. (The legacy /clips and /assets routers were removed in the canvas-media migration.) A MediaFile is the content-addressable file in STORAGE_BUCKET_MEDIA (deduplicated by sha256_hash or youtube_video_id); a MediaItem is a per-user reference to it. Each item carries a scope: library (reusable, no project) or project (bound to a project the caller owns; scope_project_id required).

Upload picks one of two paths by file size, both ending in a HEAD-based quota recheck against the actual uploaded size (the declared size_bytes at presign/initiate is a UX early-reject only). Files below MULTIPART_CUTOFF_BYTES (25 MiB) take the single-PUT presign+confirm path; files at or above it take multipart. Confirm/complete leave the MediaFile at PROCESSING and enqueue process_media_artifacts (download worker), which computes the sha256 content hash (deduplicating against an identical existing MediaFile), generates the light artifacts (audio / waveform / thumbnail / dimensions), and flips → UPLOADED. The 480p proxy + scrub sprite are a separate post-UPLOADED task (generate_media_proxy, proxy broker) for non-web-compatible video; MediaFileRead.proxy_status (not_applicable / pending / ready / unknown) tracks it.

Request Upload URL (single-PUT)

POST /api/v1/media/presign
Content-Type: application/json

{
    "filename": "recording.mp4",
    "content_type": "video/mp4",
    "size_bytes": 104857600,
    "scope": "project",
    "scope_project_id": "project-uuid",
    "display_name": "Optional custom name",
    "tags": ["optional"]
}

Creates a MediaFile (PENDING) + MediaItem and returns a presigned PUT URL for direct upload to R2 (201):

{
    "media_file_id": "...",
    "media_item_id": "...",
    "upload_url": "https://r2.../...",
    "content_type": "video/mp4",
    "expires_in": 3600
}

scope_project_id is required when scope=project and must reference a project the caller owns. Validation: rejects with 422 if the file exceeds the per-file max (STORAGE_MAX_UPLOAD_SIZE_MB, default 2GB), the content type is not allowed, or the upload would exceed the user's tier storage quota (Free: 500MB, Hobby: 2GB, Creator: 25GB, Viral: 100GB). These are early-rejects based on the declared size; actual enforcement happens at confirm.

Confirm Upload

POST /api/v1/media/{media_file_id}/confirm

Call after uploading to R2. Backend HEADs the object, uses the actual Content-Length (not the client-declared size) to re-check the tier storage quota, deletes the object + fails the confirm if over quota, otherwise increments User.storage_used_bytes by the actual size, flips MediaFile.statusPROCESSING, and enqueues process_media_artifacts. Returns MediaFileRead. R2 does not implement PostObject, so the upload-edge content-length-range enforcement AWS S3 supports via presigned POST is unavailable — the HEAD-based recheck at confirm is the authoritative quota gate.

Import from YouTube

POST /api/v1/media/youtube
Content-Type: application/json

{
    "url": "https://youtube.com/watch?v=...",
    "scope": "project",
    "scope_project_id": "project-uuid",
    "display_name": "Optional custom name",
    "tags": ["optional"]
}

Superuser-only while the feature is admin-only-functional (public rollout waits for a bot-safe download server); a non-superuser gets 403. Accepts PROJECT or LIBRARY scope. Video metadata is fetched synchronously (fetch_video_info) and the request is rejected with 422 if the resolved duration exceeds MAX_YOUTUBE_DURATION_SECONDS (3600) or metadata can't be fetched — Convention #13 (validate against resolved metadata, not URL shape). On pass, the download runs in the download_youtube_media worker, kicked after the row commits. Returns 202 with { "media_file_id", "media_item_id", "deduplicated" }deduplicated is true when an existing MediaFile with the same youtube_video_id was reused (no re-download).

Multipart Upload (files ≥ 25 MiB)

Files at or above MULTIPART_CUTOFF_BYTES (25 MiB) skip the single-PUT presign+confirm flow above and use multipart instead — gets parallel uploads, mid-stream resume, and per-part retry. Files below the cutoff use the single-PUT path. MediaFile lifecycle: PENDING → MULTIPART_INITIATED → PROCESSING → UPLOADED (or FAILED after PROCESSING; rows still in MULTIPART_INITIATED are deleted on cancel/over-quota/cron sweep, not flipped to FAILED).

Constants in backend/src/infrastructure/storage/constants.py:

Constant Value Purpose
MULTIPART_CUTOFF_BYTES 25 MiB Below this, use single-PUT
MULTIPART_PART_SIZE_BYTES 16 MiB Per-part upload chunk
MULTIPART_MAX_PARTS 10,000 S3/R2 hard limit
MULTIPART_PART_URL_EXPIRY_SECONDS 7200 (2 hr) Per part URL
MULTIPART_INITIAL_URL_BATCH_SIZE 50 Upfront URLs at initiate; rest via refill

ETag handling — load-bearing: R2 returns the per-part ETag in the upload-part response's ETag header as a quoted string ("abc..." with literal double-quotes). The client MUST round-trip these byte-exact when calling multipart/complete — stripping the quotes will fail with MultipartCompleteError (HTTP 409, R2 InvalidPart).

Initiate

POST /api/v1/media/multipart/initiate
Content-Type: application/json

{
  "filename": "feature_film.mp4",
  "content_type": "video/mp4",
  "size_bytes": 2147483648,
  "scope": "project",
  "scope_project_id": "project-uuid",
  "display_name": "Optional custom name",
  "tags": ["optional"]
}

Validates ownership, content type, storage quota (against declared size), and bounds (size in [25 MiB, tier ceiling], parts_count <= MULTIPART_MAX_PARTS). Calls R2 create_multipart_upload, creates the MediaFile + MediaItem at MULTIPART_INITIATED, mints up to MULTIPART_INITIAL_URL_BATCH_SIZE part URLs upfront. Returns 201:

{
  "media_file_id": "...",
  "media_item_id": "...",
  "upload_id": "<r2-upload-id>",
  "key": "media/.../filename.mp4",
  "part_size_bytes": 16777216,
  "parts_count": 128,
  "parts": [
    {"part_number": 1, "url": "https://..."},
    {"part_number": 2, "url": "https://..."}
  ],
  "expires_in": 7200
}

The frontend uploads parts up to len(parts) then refills via multipart/parts/urls for the remainder. Convention #16 split-session: validate (session 1) → R2 create_multipart_upload + presigning (no session) → row creation (session 2).

Complete

POST /api/v1/media/multipart/complete
Content-Type: application/json

{
  "media_file_id": "...",
  "parts": [
    {"part_number": 1, "etag": "\"<r2-etag-with-literal-quotes>\""},
    {"part_number": 2, "etag": "\"<r2-etag-with-literal-quotes>\""}
  ]
}

Convention #16 split-session: read state (session 1) → R2 complete_multipart_upload + HEAD (no session) → quota recheck + status flip + storage accounting (session 2). The route flips the MediaFile to PROCESSING and then calls process_media_artifacts.kiq(...) (sha256 dedup + audio/waveform/thumbnail/dimensions, then proxy for non-web-compatible video). Returns MediaFileRead. Idempotent on user double-click — re-call with status=PROCESSING or status=UPLOADED returns the existing row without touching R2.

Errors: 409 MultipartCompleteError if R2 rejects the parts (most often: ETag quote-stripping); 400 ValidationError if quota recheck fails post-complete (object deleted, status flipped to FAILED).

Abort

POST /api/v1/media/multipart/abort
Content-Type: application/json

{ "media_file_id": "..." }

Deletes the MediaItem + MediaFile rows and commits BEFORE calling R2 abort_multipart_upload. Even if R2 rejects the abort, the DB is clean; orphan parts on R2 get GC'd by the bucket's lifecycle policy. Rationale: a zombie FAILED row with a Retry button can't actually resume (R2's abort destroys the parts), so deletion matches the user's mental model of "cancel = undo this upload". Returns 204 No Content.

List Uploaded Parts (resume)

GET /api/v1/media/multipart/parts?media_file_id=<uuid>

Lists parts already on R2 for an in-flight multipart upload. The frontend uses this on retry / page refresh to skip parts already uploaded and only push the missing ones. Returns:

{
  "uploaded_parts": [
    {"part_number": 1, "etag": "\"<etag>\"", "size_bytes": 16777216},
    {"part_number": 3, "etag": "\"<etag>\"", "size_bytes": 16777216}
  ]
}

Paginates IsTruncated/NextPartNumberMarker internally; callers always see the full list.

Refill Part URLs

GET /api/v1/media/multipart/parts/urls?media_file_id=<uuid>&from_part=51&to_part=100

Mints a fresh batch of presigned PUT URLs for the requested range, returning { "parts": [...], "expires_in": 7200 }. The frontend calls this proactively as the upload position approaches the end of its in-memory URL pool. Each URL has a fresh expiry timestamp at mint time, so a slow upload can exceed the original 2-hour expiry by getting fresh URLs as it goes.

Returns 400 ValidationError if the range is invalid (e.g., from_part < 1, to_part > parts_count, from_part > to_part).

List Media

GET /api/v1/media?scope=project&scope_project_id=<uuid>&page=1&items_per_page=50

Returns the caller's MediaItems (joined with their MediaFile) in a paginated response. Filters: scope (library / project), scope_project_id, group_id, ungrouped, behavior_when, unconfigured. Each item carries the joined file metadata (duration, waveform_json, dimensions, proxy_status, etc.) — there is no separate waveform endpoint; waveform peaks ride the media read schema.

Get Media Item

GET /api/v1/media/{media_item_id}

Returns one MediaItem the caller owns.

Update Media Item

PATCH /api/v1/media/{media_item_id}
Content-Type: application/json

{
    "display_name": "New Name",
    "tags": ["updated", "tags"],
    "behavior_defaults": { "...": "..." }
}

Updates display_name / tags / behavior_defaults (the write side of media b-roll config). Returns the updated MediaItem.

Reorder Project Media

POST /api/v1/media/reorder
Content-Type: application/json

{
    "project_id": "project-uuid",
    "media_item_ids": ["item-uuid-1", "item-uuid-2", "item-uuid-3"]
}

Reorders a project's media spine to the given MediaItem order, re-tiling the Canvas SINGLE spine to match. Rejects a composed or incomplete spine. Returns 204 No Content.

Get Proxy URL

GET /api/v1/media/{media_item_id}/proxy

Returns a short-lived worker-fronted URL for playback (minted over STORAGE_BUCKET_MEDIA). The browser uses this URL as the src of a <video> element; the Cloudflare Worker at /media/v1/<jwt> verifies the JWT and streams bytes from R2.

{
    "url": "https://staging.sapari.io/media/v1/<jwt>",
    "expires_in": 300,
    "sprite": {
        "url": "https://staging.sapari.io/media/v1/<sprite-jwt>",
        "expires_in": 300,
        "tile_width_px": 160,
        "tile_height_px": 90,
        "tiles_per_row": 10,
        "total_tiles": 200,
        "seconds_per_tile": 1
    }
}

The URL expires after MEDIA_TOKEN_TTL_SECONDS (default 300 / 5 min). For playback sessions longer than the TTL, the frontend retry handler detects the 401 on the next range request and refetches transparently. The sprite field is null until the 480p proxy + scrub sprite finish generating; once ready, the response includes a minted sprite URL alongside the proxy URL. Sprite responses are cached at the edge with Cache-Control: public, max-age=31536000, immutable because the URL is content-addressable.

Delete Media

DELETE /api/v1/media/{media_item_id}

Deletes a MediaItem. Decrements the caller's storage_used_bytes on every delete (each item was charged its own size at confirm); on the last remaining reference it also deletes the MediaFile + storage object. For a scope=project item it additionally removes + re-tiles that item's Canvas. Returns 204 No Content.

Edit Media (Trim/Extract Audio)

POST /api/v1/media/{media_item_id}/edit
Content-Type: application/json

{
    "start_ms": 0,
    "end_ms": 30000,
    "extract_audio": false,
    "save_mode": "copy",
    "fast_mode": true,
    "cuts": [
        {"start_ms": 5000, "end_ms": 8000},
        {"start_ms": 15000, "end_ms": 18000}
    ]
}

Edits a LIBRARY media item by trimming or extracting audio. Fire-and-forget pattern - returns immediately (HTTP 202) with the new media item UUID (for copy mode).

Parameters: - start_ms, end_ms: Overall trim range (0 to end if not specified) - extract_audio: If true, extracts audio track only (outputs .m4a) - save_mode: "copy" creates a new media item, "replace" repoints the item onto the trimmed file in place (dedup-safe — the worker mints a fresh MediaFile and never mutates the shared source) - fast_mode: If true, uses stream copy (fast but cuts at keyframes) - cuts: Array of regions to remove (inverted to compute segments to keep)

Validation: rejects with 422 if the item is PROJECT-scoped (trim is a library operation), or if start_ms, end_ms, or any cuts[i].end_ms exceeds the media file's duration_ms (when known), or (Pydantic) if end_ms <= start_ms or cuts[i].end_ms <= cuts[i].start_ms. If the media file is still processing (duration_ms IS NULL), the bounds check is skipped and the worker silently clamps at render time.

Response:

{
    "new_media_item_uuid": "..."
}

The new media item is created with status: "pending". Media list polling + the media_ready / media_failed SSE events handle the transition to uploaded when processing completes.

Edits

Edits are detected cut points (silences, false starts).

List Edits

GET /api/v1/projects/{project_uuid}/edits/?active_only=false

Returns all edits for the project. Use active_only=true to filter to active edits.

Update Edit

PATCH /api/v1/edits/{edit_uuid}
Content-Type: application/json

{
    "active": false,
    "start_ms": 1000,
    "end_ms": 2500
}

Toggle edits on/off or adjust their boundaries. For asset edits, overlay and audio properties can also be updated:

PATCH /api/v1/edits/{edit_uuid}
Content-Type: application/json

{
    "visual_mode": "overlay",
    "audio_mode": "mix",
    "overlay_position": "top_right",
    "overlay_size_percent": 30,
    "overlay_opacity_percent": 80,
    "overlay_x": 0.5,
    "overlay_y": 0.5,
    "overlay_flip_h": true,
    "overlay_flip_v": false,
    "overlay_rotation_deg": 90,
    "audio_volume_percent": 50,
    "audio_duck_main": true,
    "asset_offset_ms": 5000
}

Create Manual Edit

POST /api/v1/projects/{project_uuid}/edits/
Content-Type: application/json

{
    "type": "manual",
    "start_ms": 5000,
    "end_ms": 6500,
    "active": true,
    "reason": "User-created cut"
}

Users can add their own cut points.

Validation: rejects with 400 if end_ms exceeds the project's total timeline duration (derived from the canvas spine), or if the project has no media. If any source is still processing (duration_ms IS NULL), the bounds check is skipped. Same validation applies to PATCH when end_ms is in the request body. INSERT-mode asset edits (type=asset, visual_mode=insert) are the only exception — they extend the timeline at render time, so they bypass the ceiling and are rejected with 400 only if end_ms - start_ms exceeds MAX_VIDEO_DURATION_MS (2h sanity cap to block oversized-edit overflow). All other asset visual modes (OVERLAY, REPLACE, NONE) follow the same ceiling rule as non-asset edits because the renderer doesn't extend for them. For asset edits, asset_offset_ms is separately bounds-checked against the referenced media item's duration_ms (on both POST and PATCH); swapping media_item_id via PATCH re-verifies the new source is uploaded (not pending/failed) before accepting the update. Updating visual_mode from INSERT to OVERLAY/REPLACE on an existing edit with end_ms > total is rejected — the post-update mode determines validation, so the same PATCH must lower end_ms to fit.

Delete Edit

DELETE /api/v1/edits/{edit_uuid}

Only manual edits can be deleted. AI-detected edits should be toggled inactive instead.

Drafts

Drafts save edit configurations.

Create Draft

POST /api/v1/projects/{project_uuid}/drafts/
Content-Type: application/json

{
    "name": "Tight Cut",
    "edit_overrides": {
        "edit-uuid-1": {"active": false},
        "edit-uuid-2": {"start_ms": 1000, "end_ms": 2000}
    },
    "export_settings": {
        "platform": "youtube",
        "resolution": "1080p",
        "aspect_ratio": "16:9"
    }
}

Validation: rejects with 400 if any edit_overrides[*].start_ms or edit_overrides[*].end_ms exceeds the project's total timeline duration (derived from the canvas spine), or if the project has no media. Same validation applies to PATCH /api/v1/drafts/{draft_uuid} when edit_overrides is in the request body. If any source is still processing (duration_ms IS NULL), the bounds check is skipped. start_ms and end_ms are bounds-checked independently when set — the cross-field end_ms > start_ms validator does not close the start_ms-only overflow case. Schema-level 422 is raised when both fields are supplied with end_ms <= start_ms.

List Drafts

GET /api/v1/projects/{project_uuid}/drafts/

Load Draft

GET /api/v1/drafts/{draft_uuid}

Returns the draft with its edit overrides and export settings.

Exports

Exports are rendered videos.

Trigger Render

POST /api/v1/projects/{project_uuid}/exports/
Content-Type: application/json

{
    "name": "Final Cut v1",
    "draft_id": "optional-draft-uuid",
    "export_settings": {
        "resolution": "1080p",
        "aspect_ratio": "16:9",
        "letterbox_background": "#000000",
        "audio_censorship": "mute",
        "audio": {
            "normalize": true,
            "noise_reduction": true
        },
        "captions": {
            "enabled": true,
            "style": "default",
            "font_size": 32
        }
    }
}

Validation: rejects with 400 if any edit_overrides[*].start_ms or edit_overrides[*].end_ms exceeds the project's total timeline duration (derived from the canvas spine), or if the project has no media. Overrides resolved from a saved draft_id were already bounds-checked at draft create/update time; only inline edit_overrides in the request body re-trigger the check here. If any source is still processing, the bounds check is skipped.

Export Settings:

Field Type Description
resolution "720p" \| "1080p" Output resolution
aspect_ratio "16:9" \| "9:16" \| "1:1" Target aspect ratio (adds letterbox if needed)
letterbox_background string Hex color for letterbox bars
audio_censorship "none" \| "mute" \| "bleep" How to handle profanity
video_flip_h boolean Flip main video horizontally (default: false)
video_flip_v boolean Flip main video vertically (default: false)
audio object Audio processing settings (see below)
captions object Caption/subtitle settings

Audio Settings (export_settings.audio):

Field Type Default Description
normalize boolean false Enable LUFS loudness normalization (-14 LUFS)
target_lufs number -14.0 Target loudness in LUFS
noise_reduction boolean false Enable FFT-based noise reduction
noise_floor number -25.0 Noise floor in dB (lower = more aggressive)
main_volume_percent integer 100 Main video volume (0-100%)

When normalize is enabled, audio is adjusted to -14 LUFS (YouTube/Spotify standard). When noise_reduction is enabled, background noise (AC, fans, room tone) is reduced. The main_volume_percent adjusts the overall video volume before other audio processing.

You can either reference a saved draft or provide inline settings. Returns immediately; use SSE to track progress.

Tier snapshots on the export record. expires_at (from tier_ctx.export_retention_days) and watermark_required (inverse of tier_ctx.can_watermark_free) are both computed at create time and written onto the ProjectExport row. The render worker reads those columns directly — it does not re-resolve tier_ctx at render completion. A user who pays for an export then downgrades (or the inverse) keeps the decision that was in force when the request was accepted. See docs/development/backend.md §Key Conventions #12.

List Exports

GET /api/v1/projects/{project_uuid}/exports/

Get Download URL

GET /api/v1/exports/{export_uuid}/download

Returns a presigned download URL:

{
    "uuid": "...",
    "url": "https://r2.../...",
    "expires_in": 3600,
    "filename": "Final Cut v1.mp4"
}

Media Groups

Media groups organize library MediaItems into categories (e.g., "Brand Assets", "B-Roll", "Music"), replacing the retired asset-groups surface. All routes are user-scoped.

List Groups

GET /api/v1/media-groups?page=1&items_per_page=50

Returns the caller's groups (paginated).

Create Group

POST /api/v1/media-groups
Content-Type: application/json

{
    "name": "Brand Assets",
    "description": "Company logos and branding",
    "default_instructions": "Use logo in intro and outro",
    "is_default": false,
    "is_pinned": true
}

Returns the created group (201).

Get Group

GET /api/v1/media-groups/{group_id}

Update Group

PATCH /api/v1/media-groups/{group_id}
Content-Type: application/json

{
    "name": "Updated Name",
    "is_default": true,
    "is_pinned": false,
    "display_order": 2
}

Delete Group

DELETE /api/v1/media-groups/{group_id}

Removes the group (204). Media items remain but lose their membership.

Add Media Item to Group

POST /api/v1/media/{media_item_id}/groups/{group_id}

Creates a membership link (201). A media item can belong to multiple groups.

Remove Media Item from Group

DELETE /api/v1/media/{media_item_id}/groups/{group_id}

Removes the membership (204).

To list a group's items, filter the media list: GET /api/v1/media?group_id=<uuid> (or ?ungrouped=true for items in no group).

Canvases

Canvases are the composition tiles over source-time that make up a project's timeline. A Canvas has a type (SINGLE / SPLIT / TALKING_HEAD / INSERT / GRID) and a (project_id, sequence_position) ordering. The structural editor owns canvas rows; the analysis pipeline reads the canvas sequence but never creates or mutates it.

The mutation-workflow endpoints (layout / swap / reorder / split / merge / delete) rebalance neighboring canvases in one transaction, so each returns the cascade-affected canvas + edit set for the client to reconcile its cache.

List Canvases

GET /api/v1/projects/{project_uuid}/canvases

Returns all canvases for the project in sequence order (CanvasRead[]).

Get Playback Spine

GET /api/v1/projects/{project_uuid}/spine

Returns the structural playback spine (SpineItem[]): each SINGLE canvas joined with its CANVAS_FILL media. Authoritative source for main-video playback; static metadata only (the per-item proxy URL is fetched separately via GET /media/{media_item_id}/proxy).

Get Canvas

GET /api/v1/projects/{project_uuid}/canvases/{canvas_uuid}

Create Canvas

POST /api/v1/projects/{project_uuid}/canvases
Content-Type: application/json

Creates a canvas (201, CanvasRead). Body is a CanvasCreate.

Update Canvas

PATCH /api/v1/projects/{project_uuid}/canvases/{canvas_uuid}
Content-Type: application/json

Updates mutable canvas fields (params, crop, aspect, letterbox, per-canvas flags). Returns CanvasRead.

Delete Canvas

DELETE /api/v1/projects/{project_uuid}/canvases/{canvas_uuid}?keep=asset

Type-specific delete (returns CanvasDeleteResult): INSERT drops; SPLIT / TALKING_HEAD convert to SINGLE (a TALKING_HEAD requires keep=asset or keep=clip to pick which source survives); SINGLE is rejected.

Create Layout Canvas

POST /api/v1/projects/{project_uuid}/canvases/layout
Content-Type: application/json

{
    "canvas_type": "SPLIT",
    "t1": 5000,
    "t2": 12000,
    "co_star_media_item_id": "media-item-uuid",
    "params": { "...": "..." }
}

Creates a SPLIT or TALKING_HEAD over a source-time range [t1, t2), splitting the covering canvas. Returns CanvasLayoutResult.

Returns 422 today. Both types sit in DEFERRED_CANVAS_TYPES (modules/canvas/constants.py) because the renderer cannot compose them yet — load_canvas_spine_for_render filters to SINGLE, so a created layout would vanish from the export. The endpoint opens automatically when the type joins RENDERABLE_CANVAS_TYPES. See docs/architecture/canvas-composition.md.

Swap Adjacent Canvases

POST /api/v1/projects/{project_uuid}/canvases/swap
Content-Type: application/json

{
    "canvas_a_uuid": "...",
    "canvas_b_uuid": "..."
}

Swaps two adjacent non-INSERT canvases. Returns CanvasReorderResult.

Reorder INSERT Canvas

POST /api/v1/projects/{project_uuid}/canvases/{canvas_uuid}/reorder
Content-Type: application/json

{
    "new_sequence_position": 4
}

Moves an INSERT chip to a new slot in the sequence. Returns CanvasReorderResult.

Split Canvas At Playhead

POST /api/v1/projects/{project_uuid}/canvases/{canvas_uuid}/split
Content-Type: application/json

{
    "p": 8000
}

Splits a canvas at source-time point p into two contiguous canvases. Returns CanvasSplitResult.

Merge Canvas With Neighbor

POST /api/v1/projects/{project_uuid}/canvases/{canvas_uuid}/merge
Content-Type: application/json

{
    "direction": "left"
}

Collapses a SINGLE canvas with its left/right SINGLE neighbor (undo a split). Returns CanvasMergeResult.

Caption Lines

Caption lines are editable subtitle segments generated from the project transcript.

Generate Caption Lines

POST /api/v1/projects/{project_uuid}/caption-lines/generate?max_words=7&force=false

Generates caption lines from transcript words. Parameters: - max_words (2-20): Max words per line. Use 3-5 for vertical (9:16), 7-10 for horizontal (16:9) - force: If true, regenerates even if captions already exist

Response:

{
    "project_uuid": "...",
    "lines_created": 42,
    "message": "Caption lines generated successfully."
}

List Caption Lines

GET /api/v1/projects/{project_uuid}/caption-lines?page=1&items_per_page=100

Returns paginated caption lines ordered by sequence.

Update Caption Line

PATCH /api/v1/caption-lines/{caption_line_uuid}
Content-Type: application/json

{
    "text": "Edited caption text"
}

Edit the text of a caption line. The original_text field is preserved for comparison.

Analysis Presets

Users can save analysis settings for quick reuse. Max 5 presets per user.

List Presets

GET /api/v1/users/me/presets

Returns all presets for the current user.

Create Preset

POST /api/v1/users/me/presets
Content-Type: application/json

{
    "name": "Podcast Style",
    "pacing_level": 30,
    "false_start_sensitivity": 50,
    "language": null,
    "audio_clean": true,
    "censorship_mode": "none",
    "director_notes": "Keep natural pauses",
    "is_default": false
}

Update Preset

PATCH /api/v1/users/me/presets/{preset_uuid}
Content-Type: application/json

{
    "name": "Updated Name",
    "is_default": true
}

Delete Preset

DELETE /api/v1/users/me/presets/{preset_uuid}

Preview Presets

Users can save export/preview styling settings.

List Preview Presets

GET /api/v1/users/me/preview-presets

Create Preview Preset

POST /api/v1/users/me/preview-presets
Content-Type: application/json

{
    "name": "TikTok Style",
    "format": "9:16",
    "background": "#000000",
    "caption_style": "bold",
    "caption_font": "sans",
    "caption_size": 32,
    "caption_position": "center",
    "caption_color": "#FFFFFF",
    "caption_length": "short",
    "video_flip_h": false,
    "video_flip_v": false
}

Update/Delete Preview Preset

PATCH /api/v1/users/me/preview-presets/{preset_uuid}
DELETE /api/v1/users/me/preview-presets/{preset_uuid}

Error Responses

All errors follow a consistent format:

{
    "detail": "Project not found"
}

The detail field is locale-dependent for canned defaults (4xx canonical messages, the generic 5xx, the 422 from RequestValidationError): the backend reads Accept-Language on the incoming request and renders the matching string from the en / pt / es catalogs in backend/src/modules/common/i18n/. Service-raised messages that go out verbatim (e.g. "Insufficient credits — need 12, have 4") flow through the same catalogs at the raise site via t_current("namespace.key", **interp) (see docs/development/backend.md §Key Conventions #20); raise sites that still hold English string literals are tracked by the Convention #20 enforcement grep. 5xx responses always also carry a support_id (8-char UUID) for log correlation.

Common status codes: - 400 - Validation error (bad input) - 401 - Not authenticated - 402 - Insufficient credits (need to upgrade plan or buy more AI minutes) - 403 - Permission denied (not your resource) - 404 - Resource not found - 409 - Conflict (e.g., project already analyzing, trial already used) - 429 - Rate limited (too many requests)

Key Files

Component Location
Projects router backend/src/interfaces/api/v1/projects.py
Media router backend/src/interfaces/api/v1/media.py
Edits router backend/src/interfaces/api/v1/edits.py
Canvases router backend/src/interfaces/api/v1/canvas.py
Drafts router backend/src/interfaces/api/v1/drafts.py
Exports router backend/src/interfaces/api/v1/exports.py
Caption Lines router backend/src/interfaces/api/v1/caption_lines.py
Presets router backend/src/interfaces/api/v1/presets.py
Preview Presets router backend/src/interfaces/api/v1/preview_presets.py
API main backend/src/interfaces/main.py

← Models Download Pipeline →