Home › Guides › Python tutorial

MiniMax H3 in Python, end to end

Updated 2026-10-02

This tutorial builds a small, production-shaped Python client for MiniMax H3 in four steps: create a job, poll it, download the result, and handle the ways it can go wrong. It uses only requests. Replace the placeholder key with your own, ideally read from an environment variable.

0. Setup

pip install requests
export VIDEOROUTER_KEY=llmr_sk_live_...

Base URL is https://videorouter.sh/api/v1, auth is a bearer token, and the model id for the base H3 model is minimax/h3. Variants are minimax/h3-max and minimax/h3-max-turbo.

1. Create the job

import os, time, requests

API = "https://videorouter.sh/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['VIDEOROUTER_KEY']}",
    "Content-Type": "application/json",
}

def create_job(prompt, model="minimax/h3", **params):
    body = {"model": model, "prompt": prompt, **params}
    r = requests.post(f"{API}/videos", headers=HEADERS, json=body, timeout=30)
    if r.status_code != 200 and r.status_code != 202:
        raise ApiError.from_response(r)
    return r.json()

Common parameters are duration_secs, resolution, aspect_ratio and, for image-to-video, start_image_url. Billing is a single charge at creation from the requested duration. Resolution and aspect ratio that a model does not support are ignored rather than rejected, so verify the output size if it matters.

2. Model the errors

Errors use the OpenAI-style envelope {"error": {"message", "type", "code"}}. Wrapping it once keeps the rest of your code clean:

class ApiError(Exception):
    def __init__(self, status, message, type_=None, code=None, retry_after=None):
        super().__init__(f"{status} {type_}/{code}: {message}")
        self.status, self.type, self.code = status, type_, code
        self.retry_after = retry_after

    @classmethod
    def from_response(cls, r):
        try:
            e = r.json().get("error", {})
        except ValueError:
            e = {}
        ra = r.headers.get("Retry-After")
        return cls(r.status_code, e.get("message", r.text[:200]),
                   e.get("type"), e.get("code"), int(ra) if ra and ra.isdigit() else None)

The statuses worth distinguishing: 400 (bad request or unsupported field, fix it), 401 (key), 402 (insufficient credits or key spend cap), 403 (model_not_allowed by the key's allow-list), 429 (rate limit, wait for Retry-After), and 5xx upstream_error (every candidate host failed, not billed).

3. Poll until done

def wait_for_job(job, every=5, timeout=1200):
    job_id = job["id"]
    deadline = time.time() + timeout
    while job["status"] not in ("completed", "failed"):
        if time.time() > deadline:
            raise TimeoutError(f"job {job_id} still {job['status']} after {timeout}s")
        time.sleep(every)
        r = requests.get(f"{API}/videos/{job_id}", headers=HEADERS, timeout=30)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", 5)))
            continue
        if r.status_code >= 400:
            raise ApiError.from_response(r)
        job = r.json()
    return job

Status goes queued, in_progress, then completed or failed. Polling is free, so the interval is about politeness and latency, not cost. If your timeout fires, the job is probably still running and billed, so store the id and check later instead of submitting again.

4. Download and save

from pathlib import Path

def download(job, out_dir="out"):
    if job["status"] == "failed":
        raise RuntimeError(f"generation failed: {job.get('error')}")
    url = job["data"][0]["url"]
    Path(out_dir).mkdir(exist_ok=True)
    path = Path(out_dir) / f"{job['id']}.mp4"
    with requests.get(url, stream=True, timeout=60) as r:
        r.raise_for_status()
        tmp = path.with_suffix(".part")
        with open(tmp, "wb") as f:
            for chunk in r.iter_content(1 << 20):
                f.write(chunk)
        tmp.rename(path)           # never leave a half-written .mp4
    return path

Download straight away and keep the file in your own storage rather than relying on the provider URL staying valid.

5. Put it together

if __name__ == "__main__":
    job = create_job(
        "a paper airplane gliding over a city at golden hour",
        duration_secs=5,
        resolution="768p",
        aspect_ratio="16:9",
    )
    print("created", job["id"], "via", job.get("provider"))
    done = wait_for_job(job)
    print("saved", download(done))

6. Pinning a host

With no host in the model id, each request goes to the cheapest healthy host and fails over automatically if a submission is rejected. To prefer a host, add it to the id:

job = create_job("a paper airplane gliding over a city", model="minimax/h3/fal", duration_secs=5)

minimax/h3/fal tries Fal first and still falls back to the rest of the pool if Fal errors. It is a preference, not a pin. For a request that must stay on one host, use the provider object:

job = create_job(
    "a paper airplane gliding over a city",
    model="minimax/h3",
    provider={"only": ["fal"], "allow_fallbacks": False},
    duration_secs=5,
)

This makes exactly one attempt on that host and returns an error immediately if it rejects the job. The response includes provider, fallback_used and fallbacks_tried, so log them. The model page lists every host and its per-resolution price, and you can read how to compare them in the tier guide.

7. Running several jobs at once

Because creation returns immediately with an id, you can submit a batch first and poll afterwards. Total wall-clock time then approaches the slowest single job instead of the sum:

from concurrent.futures import ThreadPoolExecutor

prompts = ["a paper airplane at dawn", "a paper airplane in a storm", "a paper airplane at night"]
jobs = [create_job(p, duration_secs=5) for p in prompts]

with ThreadPoolExecutor(max_workers=3) as pool:
    done = list(pool.map(wait_for_job, jobs))

paths = [download(j) for j in done if j["status"] == "completed"]

Keep the worker count modest. Each key has request-per-minute limits enforced as a token bucket, and a 429 response carries a Retry-After value that the polling loop above already honours.

8. Retrying safely

Retry only what is safe to retry:

Image-to-video only needs start_image_url added to create_job, as covered in the image-to-video guide. For a first call without the scaffolding, see the quickstart, and create a key at videorouter.sh/signup.

Frequently asked questions

What model id do I use for MiniMax H3 in Python?

Use minimax/h3, or minimax/h3-max and minimax/h3-max-turbo for the variants. Add a host suffix such as minimax/h3/fal to prefer a provider.

How do I know when the video is ready?

Poll GET /videos/{id} until status is completed or failed. Polling is free, and the video URL is in data[0].url when completed.

Is the model/host suffix a hard pin?

No. It is tried first but the rest of the pool remains as fallback. For a hard pin use provider.only with allow_fallbacks set to false.

Which errors should I retry?

Retry 429 after the Retry-After delay and transient 5xx at creation. Do not retry 400, 401, 402 or 403, and do not resubmit a job that is merely slow.

Keep reading

Using MiniMax H3 is one part of the job.

VideoRouter puts it next to dozens of other video and image models behind one API key, so you can compare providers, prices and fail over automatically. Compare providers on VideoRouter →