Download Public Videos in Python Using the RenderIO API

May 22, 2026 · RenderIO

subprocess is not a video downloading strategy

The typical Python approach is subprocess.run() or subprocess.Popen() shelling out to yt-dlp:

import subprocess

result = subprocess.run(
    ["yt-dlp", "-o", "%(title)s.%(ext)s", url],
    capture_output=True,
    text=True,
)

Fine on a server where you control the environment. Not fine on Lambda, Cloud Run, or anywhere that doesn't let you install binaries. And even where it does work, you own the update cycle — yt-dlp releases frequently, and a stale build silently fails when a platform updates their extraction logic.

The API version is just requests.post().

Setup

pip install requests
import os
import time
import requests

API_KEY = os.environ["RENDERIO_API_KEY"]
BASE_URL = "https://renderio.dev/api/v1"

Basic download

def download_video(url: str) -> dict:
    response = requests.post(
        f"{BASE_URL}/ytdlp-download",
        headers={
            "Content-Type": "application/json",
            "X-API-KEY": API_KEY,
        },
        json={
            "input_urls": {"in_video": url},
        },
    )
    response.raise_for_status()
    command_id = response.json()["command_id"]
    return poll_until_done(command_id)


def poll_until_done(command_id: str, interval: float = 3.0, timeout: float = 300.0) -> dict:
    deadline = time.time() + timeout

    while time.time() < deadline:
        response = requests.get(
            f"{BASE_URL}/commands/{command_id}",
            headers={"X-API-KEY": API_KEY},
        )
        response.raise_for_status()
        result = response.json()

        if result["status"] == "SUCCESS":
            return result
        if result["status"] == "FAILED":
            raise RuntimeError(f"Download failed: {result.get('error', 'unknown error')}")

        time.sleep(interval)

    raise TimeoutError(f"Command {command_id} did not complete within {timeout}s")

Usage:

result = download_video("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
file_url = result["output_files"]["out_video"]["storage_url"]
print(file_url)

The output key follows the input key: in_videoout_video, in_clipout_clip. Whatever comes after in_ becomes the out_ key.

Tagging jobs with metadata

Pass up to 10 key/value pairs in metadata:

def download_with_metadata(url: str, user_id: str, job_ref: str) -> str:
    response = requests.post(
        f"{BASE_URL}/ytdlp-download",
        headers={"Content-Type": "application/json", "X-API-KEY": API_KEY},
        json={
            "input_urls": {"in_video": url},
            "metadata": {
                "user_id": user_id,
                "job_ref": job_ref,
            },
        },
    )
    response.raise_for_status()
    return response.json()["command_id"]

Metadata comes back in poll responses and webhooks.

Full client class

import os
import time
from dataclasses import dataclass
from typing import Optional
import requests


@dataclass
class OutputFile:
    storage_url: str
    size_mbytes: float


@dataclass
class CommandResult:
    command_id: str
    status: str
    output_files: dict[str, OutputFile]
    error: Optional[str] = None
    metadata: Optional[dict] = None


class VideoDownloader:
    def __init__(
        self,
        api_key: str,
        poll_interval: float = 3.0,
        timeout: float = 300.0,
    ):
        self.api_key = api_key
        self.base_url = "https://renderio.dev/api/v1"
        self.poll_interval = poll_interval
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({
            "Content-Type": "application/json",
            "X-API-KEY": api_key,
        })

    def download(self, url: str, metadata: dict | None = None) -> CommandResult:
        command_id = self._submit(url, metadata or {})
        return self._poll(command_id)

    def download_and_process(
        self,
        url: str,
        ffmpeg_command: str,
        output_files: dict[str, str],
        metadata: dict | None = None,
    ) -> CommandResult:
        response = self.session.post(
            f"{self.base_url}/run-ytdlp-command",
            json={
                "input_urls": {"in_video": url},
                "ffmpeg_command": ffmpeg_command,
                "output_files": output_files,
                "metadata": metadata or {},
            },
        )
        response.raise_for_status()
        command_id = response.json()["command_id"]
        return self._poll(command_id)

    def _submit(self, url: str, metadata: dict) -> str:
        response = self.session.post(
            f"{self.base_url}/ytdlp-download",
            json={"input_urls": {"in_video": url}, "metadata": metadata},
        )
        response.raise_for_status()
        return response.json()["command_id"]

    def _poll(self, command_id: str) -> CommandResult:
        deadline = time.time() + self.timeout

        while time.time() < deadline:
            response = self.session.get(f"{self.base_url}/commands/{command_id}")
            response.raise_for_status()
            data = response.json()

            if data["status"] == "SUCCESS":
                output_files = {
                    key: OutputFile(**val)
                    for key, val in (data.get("output_files") or {}).items()
                }
                return CommandResult(
                    command_id=data["command_id"],
                    status=data["status"],
                    output_files=output_files,
                    metadata=data.get("metadata"),
                )

            if data["status"] == "FAILED":
                raise RuntimeError(f"Command failed: {data.get('error', 'unknown')}")

            time.sleep(self.poll_interval)

        raise TimeoutError(f"Timed out waiting for {command_id}")

Usage:

downloader = VideoDownloader(os.environ["RENDERIO_API_KEY"])

result = downloader.download(
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    metadata={"user_id": "usr_123"},
)
print(result.output_files["out_video"].storage_url)

# Download a TikTok video and extract the audio
audio_result = downloader.download_and_process(
    url="https://www.tiktok.com/@user/video/1234567890",
    ffmpeg_command="-i {{in_video}} -vn -c:a libmp3lame -q:a 2 {{out_audio}}",
    output_files={"out_audio": "audio.mp3"},
)
print(audio_result.output_files["out_audio"].storage_url)

Async with httpx

If you're running FastAPI or anything asyncio-based:

pip install httpx
import asyncio
import httpx
import os


async def download_video_async(url: str) -> dict:
    api_key = os.environ["RENDERIO_API_KEY"]
    base_url = "https://renderio.dev/api/v1"

    async with httpx.AsyncClient() as client:
        submit = await client.post(
            f"{base_url}/ytdlp-download",
            headers={"Content-Type": "application/json", "X-API-KEY": api_key},
            json={"input_urls": {"in_video": url}},
        )
        submit.raise_for_status()
        command_id = submit.json()["command_id"]

        deadline = asyncio.get_event_loop().time() + 300

        while asyncio.get_event_loop().time() < deadline:
            await asyncio.sleep(3)

            poll = await client.get(
                f"{base_url}/commands/{command_id}",
                headers={"X-API-KEY": api_key},
            )
            result = poll.json()

            if result["status"] == "SUCCESS":
                return result
            if result["status"] == "FAILED":
                raise RuntimeError(f"Failed: {result.get('error')}")

        raise TimeoutError("Download timed out")


result = asyncio.run(download_video_async("https://www.youtube.com/watch?v=dQw4w9WgXcQ"))
print(result["output_files"]["out_video"]["storage_url"])

Parallel downloads

Submit all jobs first, then poll concurrently with asyncio:

async def download_many(urls: list[str]) -> list[dict]:
    api_key = os.environ["RENDERIO_API_KEY"]
    base_url = "https://renderio.dev/api/v1"

    async with httpx.AsyncClient() as client:
        tasks = [
            client.post(
                f"{base_url}/ytdlp-download",
                headers={"Content-Type": "application/json", "X-API-KEY": api_key},
                json={"input_urls": {"in_video": url}},
            )
            for url in urls
        ]
        responses = await asyncio.gather(*tasks)
        command_ids = [r.json()["command_id"] for r in responses]

        results = await asyncio.gather(*[
            poll_async(client, cid, api_key, base_url)
            for cid in command_ids
        ])
        return results


async def poll_async(client, command_id, api_key, base_url, timeout=300):
    deadline = asyncio.get_event_loop().time() + timeout

    while asyncio.get_event_loop().time() < deadline:
        await asyncio.sleep(3)
        res = await client.get(
            f"{base_url}/commands/{command_id}",
            headers={"X-API-KEY": api_key},
        )
        data = res.json()
        if data["status"] in ("SUCCESS", "FAILED"):
            return data

    raise TimeoutError(command_id)

Download and process

For downloading and immediately transcoding in one call, use download_and_process() from the client class above — it hits /run-ytdlp-command with your FFmpeg command. More recipes in the download and process guide.

FAQ

Which Python versions does this support?

Python 3.10+ for the dict[str, str] type hints. Replace with Dict from typing for older versions.

Does this work in Django or Flask?

Yes. Call download_video() from your view, or better, kick it off via Celery and return the command_id to the client immediately — don't block a request thread waiting on a 30-second download.

How do I handle rate limits?

Check response.headers["X-RateLimit-Remaining"] before batching. If you're near the limit, sleep between submissions.

Can I save the downloaded file locally?

You get a URL from the API. Fetch it to disk with:

import urllib.request

urllib.request.urlretrieve(file_url, "local_video.mp4")