Uploading Media

Mushu uses presigned URLs for secure, direct uploads. Your files go straight to Cloudflare storage without passing through your server.

Important: Clients should upload directly to the presigned URL. Don't proxy uploads through your backend—this doubles bandwidth and latency. Your backend only needs to request the upload URL and confirm completion.

Upload Flow

  1. Your app requests an upload URL from Mushu (authenticated)
  2. Mushu returns a presigned URL valid for 1 hour
  3. Your app uploads directly to the presigned URL
  4. For images: confirm the upload to trigger processing
  5. For videos: processing starts automatically

Uploading Images

CLI

mushu media upload photo.jpg --org ORG_ID

With tenant association:

mushu media upload photo.jpg --org ORG_ID --tenant TENANT_ID

API - Step 1: Get Upload URL

POST /media/upload-url
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "org_id": "org_xxx",
  "filename": "photo.jpg",
  "content_type": "image/jpeg",
  "size_bytes": 102400
}

Response:

{
  "media_id": "media_abc123",
  "upload_url": "https://xxx.r2.cloudflarestorage.com/...",
  "key": "org_xxx/media_abc123/photo.jpg",
  "expires_in": 3600
}

API - Step 2: Upload to Presigned URL

PUT {upload_url}
Content-Type: image/jpeg

[binary image data]

API - Step 3: Confirm Upload

POST /media/{media_id}/confirm
Authorization: Bearer YOUR_TOKEN

Response:

{
  "status": "ready",
  "url": "https://images.mushucorp.com/t/original/org_xxx/media_abc123/photo.jpg"
}

Uploading Videos

CLI

mushu media upload video.mp4 --org ORG_ID

API - Step 1: Get Video Upload URL

POST /media/video/upload-url
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "org_id": "org_xxx",
  "filename": "video.mp4",
  "max_duration_seconds": 3600
}

Response:

{
  "media_id": "media_xyz789",
  "video_id": "stream_abc",
  "upload_url": "https://upload.videodelivery.net/..."
}

API - Step 2: Upload to Stream

POST {upload_url}
Content-Type: multipart/form-data

[video file]

No confirmation step needed for videos. Cloudflare Stream processes the video automatically after upload.

iOS Example

func uploadImage(_ imageData: Data, filename: String) async throws -> MediaItem {
    // Step 1: Get upload URL
    let urlRequest = UploadURLRequest(
        orgId: currentOrgId,
        filename: filename,
        contentType: "image/jpeg",
        sizeBytes: imageData.count
    )
    let urlResponse = try await mediaAPI.getUploadURL(urlRequest)

    // Step 2: Upload to presigned URL
    var uploadRequest = URLRequest(url: URL(string: urlResponse.uploadUrl)!)
    uploadRequest.httpMethod = "PUT"
    uploadRequest.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")
    uploadRequest.httpBody = imageData

    let (_, response) = try await URLSession.shared.data(for: uploadRequest)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
        throw UploadError.uploadFailed
    }

    // Step 3: Confirm
    return try await mediaAPI.confirmUpload(urlResponse.mediaId)
}

Web Example (JavaScript)

async function uploadImage(file) {
  // Step 1: Get upload URL
  const { media_id, upload_url } = await fetch('/api/media/upload-url', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      org_id: orgId,
      filename: file.name,
      content_type: file.type,
      size_bytes: file.size
    })
  }).then(r => r.json());

  // Step 2: Upload directly to R2
  await fetch(upload_url, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file
  });

  // Step 3: Confirm
  const media = await fetch(`/api/media/${media_id}/confirm`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${token}` }
  }).then(r => r.json());

  return media;
}

Error Handling

ErrorCauseSolution
400 Bad Request Invalid content type Use supported image/video format
403 Forbidden Presigned URL expired Request a new upload URL
413 Payload Too Large File exceeds size limit Compress or resize before upload

Best Practices

  • Validate before uploading - Check file type and size client-side
  • Show progress - Use XMLHttpRequest or fetch with progress events
  • Retry on failure - Request a new URL if upload fails
  • Use tenant IDs - Associate media with tenants for easier organization

Common Mistakes

Proxying uploads through your backend

Wrong: Client → Your Server → Mushu → R2
Right: Client → R2 (direct)

If your backend receives the file and re-uploads it to Mushu, you're paying for bandwidth twice and doubling latency. Instead, have your backend request the presigned URL, return it to the client, and let the client upload directly to R2.

Recommended architecture

// Your backend endpoint
POST /api/photos/upload-url
  → Calls Mushu to get presigned URL
  → Returns {"{"}upload_url, media_id{"}"} to client

// Client uploads directly
PUT {"{"}upload_url{"}"}
  → Goes straight to Cloudflare R2 (no auth needed)

// Your backend confirms
POST /api/photos/confirm
  → Calls Mushu to confirm
  → Stores media_id in your database