SPVD Render API Guide

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 URLDirect job API
ForClients that were handed a ready-made jobIntegrations with a Render API key
You start withAn executionUrl (plus status URLs) issued by a service you useYour own job description (inputs, output, operation)
AuthenticationNone: the execution URL is signedAuthorization: Bearer <render key>
Progress & downloadSame for bothSame for both

How it works

  1. Start a job (execution URL, or POST /execute). You immediately get the job id and status URLs.
  2. Inputs are downloaded in parallel chunks (downloading_inputs).
  3. FFmpeg runs the requested operation (processing).
  4. The result is uploaded to storage (uploading_output).
  5. You download output.url (done) any time before expires_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);

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_KEY
const 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);
{
  "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
StatusMeaning
pendingAccepted and about to start.
queuedWaiting for a free worker because the service is busy. It will start automatically; keep listening.
downloading_inputsFetching the source files. progress moves as bytes arrive.
processingMerging / converting.
uploading_outputStoring the result for download.
doneFinished. output is set. Terminal state.
failedStopped with an error; see error. Terminal state.
not_foundNo 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).

TransportURLUse when
WebSocketwss://…/api/v1/render/status/{jobId}Browsers, Node.js, mobile apps.
Server-Sent Eventshttps://…/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 jobWhat happensHTTP
NoneA new job starts (status: "queued" in the response).202
doneNothing 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 activeYou are attached to the running job.200 already_exists
failed, or stalled with no progressThe 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). After expires_at the job and its file are deleted and the status streams report not_found.
  • output.url is a direct HTTPS link that works until expires_at. Download it promptly; don't store it long-term.
  • If you set output.downloadName, browsers save the file under that name.
  • output.size is in bytes; output.sizeText is human-readable (e.g. 98.2 MB).

Operations

operation.typeInputsResult
join_streams1+ (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_format1Change the container to output.ext (e.g. HLS/TS → MP4). Re-encodes only when copyStreams is false.
replace_audio_in_video2 — video, then audioKeep the video of input 1 and use the audio of input 2.
extract_audio1Drop the video, keep the audio (e.g. to m4a/mp3).
extract_video1Drop the audio, keep the video.
mix_audio_and_video2 — video with audio, then audioKeep the video of input 1 and mix both audio tracks together (e.g. voice-over on top of the original sound).
no_process1No 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, concurrency 1–10, and type: header uses a Range header, query adds a range query parameter for servers that need it).
  • HLS. For .m3u8 inputs set ext: "m3u8"; segments are fetched in parallel (HLSPlaylistDownload.concurrency). Combine with convert_format to get an MP4.
  • Custom requests. requestOptions sets the method, headers (cookies, referer, user-agent) and body used to fetch an input.
  • Timeouts. Each input may take up to timeout ms (1 s – 10 min, default 2 min) before the job fails.
  • Size guards. limits.inputSize stops 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

HTTPMeaning
202New job accepted.
200Job with this id already exists (see idempotency).
400The request body failed validation. The response lists the invalid fields.
401Missing or wrong Bearer token (direct API only).
404Cached job id not found (GET /execute/{cacheId}).
5xxThe 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:

CauseWhat 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 failed and not_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.