Skip to main content
Mosaic uses a streamlined 3-step process for video uploads, leveraging Google Cloud Storage’s signed policy uploads for secure and reliable handling of files. This allows you to upload directly to cloud storage using pre-signed URLs with policy-based authentication.

Upload Limits

  • Maximum duration: 90 minutes
  • Maximum file size: 5 GB

Upload Process Overview

  1. Request an upload URL - POST your file details to get a pre-signed URL and policy fields
  2. Upload your video - POST your video as multipart form data with the policy fields
  3. Finalize the upload - Confirm the upload completion

Step 1: POST /videos/get_upload_url

Request a pre-signed URL for uploading your video. Video metadata is now validated upfront for immediate feedback.

Request

curl -X POST "https://api.mosaic.so/videos/get_upload_url" \
  -H "Authorization: Bearer mk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "my-video.mp4",
    "content_type": "video/mp4",
    "file_size": 104857600,
    "width": 1920,
    "height": 1080,
    "duration_ms": 30000
  }'

Request Body

FieldTypeRequiredDescription
filenamestringName of the file being uploaded
content_typestringMIME type of the video (e.g., “video/mp4”)
file_sizeintegerFile size in bytes (max 5GB)
widthintegerVideo width in pixels
heightintegerVideo height in pixels
duration_msintegerVideo duration in milliseconds (max 90min)

Response

{
  "video_id": "550e8400-e29b-41d4-a716-446655440000",
  "upload_url": "https://storage.googleapis.com/mosaic-uploads/...",
  "method": "POST",
  "fields": {
    "key": "uploads/550e8400-e29b-41d4-a716-446655440000",
    "x-goog-date": "20240115T120000Z",
    "x-goog-credential": "...",
    "x-goog-algorithm": "GOOG4-RSA-SHA256",
    "policy": "...",
    "x-goog-signature": "..."
  }
}
FieldTypeDescription
video_idstringUnique identifier for your video
upload_urlstringGCS upload endpoint URL
methodstringHTTP method to use for upload (always “POST”)
fieldsobjectPolicy fields required for GCS upload (include all fields in your upload request)

Error Codes

Status CodeDescription
200 OKSuccess - upload URL generated
400 Bad RequestInvalid metadata (negative dimensions, invalid content type, etc.)
413 Payload Too LargeFile size > 5GB or duration > 90 minutes
401 UnauthorizedInvalid API key

Step 2: Upload to the URL

Upload your video file to the provided upload_url using a multipart POST request with the policy fields from the response.
# Using the response from Step 1, construct a multipart form upload
# Note: The fields from the response must be included as form data
# The file must be sent with field name "file"
curl -X POST "$UPLOAD_URL" \
  $(echo $FIELDS | jq -r 'to_entries | map("-F \"\(.key)=\(.value)\"") | join(" ")') \
  -F "[email protected]"

Response Codes

StatusDescription
204 No ContentSuccess - upload completed
400 Bad RequestPolicy violation (file exceeds 5GB limit)
OtherUnexpected error - check response body

TypeScript Example

async function uploadVideo(
  uploadUrl: string, 
  videoFile: File,
  fields: Record<string, string>
): Promise<void> {
  // Create form data with policy fields first, then the file
  const formData = new FormData();
  
  // Add all policy fields from the response
  Object.entries(fields).forEach(([key, value]) => {
    formData.append(key, value);
  });
  
  // Add file with field name "file" (must be last)
  formData.append('file', videoFile);
  
  const response = await fetch(uploadUrl, {
    method: 'POST',
    body: formData
  });

  // Check response codes
  if (response.status === 204) {
    // Success - no content returned
    return;
  } else if (response.status === 400) {
    throw new Error('File exceeds 5GB size limit');
  } else {
    throw new Error(`Upload failed: ${response.statusText}`);
  }
}
Important:
  • The upload URL and policy fields expire after 1 hour
  • Include all fields from the response in your multipart form data
  • The file must be the last field in the multipart form
  • Videos must be under 90 minutes and 5 GB in size

Step 3: POST /videos/finalize_upload

Finalize the upload to make your video available for processing. Since validation now occurs during Step 1, this step processes much faster without Lambda metadata extraction.

Request

curl -X POST "https://api.mosaic.so/videos/finalize_upload" \
  -H "Authorization: Bearer mk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "video_id": "550e8400-e29b-41d4-a716-446655440000"
  }'

Request Body

FieldTypeRequiredDescription
video_idstringThe video ID from step 1

Response

{
  "status": "ok"
}

Response Codes

StatusDescription
200 OKSuccess - upload finalized
413 Payload Too LargeVideo exceeds duration limit (90 minutes) or processing limits
400 Bad RequestInvalid video_id or upload not found
500 Internal Server ErrorServer error during finalization

Complete Examples

Bash Example

#!/bin/bash

# Note: This example assumes you have already extracted metadata from your video file
# Use tools like ffprobe to get width, height, duration, and file size:
# ffprobe -v quiet -show_entries stream=width,height,duration -of csv=p=0:s=x video.mp4

VIDEO_FILE="my-video.mp4"
FILE_SIZE=$(stat -f%z "$VIDEO_FILE")  # Get file size in bytes
WIDTH=1920  # Extract these values using ffprobe or similar tool  
HEIGHT=1080
DURATION_MS=30000  # Duration in milliseconds

# Step 1: Get upload URL with metadata validation
echo "📤 Getting upload URL with validation..."
RESPONSE=$(curl -s -X POST "https://api.mosaic.so/videos/get_upload_url" \
  -H "Authorization: Bearer $MOSAIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"filename\": \"my-video.mp4\",
    \"content_type\": \"video/mp4\",
    \"file_size\": $FILE_SIZE,
    \"width\": $WIDTH,
    \"height\": $HEIGHT,
    \"duration_ms\": $DURATION_MS
  }")

# Check for validation errors
HTTP_CODE=$(curl -w "%{http_code}" -o /dev/null -s -X POST "https://api.mosaic.so/videos/get_upload_url" \
  -H "Authorization: Bearer $MOSAIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"filename\": \"my-video.mp4\",
    \"content_type\": \"video/mp4\",
    \"file_size\": $FILE_SIZE,
    \"width\": $WIDTH,
    \"height\": $HEIGHT,
    \"duration_ms\": $DURATION_MS
  }")

if [ "$HTTP_CODE" -eq 413 ]; then
  echo "   ❌ File exceeds limits (>5GB or >90min)"
  exit 1
elif [ "$HTTP_CODE" -eq 400 ]; then
  echo "   ❌ Invalid metadata (check dimensions and content type)"
  exit 1
fi

VIDEO_ID=$(echo $RESPONSE | jq -r '.video_id')
UPLOAD_URL=$(echo $RESPONSE | jq -r '.upload_url')
FIELDS=$(echo $RESPONSE | jq -r '.fields')

echo "   ✅ Got video_id: $VIDEO_ID"

# Step 2: Upload video with policy fields
echo "⬆️  Uploading video..."
HTTP_STATUS=$(curl -w "%{http_code}" -o /dev/null -s -X POST "$UPLOAD_URL" \
  $(echo $FIELDS | jq -r 'to_entries | map("-F \"\(.key)=\(.value)\"") | join(" ")') \
  -F "[email protected]")

if [ "$HTTP_STATUS" -eq 204 ]; then
  echo "   ✅ Upload successful"
elif [ "$HTTP_STATUS" -eq 400 ]; then
  echo "   ❌ File exceeds 5GB limit"
  exit 1
else
  echo "   ❌ Upload failed with status $HTTP_STATUS"
  exit 1
fi

# Step 3: Finalize upload
echo "✅ Finalizing upload..."
FINALIZE_STATUS=$(curl -w "%{http_code}" -o /dev/null -s -X POST "https://api.mosaic.so/videos/finalize_upload" \
  -H "Authorization: Bearer $MOSAIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"video_id\": \"$VIDEO_ID\"}")

if [ "$FINALIZE_STATUS" -eq 200 ]; then
  echo "🎉 Upload complete! Video ID: $VIDEO_ID"
elif [ "$FINALIZE_STATUS" -eq 413 ]; then
  echo "   ❌ Video exceeds 90 minute duration limit"
  exit 1
else
  echo "   ❌ Finalization failed with status $FINALIZE_STATUS"
  exit 1
fi

TypeScript Example

interface VideoMetadata {
  width: number;
  height: number;
  duration_ms: number;
  file_size: number;
}

async function extractVideoMetadata(file: File): Promise<VideoMetadata> {
  return new Promise((resolve, reject) => {
    const video = document.createElement('video');
    video.preload = 'metadata';
    
    video.onloadedmetadata = () => {
      resolve({
        width: video.videoWidth,
        height: video.videoHeight,
        duration_ms: Math.round(video.duration * 1000),
        file_size: file.size
      });
      URL.revokeObjectURL(video.src);
    };
    
    video.onerror = () => {
      reject(new Error('Failed to extract video metadata'));
      URL.revokeObjectURL(video.src);
    };
    
    video.src = URL.createObjectURL(file);
  });
}

async function uploadVideoToMosaic(apiKey: string, videoFile: File): Promise<string> {
  console.log('📊 Extracting video metadata...');
  
  // Step 0: Extract metadata
  let metadata: VideoMetadata;
  try {
    metadata = await extractVideoMetadata(videoFile);
    console.log(`   ✅ Metadata: ${metadata.width}x${metadata.height}, ${metadata.duration_ms/1000}s, ${(metadata.file_size/(1024**2)).toFixed(1)}MB`);
  } catch (error) {
    throw new Error(`Failed to extract metadata: ${error}`);
  }
  
  // Step 1: Get upload URL with validation
  console.log('📤 Step 1: Getting upload URL with validation...');
  
  const getUrlResponse = await fetch('https://api.mosaic.so/videos/get_upload_url', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      filename: videoFile.name,
      content_type: videoFile.type || 'video/mp4',
      file_size: metadata.file_size,
      width: metadata.width,
      height: metadata.height,
      duration_ms: metadata.duration_ms
    })
  });
  
  if (getUrlResponse.status === 413) {
    const error = await getUrlResponse.json();
    throw new Error(`File exceeds limits: ${error.detail}`);
  } else if (getUrlResponse.status === 400) {
    const error = await getUrlResponse.json();
    throw new Error(`Invalid metadata: ${error.detail}`);
  } else if (!getUrlResponse.ok) {
    throw new Error(`Failed to get upload URL: ${getUrlResponse.status}`);
  }
  
  const { video_id, upload_url, method } = await getUrlResponse.json();
  console.log(`   ✅ Got video_id: ${video_id}`);
  
  // Step 2: Upload with resumable method
  console.log('⬆️  Step 2: Uploading video...');
  
  const uploadResponse = await fetch(upload_url, {
    method: method,
    headers: {
      'x-goog-resumable': 'start',
      'Content-Type': videoFile.type || 'video/mp4',
      'Content-Length': metadata.file_size.toString()
    },
    body: videoFile
  });
  
  if (![200, 201, 204].includes(uploadResponse.status)) {
    throw new Error(`Upload failed: ${uploadResponse.status}`);
  }
  console.log('   ✅ Upload successful');
  
  // Step 3: Finalize
  console.log('✅ Step 3: Finalizing upload...');
  
  const finalizeResponse = await fetch('https://api.mosaic.so/videos/finalize_upload', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ video_id })
  });
  
  if (!finalizeResponse.ok) {
    const error = await finalizeResponse.json();
    throw new Error(`Finalization failed: ${error.detail}`);
  }
  
  console.log(`🎉 Upload complete! Video ID: ${video_id}`);
  return video_id;
}

Python Example

import requests
import os
from moviepy.editor import VideoFileClip  # For extracting video metadata

def get_video_metadata(file_path: str) -> dict:
    """Extract metadata from video file."""
    try:
        with VideoFileClip(file_path) as clip:
            return {
                "width": clip.w,
                "height": clip.h, 
                "duration_ms": int(clip.duration * 1000),
                "file_size": os.path.getsize(file_path)
            }
    except Exception as e:
        raise Exception(f"Failed to extract video metadata: {e}")

def upload_video_to_mosaic(api_key: str, file_path: str) -> str:
    """Upload a video file to Mosaic using the 3-step process with metadata validation."""
    
    headers = {"Authorization": f"Bearer {api_key}"}
    filename = os.path.basename(file_path)
    
    # Step 0: Extract video metadata locally
    print("📊 Extracting video metadata...")
    try:
        metadata = get_video_metadata(file_path)
        print(f"   ✅ Metadata: {metadata['width']}x{metadata['height']}, "
              f"{metadata['duration_ms']/1000:.1f}s, {metadata['file_size']/(1024**2):.1f}MB")
    except Exception as e:
        print(f"   ❌ Failed to extract metadata: {e}")
        return None
    
    # Step 1: Get upload URL with metadata validation
    print("📤 Step 1: Getting upload URL with validation...")
    
    # Determine content type from file extension
    content_type_map = {
        '.mp4': 'video/mp4',
        '.mov': 'video/quicktime',
        '.avi': 'video/x-msvideo',
        '.webm': 'video/webm',
        '.mkv': 'video/x-matroska',
        '.m4v': 'video/x-m4v'
    }
    
    file_ext = os.path.splitext(filename)[1].lower()
    content_type = content_type_map.get(file_ext, 'video/mp4')
    
    response = requests.post(
        "https://api.mosaic.so/videos/get_upload_url",
        headers={**headers, "Content-Type": "application/json"},
        json={
            "filename": filename,
            "content_type": content_type,
            "file_size": metadata["file_size"],
            "width": metadata["width"],
            "height": metadata["height"],
            "duration_ms": metadata["duration_ms"]
        }
    )
    
    # Handle validation errors
    if response.status_code == 413:
        error_data = response.json()
        print(f"   ❌ File exceeds limits: {error_data.get('detail', 'File too large or too long')}")
        return None
    elif response.status_code == 400:
        error_data = response.json()
        print(f"   ❌ Invalid metadata: {error_data.get('detail', 'Bad request')}")
        return None
    elif not response.ok:
        print(f"   ❌ Failed to get upload URL: {response.status_code}")
        return None
    
    upload_data = response.json()
    video_id = upload_data["video_id"]
    upload_url = upload_data["upload_url"]
    method = upload_data["method"]
    
    print(f"   ✅ Got video_id: {video_id}")
    print(f"   ✅ Upload method: {method}")
    
    # Step 2: Upload video using resumable upload
    print("⬆️  Step 2: Uploading video...")
    
    with open(file_path, "rb") as f:
        if method == "POST":
            # Resumable upload with proper headers
            upload_response = requests.post(
                upload_url,
                headers={
                    "x-goog-resumable": "start",
                    "Content-Type": content_type,
                    "Content-Length": str(metadata["file_size"])
                },
                data=f
            )
        else:
            # Fallback PUT method
            upload_response = requests.put(
                upload_url,
                headers={"Content-Type": content_type},
                data=f
            )
    
    if upload_response.status_code in [200, 201, 204]:
        print("   ✅ Upload successful")
    else:
        print(f"   ❌ Upload failed with status {upload_response.status_code}")
        return None
    
    # Step 3: Finalize upload (now faster without Lambda)
    print("✅ Step 3: Finalizing upload...")
    
    finalize_response = requests.post(
        "https://api.mosaic.so/videos/finalize_upload",
        headers={**headers, "Content-Type": "application/json"},
        json={"video_id": video_id}
    )
    
    if finalize_response.status_code == 200:
        print("   ✅ Finalization complete")
        print(f"🎉 Upload complete! Video ID: {video_id}")
        return video_id
    else:
        error_data = finalize_response.json()
        print(f"   ❌ Finalization failed: {error_data.get('detail', 'Unknown error')}")
        return None

# Usage example
if __name__ == "__main__":
    # Install required dependency: pip install moviepy
    
    api_key = "mk_your_api_key_here"
    video_file = "path/to/your/video.mp4"
    
    try:
        video_id = upload_video_to_mosaic(api_key, video_file)
        if video_id:
            print(f"✅ Success! Use video_id '{video_id}' in your agent runs")
        else:
            print("❌ Upload failed")
    except Exception as e:
        print(f"❌ Error: {e}")

Key Benefits

With the new metadata validation approach, you get:
  • Faster finalization - No more Lambda calls for metadata extraction
  • 🚀 Immediate validation - Fail fast before upload starts, saving time and bandwidth
  • 💰 Lower costs - Reduced Lambda execution fees
  • 🔒 Better reliability - Fewer external dependencies in the upload pipeline
  • 📊 Client-side control - Extract metadata offline with full control over the process

Dependencies

Python

pip install moviepy requests

JavaScript/TypeScript

Built-in HTML5 Video API (no external dependencies required)

Best Practices

  1. Metadata Extraction: Always extract video metadata locally before requesting upload URL
  2. Error Handling: Handle validation errors immediately at Step 1 to avoid unnecessary uploads
  3. Retry Logic: Implement exponential backoff for network failures
  4. Field Order: Always add policy fields before the file in multipart uploads - GCS requires the file to be the last field
  5. Progress Tracking: For large files, consider implementing upload progress monitoring
  6. Policy Expiration: Upload URLs and policy fields expire after 1 hour - complete uploads promptly

What’s Next?

After successfully uploading your video, you can: Your videos will now process more quickly since validation happens upfront, eliminating the need for Lambda-based metadata extraction during finalization.