Developer API
Media Render
Make one MP4 from images, videos, music, text, and optional AI instructions.
The short version
1. Create upload URLs.
2. Upload your files.
3. Start the render.
4. Check the job.
5. Download the MP4.
1. Create upload URLs
Send one files array. Required for each file: fileName, contentType, and fileSize.
curl -X POST https://www.subclip.app/api/v1/media-render/uploads \
-H "Authorization: Bearer $SUBCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"projectName": "Launch montage",
"files": [
{
"fileName": "intro.mp4",
"contentType": "video/mp4",
"fileSize": 52428800,
"durationSeconds": 12,
"width": 1920,
"height": 1080
},
{
"fileName": "product.png",
"contentType": "image/png",
"fileSize": 2048000,
"width": 1600,
"height": 1200
},
{
"fileName": "music.mp3",
"contentType": "audio/mpeg",
"fileSize": 8192000,
"durationSeconds": 90
}
]
}'{
"projectId": "mrproj_...",
"directoryPrefix": "user-id/media-render/mrproj_.../",
"uploads": [
{
"assetId": "asset_...",
"uploadUrl": "https://...",
"method": "PUT",
"objectKey": "user/media-render/...",
"mediaType": "video",
"fileName": "intro.mp4",
"expiresIn": 900
},
{
"assetId": "asset_...",
"uploadUrl": "https://...",
"method": "PUT",
"objectKey": "user/media-render/...",
"mediaType": "image",
"fileName": "product.png",
"expiresIn": 900
}
],
"mediaCounts": { "image": 1, "video": 1, "audio": 1 },
"analysisCostBasis": {
"visionAnalysis": { "images": 1, "videos": 1 },
"audioAnalysis": { "audioFiles": 1, "videos": 1 }
}
}Supported content types include common editor uploads: JPEG, PNG, GIF, WebP, SVG, HEIC, HEIF, MP4, MPEG, MOV/QuickTime, AVI, WebM, MKV, MP3, M4A, WAV, OGG, and AAC.
Images: - image/jpeg - image/png - image/gif - image/webp - image/svg+xml - image/heic - image/heif Videos: - video/mp4 - video/mpeg - video/quicktime - video/x-msvideo - video/webm - video/x-matroska Audio: - audio/mpeg - audio/mp3 - audio/mp4 - audio/m4a - audio/x-m4a - audio/wav - audio/x-wav - audio/webm - audio/ogg - audio/aac
2. Upload files
Upload each file with PUT to its matching uploadUrl. Use the same Content-Type you sent when creating the upload URL. Content-Length must match the real file size.
curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: video/mp4" \ -H "Content-Length: 52428800" \ --data-binary "@intro.mp4"
3. Start render
Smallest request: pass the projectId. Subclip sorts files by filename, shows each visual for 3 seconds, adds transitions, and ends on the last visual.
curl -X POST https://www.subclip.app/api/v1/media-render/jobs \
-H "Authorization: Bearer $SUBCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"projectId": "mrproj_...",
"aspectRatio": "9:16",
"outputFileName": "rendered.mp4"
}'You can also start from a Subclip media-render directory prefix instead of an upload project.
curl -X POST https://www.subclip.app/api/v1/media-render/jobs \
-H "Authorization: Bearer $SUBCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"projectName": "Launch montage",
"sourceDirectoryPrefix": "user-id/media-render/mrproj_.../",
"instructions": "Make a fast product launch montage.",
"aspectRatio": "9:16",
"targetDurationSeconds": 30,
"aiAnalysis": false,
"visionAnalysis": false,
"audioAnalysis": false,
"textOnScreen": false,
"bgm": true,
"bgmQuery": "upbeat product launch background music",
"bgmVolume": 0.18,
"sfx": true,
"sfxTypes": ["click", "camera-shutter"],
"sfxVolume": 0.55,
"transitionTypes": ["crossfade", "slide"],
"outputFileName": "launch-montage.mp4"
}'Prefer Node.js for uploads and request setup:
import { createReadStream, statSync } from "node:fs";
const stats = statSync("./intro.mp4");
await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": "video/mp4",
"Content-Length": String(stats.size),
},
body: createReadStream("./intro.mp4"),
duplex: "half",
});curl -X POST https://www.subclip.app/api/v1/media-render/jobs \
-H "Authorization: Bearer $SUBCLIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"projectId": "mrproj_...",
"instructions": "Open with the product shot, then cut into the demo clips. Keep it energetic.",
"aspectRatio": "9:16",
"aiAnalysis": true,
"visionAnalysis": true,
"audioAnalysis": false,
"textOnScreen": true,
"assetDurations": [
{ "assetId": "asset_intro", "durationSeconds": 4 },
{ "fileName": "product.png", "durationSeconds": 3.5 }
],
"bgm": true,
"bgmFileName": "music.mp3",
"bgmVolume": 0.18,
"sfx": true,
"sfxTypes": ["click", "camera-shutter"],
"sfxVolume": 0.55,
"transitionTypes": ["crossfade", "zoom-in", "light-leak"],
"outputFileName": "launch-montage.mp4"
}'Simple controls
{
"projectId": "mrproj_...",
"aspectRatio": "9:16",
"aiAnalysis": false,
"visionAnalysis": false,
"audioAnalysis": false,
"textOnScreen": false,
"hookText": "The product finally makes sense",
"bgm": true,
"bgmFileName": "music.mp3",
"bgmVolume": 0.18,
"sfx": true,
"sfxTypes": ["click", "camera-shutter"],
"sfxVolume": 0.55,
"transitionTypes": ["crossfade"],
"outputFileName": "launch-montage.mp4"
}Hook-only request example:
{
"projectId": "mrproj_...",
"aspectRatio": "9:16",
"aiAnalysis": false,
"hookText": "The product finally makes sense",
"bgm": true,
"bgmFileName": "music.mp3",
"sfx": true,
"sfxTypes": ["click", "camera-shutter"],
"sfxVolume": 0.55,
"transitionTypes": ["crossfade", "zoom-in"]
}Let AI cook
{
"projectId": "mrproj_...",
"instructions": "Create an energetic product launch montage. Build a story across all assets. Add Snapchat-style text on each clip transition so the message continues until the end. Use the UI screenshots first, then the lifestyle shots.",
"aspectRatio": "9:16",
"aiAnalysis": true,
"visionAnalysis": true,
"audioAnalysis": true,
"textOnScreen": true,
"bgm": true,
"bgmQuery": "upbeat product launch background music",
"bgmVolume": 0.18,
"sfx": true,
"sfxTypes": ["click", "camera-shutter", "whoosh"],
"sfxVolume": 0.55,
"transitionTypes": ["light-leak", "zoom-in", "slide"]
}Advanced Controls for Pro users
Exact clip timing
Use clipPlacements when you want to choose where each clip starts and ends. Match by assetId or fileName. These timings win over AI planning.
{
"projectId": "mrproj_...",
"aspectRatio": "9:16",
"aiAnalysis": false,
"clipPlacements": [
{
"fileName": "01-hook.png",
"startTime": 0,
"durationSeconds": 3,
"transitionType": "none"
},
{
"fileName": "02-proof.mp4",
"startTime": 3,
"durationSeconds": 4,
"sourceStartTime": 1,
"sourceEndTime": 5,
"transitionType": "crossfade"
},
{
"fileName": "03-result.png",
"startTime": 7,
"durationSeconds": 3,
"transitionType": "light-leak"
}
],
"sfx": true,
"sfxTypes": ["click", "camera-shutter"]
}Exact text overlays
Use textOverlays when you want to place text yourself. Pass text, timing, preset name, and top/middle/bottom position.
{
"projectId": "mrproj_...",
"aspectRatio": "9:16",
"textOverlays": [
{
"text": "This is the hook",
"startTime": 0,
"endTime": 2.8,
"templateName": "Snapchat Hook",
"position": "top"
},
{
"text": "Now the proof is clear",
"startTime": 3,
"durationSeconds": 3,
"templateName": "Headline Bar",
"position": "middle"
},
{
"text": "The result speaks for itself",
"startTime": 7,
"durationSeconds": 3,
"templateName": "Simple",
"position": "bottom"
}
]
}Exact durations only
Use assetDurations when you only need each visual to stay longer or shorter.
{
"projectId": "mrproj_...",
"instructions": "Use this exact pacing.",
"aspectRatio": "9:16",
"aiAnalysis": true,
"visionAnalysis": true,
"textOnScreen": true,
"assetDurations": [
{ "fileName": "01-hero.png", "durationSeconds": 4 },
{ "fileName": "02-dashboard.png", "durationSeconds": 3 },
{ "fileName": "03-proof.png", "durationSeconds": 5 }
],
"bgm": true,
"bgmFileName": "music.mp3",
"sfx": true,
"sfxTypes": ["camera-shutter"],
"transitionTypes": ["fade", "crossfade"]
}Voiceover
First list available voices, then pass a returned voiceoverVoiceId. You can filter by language and optional gender. Script can be plain text, SRT, or JSON text segments.
curl "https://www.subclip.app/api/v1/media-render/voices?language=en&gender=female" \ -H "Authorization: Bearer $SUBCLIP_API_KEY"
{
"voices": [
{
"id": "dvoice_...",
"name": "My launch narrator",
"voiceoverVoiceId": "dvoice_...",
"language": "en",
"source": "saved",
"gender": "female",
"createdAt": "2026-06-10T10:00:00.000Z",
"updatedAt": "2026-06-10T10:00:00.000Z"
},
{
"id": "svoice_...",
"name": "Warm Presenter",
"voiceoverVoiceId": "svoice_...",
"language": "en",
"source": "system",
"gender": "female",
"accent": "American",
"tags": ["narration", "warm"]
}
]
}{
"projectId": "mrproj_...",
"instructions": "Make a product launch story from these visuals. Keep it clear and confident.",
"aspectRatio": "9:16",
"aiAnalysis": true,
"visionAnalysis": true,
"audioAnalysis": false,
"textOnScreen": true,
"voiceover": true,
"voiceoverVoiceId": "dvoice_...",
"voiceoverLanguage": "en-US",
"voiceoverScript": {
"format": "text",
"content": "This is where the launch starts. Then the product proves why it matters. By the end, the workflow feels simple."
},
"bgm": true,
"bgmQuery": "confident product launch background music",
"sfx": true,
"sfxTypes": ["click", "camera-shutter"]
}{
"projectId": "mrproj_...",
"voiceover": true,
"voiceoverVoiceId": "dvoice_...",
"voiceoverScript": {
"format": "json",
"segments": [
{ "text": "This is where the launch starts." },
{ "text": "Then the product proves why it matters." },
{ "text": "By the end, the workflow feels simple." }
]
},
"aiAnalysis": true,
"visionAnalysis": true,
"textOnScreen": false
}Option reference
These are the exact values you can pass for text, SFX, and transitions.
Text overlays
Pick the style first, then pass its name in templateName.
Use textOverlays when you want exact text at exact times. Each overlay supports text, startTime, endTime or durationSeconds, templateName or templateId, and position.
{
"textOverlays": [
{
"text": "Your line here",
"startTime": 0,
"durationSeconds": 3,
"templateName": "Snapchat Hook",
"position": "top"
}
]
}Common templateName values
| # | Value | What it does | Preview |
|---|---|---|---|
| 1 | Snapchat Hook | Bold white hook near the top. | This changes everything |
| 2 | Headline Bar | Compact white headline bar. | Headline Bar |
| 3 | Cloud Tag | Small rounded label. | Cloud Tag |
| 4 | Midnight Strip | Dark strip with white text. | Midnight Strip |
| 5 | Title | Large title text. | Title |
| 6 | Simple | Readable boxed text. | Simple |
| 7 | Bold | Heavy outlined text. | Bold |
position can be top, middle, or bottom. If you pass an unsupported templateName or templateId, the API returns 400 invalid_request.
SFX
Listen first, then pass the values you want in sfxTypes.
Auto mode: set sfx to true, then choose sfxTypes. If you omit sfxTypes, Subclip uses click and camera-shutter.
{
"sfx": true,
"sfxTypes": ["click", "camera-shutter"],
"sfxVolume": 0.55
}Manual mode: use sfxPlacements when you want a specific sound at a specific clip start or exact timeline time. When sfxPlacements is passed, Subclip uses those manual SFX events instead of automatic transition SFX.
{
"sfxPlacements": [
{
"sfxType": "camera-shutter",
"clipFileName": "02-proof.mp4",
"offsetSeconds": 0,
"volume": 0.6
},
{
"sfxType": "whoosh",
"startTime": 7.2,
"durationSeconds": 0.8,
"volume": 0.45
}
]
}sfxType: required SFX value.
startTime: exact timeline time in seconds.
clipAssetId or clipFileName: use this when the sound should start at a clip start.
offsetSeconds: optional offset from the clip start.
durationSeconds: optional SFX trim length.
volume: optional per-placement number from 0 to 1.
sfxVolume: optional default SFX volume from 0 to 1.
Allowed sfxTypes
| # | Value | What it does | Preview |
|---|---|---|---|
| 1 | click | Small UI click. Good default. | |
| 2 | camera-shutter | Camera/photo cut sound. Good for image changes. | |
| 3 | whoosh | Bigger motion sound. Use sparingly. | |
| 4 | pop | Light pop. | |
| 5 | hit | Stronger impact sound. | |
| 6 | riser | Build-up sound. | |
| 7 | glitch | Digital glitch. | |
| 8 | notification | Phone notification. | |
| 9 | typing | Keyboard typing. | |
| 10 | processing | Loading or processing sound. |
Transitions
Preview the motion first, then pass the values you want in transitionTypes.
Set transitionTypes to the list you want. Subclip rotates through only those values. Use ["none"] for hard cuts.
{
"transitionTypes": ["crossfade", "light-leak", "zoom-in"]
}Allowed transitionTypes
| # | Value | What it does | Preview |
|---|---|---|---|
| 1 | none | No transition. Hard cut. | |
| 2 | crossfade | Soft fade between clips. | |
| 3 | fade | Simple fade. | |
| 4 | light-leak | Bright flash-style transition. | |
| 5 | zoom-in | Pushes into the next clip. | |
| 6 | zoom-out | Pulls out from the clip. | |
| 7 | ken-burns | Slow image movement. | |
| 8 | dip-to-black | Fades through black. | |
| 9 | slide | Slide movement. | |
| 10 | wipe | Wipe movement. | |
| 11 | flip | Flip movement. | |
| 12 | clock-wipe | Circular clock-style wipe. | |
| 13 | iris | Iris-style reveal. |
Music
bgmFileName or bgmAssetId: use an uploaded audio file as BGM.
bgmQuery: let Subclip pick curated BGM.
bgmVolume: optional music volume from 0 to 1.
When bgm is enabled and every visual does not have a manual duration, Subclip can sync clip timing to stronger music beats.
Voiceover
GET /api/v1/media-render/voices: lists saved voices plus Subclip catalog voices. Optional filters: language and gender.
voiceoverVoiceId: required when voiceover is true. Use a value returned by the voices endpoint.
voiceoverLanguage: optional language hint, for example en-US.
voiceoverScript: required when voiceover is true. Use plain text, SRT, or JSON text segments. Do not send segment start/end times; Subclip derives them after speech generation.
If voiceoverVoiceId is not returned by the voices endpoint, the API returns 400 invalid_voiceover_voice_id before the render starts.
Rules and limits
You must provide projectId or sourceDirectoryPrefix.
sourceDirectoryPrefix must be a Subclip media-render directory created for the API key user.
You must upload at least one image or video.
Rate limits are per API key: uploads 20/min, job starts 10/min, voice listing 60/min, status polling 120/min, download URL requests 60/min.
If bgm is true, pass bgmAssetId, bgmFileName, or bgmQuery.
If voiceover is true, pass a voiceoverVoiceId returned by GET /api/v1/media-render/voices.
clipPlacements must include assetId or fileName.
textOverlays must include text and startTime.
templateName must be one of the allowed text templates.
sfxPlacements must include startTime, clipAssetId, or clipFileName.
Volume fields must be numbers from 0 to 1.
Unknown top-level request fields return 400 invalid_request.
Manual clipPlacements win over AI timing.
Manual textOverlays render even when AI text generation is off.
Manual sfxPlacements replace automatic transition SFX.
Outputs and uploaded inputs are deleted after the scheduled cleanup window.
Credits
Credits are estimated before the job starts and deducted only after a successful render.
credits = max(2, renderedMinutes * 5 + audioAddOn + aiPlanningAddOn + visionAnalysisAddOn + audioAnalysisAddOn + voiceoverAddOn)
4. Poll status
Poll every 5 seconds for normal renders. For longer videos or heavier renders, poll every 15 seconds. Do not poll in a tight loop; if you receive 429 rate_limited, wait until X-RateLimit-Reset before trying again.
curl https://www.subclip.app/api/v1/media-render/jobs/mrproj_... \ -H "Authorization: Bearer $SUBCLIP_API_KEY"
{
"projectId": "mrproj_...",
"status": "completed",
"progress": 100,
"outputReady": true,
"creditsUsed": 4.5,
"errorMessage": null
}5. Download result
The API returns a short-lived signed URL. Download that URL to get the rendered MP4. If the render is still queued or processing, this endpoint returns 409 output_not_ready; poll status until outputReady is true.
DOWNLOAD_JSON=$(curl -s https://www.subclip.app/api/v1/media-render/jobs/mrproj_.../download \ -H "Authorization: Bearer $SUBCLIP_API_KEY") DOWNLOAD_URL=$(echo "$DOWNLOAD_JSON" | jq -r '.downloadUrl') curl -L "$DOWNLOAD_URL" -o rendered.mp4
Reference snippet
Use this full Node.js flow when you prefer one script.
const apiKey = process.env.SUBCLIP_API_KEY;
const uploads = await fetch("https://www.subclip.app/api/v1/media-render/uploads", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
projectName: "Launch montage",
files: [
{ fileName: "intro.mp4", contentType: "video/mp4", fileSize: 52428800, durationSeconds: 12 },
{ fileName: "product.png", contentType: "image/png", fileSize: 2048000 },
],
}),
}).then((r) => r.json());
// Upload every file to its matching uploads.uploads[i].uploadUrl.
// The API creates assetId, objectKey, directoryPrefix, and uploadUrl for every file in bulk.
// For Node streams, set Content-Length and duplex: "half".
await fetch("https://www.subclip.app/api/v1/media-render/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
projectId: uploads.projectId,
instructions: "Make a fast launch montage.",
aspectRatio: "9:16",
aiAnalysis: true,
visionAnalysis: true,
textOnScreen: true,
assetDurations: [
{ fileName: "intro.mp4", durationSeconds: 4 },
{ fileName: "product.png", durationSeconds: 3 },
],
bgm: true,
bgmQuery: "upbeat product launch background music",
sfx: true,
sfxTypes: ["click", "camera-shutter"],
transitionTypes: ["crossfade", "zoom-in", "light-leak"],
}),
});
let job;
do {
await new Promise((resolve) => setTimeout(resolve, 5000));
job = await fetch(`https://www.subclip.app/api/v1/media-render/jobs/${uploads.projectId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
}).then((r) => r.json());
} while (job.status !== "completed" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.errorMessage || "Render failed");
const download = await fetch(`https://www.subclip.app/api/v1/media-render/jobs/${uploads.projectId}/download`, {
headers: { Authorization: `Bearer ${apiKey}` },
}).then((r) => r.json());
console.log(download.downloadUrl);