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!

Returns {"csrf_token": "..."} and sets session cookie. 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

Returns {"authenticated": true/false, "user": {...}}. Used by frontend on page load to restore 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 (clips, 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

Stream Events (SSE)

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

Opens a Server-Sent Events connection. Events include:

Event When
clip_ready Clip finished processing
analysis_progress Analysis step completed
analysis_complete All edits created
export_complete Render finished

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.

Clips

Clips are video files within a project.

Request Upload URL

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

{
    "filename": "recording.mp4",
    "content_type": "video/mp4",
    "size_bytes": 104857600
}

Returns a presigned PUT URL for direct upload to R2:

{
    "clip_uuid": "...",
    "clip_file_id": "...",
    "upload_url": "https://r2.../...",
    "content_type": "video/mp4",
    "expires_in": 3600
}

Validation: Rejects with 422 if file exceeds per-file max (STORAGE_MAX_UPLOAD_SIZE_MB, default 2GB), content type is not allowed, or upload would exceed the user's tier storage quota (Free: 500MB, Hobby: 2GB, Creator: 25GB, Viral: 100GB). These checks are early-rejects based on the declared size; actual enforcement happens at confirm (below).

Confirm Upload

POST /api/v1/projects/{project_uuid}/clips/{clip_uuid}/confirm

Call this after uploading to R2. Backend HEADs the R2 object, uses the actual Content-Length (not the client-declared size) to re-check the user's tier storage quota, deletes the object + fails the confirm if over quota, otherwise triggers audio extraction + waveform generation and increments User.storage_used_bytes by the actual size. R2 does not implement PostObject, so the upload-edge content-length-range enforcement that AWS S3 supports via presigned POST is not available — the HEAD-based recheck at confirm is the authoritative quota gate.

Import from YouTube

POST /api/v1/projects/{project_uuid}/clips/youtube-import
Content-Type: application/json

{
    "url": "https://youtube.com/watch?v=..."
}

Downloads the video and processes it. Each import creates a new ClipFile - there is no deduplication at the file level.

Validation: video metadata is fetched synchronously at import time (fetch_video_info via asyncio.to_thread + bounded asyncio.wait_for). Rejects with 400 ValidationError if the resolved duration_seconds exceeds MAX_YOUTUBE_DURATION_SECONDS (3600 — "maximum supported YouTube import is 60 minutes."), if the metadata fetch times out, or if metadata cannot be fetched. URL-shape validation alone is not sufficient — the cap is enforced against the resolved video, per Convention #13 in docs/development/backend.md §Key Conventions.

List Clips

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

Returns clips in display order with joined ClipFile data (duration, waveform, etc.).

Get Proxy URL

GET /api/v1/projects/{project_uuid}/clips/{clip_uuid}/proxy

Returns a short-lived Worker URL for clip playback. 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 proxy generation completes; once the ClipFile has sprite_key + sprite_seconds_per_tile populated, 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 (clip UUID + filename). See R2_MEDIA_PROXY_PLAN.md and TIER_3_SPRITE_PLAN.md for the full architecture.

Get Waveform

GET /api/v1/clips/{clip_uuid}/waveform

Returns the waveform peak data for timeline visualization:

{
    "peaks": [0.12, 0.45, 0.78, ...],
    "samples": 1000
}

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 duration (SUM(clip_file.duration_ms)), or if the project has no clips. If any clip 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. For asset edits, asset_offset_ms is separately bounds-checked against the referenced asset's duration_ms (on both POST and PATCH); swapping asset_file_id via PATCH re-verifies the new asset is uploaded (not pending/failed) before accepting the update.

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 duration (SUM(clip_file.duration_ms)), or if the project has no clips. Same validation applies to PATCH /api/v1/drafts/{draft_uuid} when edit_overrides is in the request body. If any clip 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 duration (SUM(clip_file.duration_ms)), or if the project has no clips. 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 clip 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"
}

Assets

Assets are user-uploaded media files (images, videos, audio) for overlays and b-roll.

Upload Asset (Presigned URL)

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

{
    "filename": "logo.png",
    "content_type": "image/png",
    "size_bytes": 51200,
    "group_id": "optional-group-uuid",
    "display_name": "Company Logo",
    "tags": ["brand", "logo"]
}

Returns a presigned PUT URL (same shape as clip presign above):

{
    "asset_file_id": "...",
    "user_asset_id": "...",
    "upload_url": "https://r2.../...",
    "content_type": "image/png",
    "expires_in": 3600
}

Validation: Same as clips — rejects with 422 if file exceeds per-file max, content type is not allowed, or upload would exceed tier storage quota. On confirm, the backend HEADs the object and re-checks the quota against the actual uploaded size (the declared size at presign is a UX early-reject only).

Confirm Upload

POST /api/v1/assets/confirm
Content-Type: application/json

{
    "asset_file_id": "..."
}

Call after uploading to R2. Creates the UserAsset record.

Import from YouTube

POST /api/v1/assets/import-youtube
Content-Type: application/json

{
    "url": "https://youtube.com/watch?v=...",
    "group_id": "optional-group-uuid",
    "display_name": "Optional custom name"
}

Downloads YouTube video as an asset. Each import creates a new AssetFile and UserAsset (per-user storage, no deduplication). Returns immediately with pending status; use polling to track download progress.

List Assets

GET /api/v1/assets/?page=1&items_per_page=50&group_id=optional

Returns paginated assets, optionally filtered by group.

List Ungrouped Assets

GET /api/v1/assets/ungrouped?page=1&items_per_page=50

Returns assets not belonging to any group.

Update Asset

PATCH /api/v1/assets/{uuid}
Content-Type: application/json

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

Delete Asset

DELETE /api/v1/assets/{uuid}

Removes the user's asset and its underlying file from storage (CASCADE delete).

Edit Asset (Trim/Extract Audio)

POST /api/v1/assets/{uuid}/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 an asset by trimming or extracting audio. Fire-and-forget pattern - returns immediately with the new asset 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 asset, "replace" overwrites original - 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 400 if start_ms, end_ms, or any cuts[i].end_ms exceeds the asset's duration_ms (when known). Also rejects at 422 (Pydantic) if end_ms <= start_ms or cuts[i].end_ms <= cuts[i].start_ms. If the asset is still processing (duration_ms IS NULL), the bounds check is skipped and the worker silently clamps at render time.

Response:

{
    "new_asset_uuid": "..."
}

The new asset is created with status: "pending". Asset list polling handles the transition to uploaded when processing completes.

Get Video URL

GET /api/v1/assets/{uuid}/video-url

Returns a presigned URL for video playback. Note: unlike clip playback (which routes through the Cloudflare Worker at /media/v1/<jwt> — see Get Proxy URL above), asset playback still uses direct-to-R2 presigned URLs with a 1-hour expiry. Asset migration to the Worker path is a post-Stage-6 follow-up tracked in R2_MEDIA_PROXY_PLAN.md under "Follow-ups (out of pilot scope)".

{
    "url": "https://r2.../...",
    "expires_in": 3600
}

Only works for assets with status: "uploaded".

Asset Groups

Groups organize assets into categories (e.g., "Brand Assets", "B-Roll", "Music").

List Groups

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

Returns paginated groups with asset counts.

Create Group

POST /api/v1/asset-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
}

Update Group

PATCH /api/v1/asset-groups/{uuid}
Content-Type: application/json

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

Delete Group

DELETE /api/v1/asset-groups/{uuid}

Removes the group. Assets remain but lose their group membership.

List Group Assets

GET /api/v1/asset-groups/{uuid}/assets?page=1&items_per_page=50

Returns paginated assets belonging to a specific group.

Asset Memberships

Assets can belong to multiple groups via many-to-many relationships.

Add Asset to Group

POST /api/v1/assets/{asset_uuid}/groups/{group_uuid}

Creates a membership link. The asset now appears in both its original group(s) and the new one.

Remove Asset from Group

DELETE /api/v1/assets/{asset_uuid}/groups/{group_uuid}

Removes the membership. Asset remains in other groups.

Get Asset's Groups

GET /api/v1/assets/{asset_uuid}/groups

Returns all groups this asset belongs to.

Bulk Add to Groups

POST /api/v1/assets/{asset_uuid}/groups/bulk
Content-Type: application/json

{
    "group_ids": ["group-uuid-1", "group-uuid-2"]
}

Adds asset to multiple groups at once.

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"
}

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
Clips router backend/src/interfaces/api/v1/clips.py
Edits router backend/src/interfaces/api/v1/edits.py
Drafts router backend/src/interfaces/api/v1/drafts.py
Exports router backend/src/interfaces/api/v1/exports.py
Assets router backend/src/interfaces/api/v1/assets.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 →