The asset management system has been consolidated into a single, unified API that supports images, videos, audio, and documents. Assets can be associated with clients, campaigns, or both.
Key Features:
- ✅ Single consolidated
assetstable (replaceduploaded_assets,client_assets,campaign_assets) - ✅ Discriminated union response format for type-safe asset handling
- ✅ Automatic metadata extraction (dimensions, duration, file size)
- ✅ Automatic thumbnail generation for videos
- ✅ Flexible filtering by client, campaign, and asset type
- ❌ Deprecated:
POST /api/clients/{id}/assetsandPOST /api/campaigns/{id}/assets
Security Features:
- 🔒 Path traversal protection with format whitelisting
- 🔒 File type validation with magic byte checking
- 🔒 Owner-only access control for all asset operations
- 🔒 UUID validation for asset IDs
- 🔒 Path resolution verification
- 🔒 50MB file size limit
| Column | Type | Nullable | Description |
|---|---|---|---|
id |
TEXT | No | UUID primary key |
user_id |
INTEGER | Yes | Owner user ID |
client_id |
TEXT | Yes | Associated client ID (FK) |
campaign_id |
TEXT | Yes | Associated campaign ID (FK) |
name |
TEXT | No | Display name |
asset_type |
TEXT | No | Discriminator: 'image', 'video', 'audio', 'document' |
url |
TEXT | No | Full URL to asset |
size |
INTEGER | Yes | File size in bytes |
uploaded_at |
TIMESTAMP | No | Upload timestamp |
format |
TEXT | No | File format: 'png', 'mp4', 'pdf', etc. |
tags |
TEXT | Yes | JSON array of tags |
width |
INTEGER | Yes | For images/videos |
height |
INTEGER | Yes | For images/videos |
duration |
INTEGER | Yes | For videos/audio (seconds) |
thumbnail_url |
TEXT | Yes | For videos/documents |
waveform_url |
TEXT | Yes | For audio (future) |
page_count |
INTEGER | Yes | For documents |
Endpoint: POST /api/v2/upload-asset
Authentication: Required (Bearer token)
Content-Type: multipart/form-data
Security: File type validation with magic bytes, maximum file size: 50MB
Form-Data Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
file |
File | Yes | The binary file to upload |
clientId |
String | No | Associate with a client |
campaignId |
String | No | Associate with a campaign |
name |
String | No | Custom display name (defaults to filename) |
Supported File Types:
- Images: jpg, jpeg, png, gif, webp
- Videos: mp4, mov
- Audio: mp3, wav
- Documents: pdf
Max File Size: 50MB
Example Request (cURL):
curl -X POST "https://api.example.com/api/v2/upload-asset" \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@/path/to/logo.png" \
-F "clientId=client-uuid-123" \
-F "name=Company Logo"Example Request (JavaScript/Fetch):
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('clientId', 'client-uuid-123');
formData.append('name', 'Company Logo');
const response = await fetch('/api/v2/upload-asset', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
const asset = await response.json();Response (201 Created):
Returns the full Asset object as a discriminated union. The exact fields depend on the type discriminator:
Image Asset:
{
"id": "asset-uuid-789",
"userId": 1,
"clientId": "client-uuid-123",
"campaignId": null,
"name": "logo.png",
"url": "https://api.example.com/api/v2/assets/asset-uuid-789",
"size": 51200,
"uploadedAt": "2025-11-16T19:43:00Z",
"tags": null,
"type": "image",
"format": "png",
"width": 800,
"height": 600
}Video Asset:
{
"id": "asset-uuid-abc",
"userId": 1,
"clientId": null,
"campaignId": "campaign-uuid-456",
"name": "product_demo.mp4",
"url": "https://api.example.com/api/v2/assets/asset-uuid-abc",
"size": 12345678,
"uploadedAt": "2025-11-16T19:43:00Z",
"tags": null,
"type": "video",
"format": "mp4",
"width": 1920,
"height": 1080,
"duration": 30,
"thumbnailUrl": "https://api.example.com/api/v2/assets/asset-uuid-abc/thumbnail"
}Audio Asset:
{
"id": "asset-uuid-def",
"userId": 1,
"clientId": null,
"campaignId": "campaign-uuid-456",
"name": "voiceover.mp3",
"url": "https://api.example.com/api/v2/assets/asset-uuid-def",
"size": 2048000,
"uploadedAt": "2025-11-16T19:43:00Z",
"tags": null,
"type": "audio",
"format": "mp3",
"duration": 60,
"waveformUrl": null
}Document Asset:
{
"id": "asset-uuid-ghi",
"userId": 1,
"clientId": "client-uuid-123",
"campaignId": null,
"name": "brand_guidelines.pdf",
"url": "https://api.example.com/api/v2/assets/asset-uuid-ghi",
"size": 1024000,
"uploadedAt": "2025-11-16T19:43:00Z",
"tags": null,
"type": "document",
"format": "pdf",
"pageCount": null,
"thumbnailUrl": null
}Endpoint: GET /api/v2/assets
Authentication: Required (Bearer token)
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
clientId |
String | No | Filter by client ID |
campaignId |
String | No | Filter by campaign ID |
asset_type |
String | No | Filter by type: 'image', 'video', 'audio', 'document' |
limit |
Integer | No | Max results (default: 50, max: 100) |
offset |
Integer | No | Pagination offset (default: 0) |
Example Requests:
# Get all assets for current user
GET /api/v2/assets
# Get all assets for a specific client
GET /api/v2/assets?clientId=client-uuid-123
# Get all video assets for a campaign
GET /api/v2/assets?campaignId=campaign-uuid-456&asset_type=video
# Get images with pagination
GET /api/v2/assets?asset_type=image&limit=20&offset=0Response (200 OK):
Returns an array of Asset objects (discriminated union):
[
{
"id": "asset-uuid-abc",
"userId": 1,
"clientId": "client-uuid-123",
"campaignId": null,
"name": "logo.png",
"url": "https://api.example.com/api/v2/assets/asset-uuid-abc",
"size": 51200,
"uploadedAt": "2025-11-16T18:00:00Z",
"tags": null,
"type": "image",
"format": "png",
"width": 800,
"height": 600
},
{
"id": "asset-uuid-xyz",
"userId": 1,
"clientId": "client-uuid-123",
"campaignId": "campaign-uuid-456",
"name": "product_video.mp4",
"url": "https://api.example.com/api/v2/assets/asset-uuid-xyz",
"size": 10485760,
"uploadedAt": "2025-11-16T19:00:00Z",
"tags": null,
"type": "video",
"format": "mp4",
"width": 1920,
"height": 1080,
"duration": 45,
"thumbnailUrl": "https://api.example.com/api/v2/assets/asset-uuid-xyz/thumbnail"
}
]Endpoint: GET /api/v2/assets/{asset_id}
Authentication: Required (Bearer token, owner only)
Description: Serves the actual asset file (image, video, audio, or document). Only the asset owner can access their files.
Example:
GET /api/v2/assets/asset-uuid-abc
# Returns the actual file with appropriate Content-TypeUse Cases:
- Display images: Include Bearer token in request headers
- Play videos: Use authenticated API client for fetching
- Download documents: Authenticate before accessing
Note: Since authentication is now required, you'll need to pass the Bearer token in the Authorization header when accessing assets from your frontend.
Endpoint: GET /api/v2/assets/{asset_id}/thumbnail
Authentication: Required (Bearer token, owner only)
Description: Serves the auto-generated thumbnail for video or document assets. Only the asset owner can access thumbnails.
Note: Only available if the asset has a thumbnailUrl field.
Example:
GET /api/v2/assets/asset-uuid-abc/thumbnail
# Returns JPEG thumbnailUse Cases:
- Video previews:
<img src="/api/v2/assets/{asset_id}/thumbnail" /> - Document previews
Endpoint: DELETE /api/v2/assets/{asset_id}
Authentication: Required (Bearer token, owner only)
Description: Deletes an asset and its associated files (including thumbnails).
Example Request:
curl -X DELETE "https://api.example.com/api/v2/assets/asset-uuid-abc" \
-H "Authorization: Bearer YOUR_TOKEN"Response (200 OK):
{
"success": true,
"message": "Asset asset-uuid-abc deleted successfully"
}Error Responses:
404 Not Found: Asset doesn't exist403 Forbidden: User doesn't own the asset
For frontend TypeScript projects, use these types:
// Base asset fields (common to all types)
interface BaseAsset {
id: string;
userId: number | null;
clientId: string | null;
campaignId: string | null;
name: string;
url: string;
size: number | null;
uploadedAt: string; // ISO 8601
tags: string[] | null;
format: string;
}
// Image asset
interface ImageAsset extends BaseAsset {
type: 'image';
width: number;
height: number;
}
// Video asset
interface VideoAsset extends BaseAsset {
type: 'video';
width: number;
height: number;
duration: number; // seconds
thumbnailUrl: string | null;
}
// Audio asset
interface AudioAsset extends BaseAsset {
type: 'audio';
duration: number; // seconds
waveformUrl: string | null;
}
// Document asset
interface DocumentAsset extends BaseAsset {
type: 'document';
pageCount: number | null;
thumbnailUrl: string | null;
}
// Discriminated union
type Asset = ImageAsset | VideoAsset | AudioAsset | DocumentAsset;Type Guard Example:
function isVideoAsset(asset: Asset): asset is VideoAsset {
return asset.type === 'video';
}
// Usage
if (isVideoAsset(asset)) {
console.log(`Video duration: ${asset.duration}s`);
console.log(`Thumbnail: ${asset.thumbnailUrl}`);
}Before:
// Upload client asset
POST /api/clients/{clientId}/assets
FormData: { file }
// Upload campaign asset
POST /api/campaigns/{campaignId}/assets
FormData: { file }After:
// Upload asset with client association
POST /api/v2/upload-asset
FormData: { file, clientId }
// Upload asset with campaign association
POST /api/v2/upload-asset
FormData: { file, campaignId }
// Upload asset with BOTH associations
POST /api/v2/upload-asset
FormData: { file, clientId, campaignId }Before: No unified way to get client/campaign assets
After:
// Get all client assets
GET /api/v2/assets?clientId=client-uuid-123
// Get all campaign assets
GET /api/v2/assets?campaignId=campaign-uuid-456
// Get all video assets for a campaign
GET /api/v2/assets?campaignId=campaign-uuid-456&asset_type=videoasync function uploadClientLogo(clientId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
formData.append('clientId', clientId);
formData.append('name', 'Company Logo');
const response = await fetch('/api/v2/upload-asset', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const asset: Asset = await response.json();
if (asset.type === 'image') {
console.log(`Logo uploaded: ${asset.width}x${asset.height}`);
}
return asset;
}async function uploadCampaignVideo(campaignId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
formData.append('campaignId', campaignId);
const response = await fetch('/api/v2/upload-asset', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const asset: Asset = await response.json();
if (asset.type === 'video') {
console.log(`Video uploaded: ${asset.duration}s`);
console.log(`Thumbnail available at: ${asset.thumbnailUrl}`);
}
return asset;
}async function loadClientAssets(clientId: string) {
const response = await fetch(
`/api/v2/assets?clientId=${clientId}&asset_type=image`,
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
const assets: Asset[] = await response.json();
// Render gallery
return assets.map(asset => {
if (asset.type === 'image') {
return `<img src="${asset.url}" alt="${asset.name}" />`;
}
});
}async function loadCampaignVideos(campaignId: string) {
const response = await fetch(
`/api/v2/assets?campaignId=${campaignId}&asset_type=video`,
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
const assets: Asset[] = await response.json();
return assets.map(asset => {
if (asset.type === 'video') {
return {
id: asset.id,
name: asset.name,
duration: asset.duration,
thumbnail: asset.thumbnailUrl,
videoUrl: asset.url
};
}
});
}When you upload an asset, the backend automatically extracts:
- Images: Width, height, file size
- Videos: Width, height, duration, generates thumbnail at 1 second
- Audio: Duration, file size
- Documents: File size, page count (planned)
- Assets are stored in
DATA/assets/{asset_id}.{format} - Thumbnails are stored in
DATA/assets/{asset_id}_thumb.jpg - Files are served via
/api/v2/assets/{asset_id}
When a client or campaign is deleted, all associated assets are automatically deleted (cascade).
Common error responses:
// 400 Bad Request - Invalid file type
{
"detail": "Invalid file type: image/bmp. Allowed: images (jpg, png, gif, webp), videos (mp4, mov), audio (mp3, wav), documents (pdf)"
}
// 400 Bad Request - File too large
{
"detail": "File too large. Maximum size is 50.0MB"
}
// 404 Not Found - Asset doesn't exist
{
"detail": "Asset not found"
}
// 403 Forbidden - User doesn't own asset
{
"detail": "You don't have permission to delete this asset"
}If you encounter any issues or have questions about the Asset API, please contact the backend team or file an issue in the repository.