Fill these in to personalise every example on this page. Nothing is sent anywhere.
Render API guide
The Render API turns media streams into finished files: it merges separate video and audio, converts streaming formats to MP4, or extracts audio, then hosts the result for you to download.
Overview
There are two ways to use it:
| Signed execution URL | Direct job API | |
|---|---|---|
| For | Clients that were handed a ready-made job | Integrations with a Render API key |
| You start with | An executionUrl (plus status URLs) issued by a service you use | Your own job description (inputs, output, operation) |
| Authentication | None: the execution URL is signed | Authorization: Bearer <render key> |
| Progress & download | Same for both | Same for both |
How it works
- Start a job (execution URL, or
POST /execute). You immediately get the job id and status URLs. - Inputs are downloaded in parallel chunks (
downloading_inputs). - FFmpeg runs the requested operation (
processing). - The result is uploaded to storage (
uploading_output). - You download
output.url(done) any time beforeexpires_at.
Typical timing: a few seconds for short clips, 30–120 seconds for long HD videos. Very large files, or a busy service (queued), can take longer.
Signed execution URLs
Services built on the Render API can prepare a job for you and hand you a set of URLs. You need no key: the execution URL is signed and already contains the full job description and its limits.
{
"executionUrl": "https://render-host/…", // GET to start
"statusUrl": "wss://render-host/…", // WebSocket progress
"sseStatusUrl": "https://render-host/…" // Server-Sent Events progress
}function watchRender(sseUrl) {
return new Promise((resolve, reject) => {
const events = new EventSource(sseUrl);
events.onmessage = (event) => {
const job = JSON.parse(event.data);
console.log(job.status, job.progress);
if (job.status === "done") { events.close(); resolve(job.output); }
if (job.status === "failed" || job.status === "not_found") { events.close(); reject(new Error(job.error)); }
};
});
}
await fetch(urls.executionUrl); // 1. start (or re-attach to) the job
const output = await watchRender(urls.sseStatusUrl); // 2. wait for it to finish
console.log(output.url, output.sizeText);import json, requests
def watch_render(sse_url):
with requests.get(sse_url, stream=True, timeout=900) as stream:
for line in stream.iter_lines(decode_unicode=True):
if not line or not line.startswith("data:"):
continue
job = json.loads(line[5:])
print(job["status"], job.get("progress"))
if job["status"] == "done":
return job["output"]
if job["status"] in ("failed", "not_found"):
raise RuntimeError(job.get("error"))
requests.get(urls["executionUrl"], timeout=60) # 1. start (or re-attach to) the job
output = watch_render(urls["sseStatusUrl"]) # 2. wait for it to finish
print(output["url"], output["sizeText"])<?php
function watchRender(string $sseUrl): array
{
$buffer = "";
$final = null;
$ch = curl_init($sseUrl);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Accept: text/event-stream"],
CURLOPT_TIMEOUT => 900,
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$buffer, &$final) {
$buffer .= $chunk;
while (($pos = strpos($buffer, "\n\n")) !== false) {
$event = substr($buffer, 0, $pos);
$buffer = substr($buffer, $pos + 2);
foreach (explode("\n", $event) as $line) {
if (!str_starts_with($line, "data:")) {
continue;
}
$job = json_decode(trim(substr($line, 5)), true);
echo $job["status"], " ", $job["progress"] ?? "", PHP_EOL;
if (in_array($job["status"], ["done", "failed", "not_found"], true)) {
$final = $job;
return 0; // stop reading, the job has finished
}
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
if ($final === null) {
throw new RuntimeException("Status stream ended unexpectedly");
}
if ($final["status"] !== "done") {
throw new RuntimeException($final["error"] ?? "Render failed");
}
return $final["output"];
}
file_get_contents($urls["executionUrl"]); // 1. start (or re-attach to) the job
$output = watchRender($urls["sseStatusUrl"]); // 2. wait for it to finish
echo $output["url"], " ", $output["sizeText"], PHP_EOL;# 1. Start (or re-attach to) the job
curl "$EXECUTION_URL"
# 2. Follow progress as Server-Sent Events
curl -N "$SSE_STATUS_URL"A signed URL starts exactly the job it describes; it cannot be edited. The source links inside it may be temporary, so start the job soon after you receive the URL. If the job fails because a source expired, ask the issuing service for a new URL.
Direct job API
With a Render API key you can describe jobs yourself. Send your key as a Bearer token:
Authorization: Bearer YOUR_RENDER_KEYconst res = await fetch("https://api-host/api/v1/render/execute", {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_RENDER_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
id: `merge-${crypto.randomUUID()}`,
inputs: [
{ url: "https://cdn.example.com/video-only.mp4", ext: "mp4" },
{ url: "https://cdn.example.com/audio-only.m4a", ext: "m4a" },
],
output: { ext: "mp4", downloadName: "My video (1080p).mp4" },
operation: { type: "join_streams" },
ttl: 3600000,
}),
});
const job = await res.json(); // { jobId, status, message, statusUrl, sseStatusUrl }; 202 = new, 200 = existing
console.log(job);import uuid, requests
res = requests.post(
"https://api-host/api/v1/render/execute",
headers={"Authorization": "Bearer YOUR_RENDER_KEY"},
json={
"id": f"merge-{uuid.uuid4()}",
"inputs": [
{"url": "https://cdn.example.com/video-only.mp4", "ext": "mp4"},
{"url": "https://cdn.example.com/audio-only.m4a", "ext": "m4a"},
],
"output": {"ext": "mp4", "downloadName": "My video (1080p).mp4"},
"operation": {"type": "join_streams"},
"ttl": 3600000,
},
timeout=60,
)
job = res.json()
print(job)<?php
$body = [
"id" => "merge-" . bin2hex(random_bytes(16)),
"inputs" => [
["url" => "https://cdn.example.com/video-only.mp4", "ext" => "mp4"],
["url" => "https://cdn.example.com/audio-only.m4a", "ext" => "m4a"],
],
"output" => ["ext" => "mp4", "downloadName" => "My video (1080p).mp4"],
"operation" => ["type" => "join_streams"],
"ttl" => 3600000,
];
$ch = curl_init("https://api-host/api/v1/render/execute");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer YOUR_RENDER_KEY",
"Content-Type: application/json",
],
]);
$job = json_decode(curl_exec($ch), true);
print_r($job);curl -X POST "https://api-host/api/v1/render/execute" \
-H "Authorization: Bearer YOUR_RENDER_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "merge-3f6c2a1e-5b8d-4c1f-9e2a-7d4b6c8e0f12",
"inputs": [
{ "url": "https://cdn.example.com/video-only.mp4", "ext": "mp4" },
{ "url": "https://cdn.example.com/audio-only.m4a", "ext": "m4a" }
],
"output": { "ext": "mp4", "downloadName": "My video (1080p).mp4" },
"operation": { "type": "join_streams" },
"ttl": 3600000
}'{
"jobId": "merge-3f6c2a1e-5b8d-4c1f-9e2a-7d4b6c8e0f12",
"status": "queued",
"message": "Job accepted for processing.",
"statusUrl": "wss://render-host/api/v1/render/status/merge-3f6c…",
"sseStatusUrl": "https://render-host/api/v1/render/status/sse/merge-3f6c…"
}Prepare now, run later
POST /api/v1/render/cache stores a job description without running it and returns a public executionUrl. Hand that URL to a client (browser, app, another service): calling it with GET starts the job, no key required. This is how you offer “render on click” without exposing your key or rendering files nobody downloads.
{
"cacheId": "merge-3f6c…",
"executionUrl": "https://render-host/api/v1/render/execute/merge-3f6c…",
"statusUrl": "wss://render-host/api/v1/render/status/merge-3f6c…",
"expiresAt": "2026-09-23T13:00:00.000Z"
}Anyone holding a cached executionUrl can start that job. Use unguessable ids (UUIDs) and share the URL only with the intended client.
Job lifecycle
pending ─┐
├──► downloading_inputs ──► processing ──► uploading_output ──► done
queued ─┘ │ │ │
└─────────────────────┴─────────────────┴──────────► failed| Status | Meaning |
|---|---|
pending | Accepted and about to start. |
queued | Waiting for a free worker because the service is busy. It will start automatically; keep listening. |
downloading_inputs | Fetching the source files. progress moves as bytes arrive. |
processing | Merging / converting. |
uploading_output | Storing the result for download. |
done | Finished. output is set. Terminal state. |
failed | Stopped with an error; see error. Terminal state. |
not_found | No job with this id exists (never started, or already expired). Only sent by the status streams. Terminal. |
Tracking progress
Both status streams send the same JSON status event about once per second, and close by themselves after a terminal status (done, failed, not_found).
| Transport | URL | Use when |
|---|---|---|
| WebSocket | wss://…/api/v1/render/status/{jobId} | Browsers, Node.js, mobile apps. |
| Server-Sent Events | https://…/api/v1/render/status/sse/{jobId} | Environments without WebSockets, simple scripts, curl -N, serverless functions. |
{
"job_id": "a1b2c3…",
"status": "downloading_inputs",
"progress": 37,
"output": null,
"error": null,
"created_at": "2026-09-23T12:00:00.000Z",
"expires_at": "2026-09-23T13:00:00.000Z"
}progress is an overall percentage (0–100) across all stages. How the percentage is split between stages is set by the job; with the direct API you choose it with progressBrackets.
Connection dropped? Just reconnect to the same status URL. Jobs keep running whether or not anyone is listening, and a finished job immediately replays its final done event.
Re-running & idempotency
A job is identified by its id. Starting the same id again is safe:
| Existing job | What happens | HTTP |
|---|---|---|
| None | A new job starts (status: "queued" in the response). | 202 |
done | Nothing is re-rendered; you get the finished job and its expiry is extended when the new request's ttl reaches further. | 200 already_exists |
| Running and active | You are attached to the running job. | 200 already_exists |
failed, or stalled with no progress | The job is restarted from scratch. | 202 |
This makes it safe to retry a start request after a network error.
Expiry & downloads
- Each job has a
ttl(3 minutes to 24 hours, default 1 hour; signed URLs carry their own). Afterexpires_atthe job and its file are deleted and the status streams reportnot_found. output.urlis a direct HTTPS link that works untilexpires_at. Download it promptly; don't store it long-term.- If you set
output.downloadName, browsers save the file under that name. output.sizeis in bytes;output.sizeTextis human-readable (e.g.98.2 MB).
Operations
operation.type | Inputs | Result |
|---|---|---|
join_streams | 1+ (e.g. video, audio) | Put all input streams into one file without re-encoding. The usual way to merge a video-only and an audio-only stream. |
convert_format | 1 | Change the container to output.ext (e.g. HLS/TS → MP4). Re-encodes only when copyStreams is false. |
replace_audio_in_video | 2 — video, then audio | Keep the video of input 1 and use the audio of input 2. |
extract_audio | 1 | Drop the video, keep the audio (e.g. to m4a/mp3). |
extract_video | 1 | Drop the audio, keep the video. |
mix_audio_and_video | 2 — video with audio, then audio | Keep the video of input 1 and mix both audio tracks together (e.g. voice-over on top of the original sound). |
no_process | 1 | No processing: store the first input as-is and return a download link. |
operation.copyStreams (default true) copies audio/video data without re-encoding: fast and lossless, but the output container must support the input codecs (MP4 with H.264/AAC always works). Set it to false to re-encode when converting between incompatible formats, which is much slower.
Fetching inputs
- Chunked downloads. Large inputs are downloaded in parallel byte ranges. Tune with
chunkDownload(size,concurrency1–10, andtype:headeruses aRangeheader,queryadds a range query parameter for servers that need it). - HLS. For
.m3u8inputs setext: "m3u8"; segments are fetched in parallel (HLSPlaylistDownload.concurrency). Combine withconvert_formatto get an MP4. - Custom requests.
requestOptionssets the method, headers (cookies, referer, user-agent) and body used to fetch an input. - Timeouts. Each input may take up to
timeoutms (1 s – 10 min, default 2 min) before the job fails. - Size guards.
limits.inputSizestops the job early when an input, or all inputs together, exceed your limits. Both limits default to, and cannot exceed, 5 GB.
Errors & failures
When starting a job
| HTTP | Meaning |
|---|---|
202 | New job accepted. |
200 | Job with this id already exists (see idempotency). |
400 | The request body failed validation. The response lists the invalid fields. |
401 | Missing or wrong Bearer token (direct API only). |
404 | Cached job id not found (GET /execute/{cacheId}). |
5xx | The execution URL is invalid or was modified, or a temporary server problem. |
When a job fails
The status event has status: "failed" and a readable error message. Common causes:
| Cause | What to do |
|---|---|
| A source link expired or refused the download (403/404/410). | Get fresh source links (or a new signed URL) and start again. |
| An input is larger than allowed (per-input or total size limit). | Use a smaller input, or raise the job's size limits. |
| Download timed out / incomplete. | Retry; the job restarts automatically when you start it again. |
| Processing failed (incompatible codecs for the container). | Pick another output extension, or set copyStreams: false. |
| Service at capacity (“try again later”). | Wait a few seconds and start the same job again. |
Best practices
- Start renders only when a user actually wants the file (e.g. on click), not for every search result.
- Always handle
failedandnot_found; set a client-side overall timeout (e.g. 10 minutes for long videos). - On failure, retry by starting the same job again with exponential backoff (5 s, 15 s, 45 s). Use fresh execution URLs if the source links may have expired.
- Use stable, unique ids for direct jobs so repeats reuse finished results instead of re-rendering.
- Download or stream the file to your user soon after
done.
FAQ
How long does the file stay available?
Until expires_at, which is set by the job's ttl.
I only see not_found.
The job was never started (call the execution URL first) or it has expired.
Can I cancel a job?
Not currently. Unused jobs simply expire.