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.
Expiry and concurrency limits
- Expiry. A signed URL has an expiry time built in. After it, the URL returns
410withstatus: "expired"; ask the issuing service for a new one. - Concurrency groups. The issuing service can put several URLs in the same group (for example, every format returned by one request) with a maximum number running at once. Starting one more returns
429withstatus: "concurrency_limit"and nothing is started. Start it again once another job in the group reachesdoneorfailed. Re-calling the URL of a job that is already running or done never counts against the limit.
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/Xq9vT2…",
"sseStatusUrl": "https://render-host/api/v1/render/status/sse/Xq9vT2…"
}Prepare now, run later
POST /api/v1/render/cache stores a job description without running it and returns an executionUrl. Calling it with GET and the same Authorization: Bearer key starts the job, so you can prepare jobs up front and only render the ones that are needed. To let clients start renders without your key, use signed execution URLs instead.
{
"cacheId": "merge-3f6c…",
"executionUrl": "https://render-host/api/v1/render/cache/execute/merge-3f6c…",
"statusUrl": "wss://render-host/api/v1/render/status/Xq9vT2…",
"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/{statusId} | Browsers, Node.js, mobile apps. |
| Server-Sent Events | https://…/api/v1/render/status/sse/{statusId} | 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 and cached jobs). |
404 | Cached job id not found (GET /cache/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.