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 SessionManager.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 (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

Returns 422 ValidationError if every clip in the project has ClipFile.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. Mirror of the frontend SettingsPanel.allClipsSilent disable. Mixed-silent projects (at least one audio clip) pass — the pipeline's per-clip is_clip_audio_skippable filter handles silent clips 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
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.

Retry Failed Clip Processing

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

Re-queues processing for a clip whose status = FAILED. The service inspects the clip's artifact state and picks the right task to dispatch via the RetryTaskKind enum, so the retry resumes from the failure stage rather than starting over:

  • YOUTUBE_DOWNLOADstorage_key is NULL (file never landed in R2). Re-queues download_youtube_video; status reset to PENDING.
  • GENERATE_PROXYaudio_key is set but proxy_key is NULL and the source codec is non-web-compatible (proxy generation failed). Re-queues generate_clip_proxy directly, skipping process_clip_artifacts (which would short-circuit on the audio_key idempotency check and never re-queue proxy).
  • PROCESS_ARTIFACTS — fallback for everything else (audio extraction or proxy queueing failed). Re-queues process_clip_artifacts.

Idempotent: the underlying tasks HEAD their R2 outputs before re-running FFmpeg, so a retry of work already on R2 finalizes the DB write without burning compute. Returns 404 if the clip doesn't exist, 403 if the user doesn't own the project, 400 if the clip isn't in a retryable state.

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. ClipFile lifecycle: PENDING → MULTIPART_INITIATED → UPLOADED (or FAILED after UPLOADED; 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/projects/{project_uuid}/clips/multipart/initiate
Content-Type: application/json

{
  "filename": "feature_film.mp4",
  "content_type": "video/mp4",
  "size_bytes": 2147483648
}

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 ClipFile at MULTIPART_INITIATED, mints up to MULTIPART_INITIAL_URL_BATCH_SIZE part URLs upfront. Returns:

{
  "clip_uuid": "...",
  "clip_file_id": "...",
  "upload_id": "<r2-upload-id>",
  "key": "clips/.../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/projects/{project_uuid}/clips/multipart/complete
Content-Type: application/json

{
  "clip_uuid": "...",
  "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 then calls process_clip_artifacts.kiq(...) to extract audio + waveform + proxy. Returns the joined ClipRead. Idempotent on user double-click — re-call with 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/projects/{project_uuid}/clips/multipart/abort
Content-Type: application/json

{ "clip_uuid": "..." }

Deletes the Clip + ClipFile rows (Clip first — FK is ondelete=RESTRICT) 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/projects/{project_uuid}/clips/multipart/parts?clip_uuid=<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/projects/{project_uuid}/clips/multipart/parts/urls?clip_uuid=<uuid>&from_part=51&to_part=100

Mints a fresh batch of presigned PUT URLs for the requested range. 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).

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. 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 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. 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 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. The route HEADs the object, re-checks the storage quota against the actual Content-Length, increments User.storage_used_bytes, flips AssetFile.status to processing, and queues process_asset_artifacts on the download broker. The worker probes ffprobe + waveform + thumbnail off the request path (Convention #16) and flips status to uploaded when complete (or failed on the final retry). Lifecycle: pending → processing → uploaded (or failed at any phase).

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.

Retry Failed Asset

POST /api/v1/assets/{user_asset_id}/retry

Re-queues processing for an asset whose status = failed. The service inspects youtube_video_id and dispatches to the right worker:

  • YouTube imports (youtube_video_id set) — flips status to pending and re-queues download_youtube_asset.
  • Uploaded assets (youtube_video_id null) — HEADs the storage object first; if the file is gone, returns 400 with "Upload file is no longer in storage." Otherwise flips status to processing and re-queues process_asset_artifacts (re-runs ffprobe + waveform + thumbnail). Storage was already counted at confirm time, so storage_used_bytes is not re-incremented.

Returns 404 if the asset doesn't exist, 403 if the user doesn't own it, 400 if the asset is not in failed state.

Multipart Upload (files ≥ 25 MiB)

Asset multipart mirrors the clip multipart endpoints (see Clips § Multipart Upload above for full schema details, ETag handling, and constants). Asset-side differences:

  • Routes are user-scoped (no project_uuid) and key on asset_file_id.
  • 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). Complete flips to PROCESSING and the route kicks process_asset_artifacts which probes ffprobe + waveform + thumbnail off the request path (Convention #16) before flipping to UPLOADED — same shape as the single-PUT confirm path PR 1 introduced.
  • Uses the assets bucket (STORAGE_BUCKET_ASSETS) instead of the raw clips bucket.

Initiate

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

{
  "filename": "broll_archive.mp4",
  "content_type": "video/mp4",
  "size_bytes": 524288000,
  "display_name": "Optional",
  "tags": ["broll", "interview"],
  "group_id": "optional-group-uuid"
}

Returns the same shape as the clip variant, with user_asset_id and asset_file_id keys (instead of clip_uuid and clip_file_id):

{
  "user_asset_id": "...",
  "asset_file_id": "...",
  "upload_id": "<r2-upload-id>",
  "key": "assets/.../<uuid>",
  "part_size_bytes": 16777216,
  "parts_count": 32,
  "parts": [{"part_number": 1, "url": "https://..."}],
  "expires_in": 7200
}

Complete

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

{
  "asset_file_id": "...",
  "parts": [{"part_number": 1, "etag": "\"<r2-etag>\""}]
}

Convention #16 split-session. Flips AssetFile.status to PROCESSING, increments User.storage_used_bytes, returns the joined UserAssetRead. The route then kicks process_asset_artifacts.kiq(...). Idempotent on double-click — re-call with status=PROCESSING or status=UPLOADED returns the existing row without touching R2.

Abort

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

{ "asset_file_id": "..." }

Deletes the UserAsset + AssetFile rows (UserAsset first; membership rows cascade via DB-level ondelete=CASCADE) and commits BEFORE calling R2 abort_multipart_upload. Same rationale as the clip path: a FAILED row with a Retry button can't actually resume because R2's abort destroys the parts. Returns 204 No Content.

List Uploaded Parts (resume)

GET /api/v1/assets/multipart/parts?asset_file_id=<uuid>

Refill Part URLs

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

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 short-lived JWT-fronted Cloudflare Worker URL for video playback, mirroring the clip-playback path (/media/v1/<jwt>, see Get Proxy URL above). The browser uses this URL as the src of a <video> element; the Worker verifies the JWT and streams bytes from R2. Asset thumbnail URLs returned inline in list responses are minted the same way. Server-internal ffprobe (running inside process_asset_artifacts after confirm) still uses presigned R2 — the Worker JWT model is browser-oriented.

{
    "url": "https://staging.sapari.io/media/v1/<jwt>",
    "expires_in": 300
}

The URL expires after MEDIA_TOKEN_TTL_SECONDS (default 300 / 5 min). 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"
}

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
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 →