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
- Your app requests an upload URL from Mushu (authenticated)
- Mushu returns a presigned URL valid for 1 hour
- Your app uploads directly to the presigned URL
- For images: confirm the upload to trigger processing
- 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
| Error | Cause | Solution |
|---|---|---|
| 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