๐ MiniMax H3 API in Python: Setup, Polling and Errors
A working H3 client in Python โ auth, the v2 payload shape, all five modes, polling, and the six errors that actually bite. Built from the official reference.
Jordan Reyes ยท AI Video Producer
ยท 5 min read

We built a full H3 client this week, reading the v2 reference endpoint by endpoint rather than trusting the launch blog. Three things in it are not what you would guess from the older Hailuo API, and one of them will silently cost you money. Here is the working version.
By the numbers
| Value | |
|---|---|
| Model ID | MiniMax-H3 (literal string) |
| Base URL | https://api.minimax.io (global) / api.minimaxi.com (mainland) |
| Create | POST /v2/video_generation |
| Query | GET /v2/query/video_generation/{task_id} |
| Upload | POST /v1/files/upload |
| Poll interval | 10 seconds (official recommendation) |
| Prompt cap | 7,000 characters |
| Body cap | 64 MB (video โค50MB, image โค30MB, audio โค15MB) |
| Duration | 4โ15 seconds, integers only |
| Output | 2K, 24fps, native stereo audio |
| Task retention | 7 days |
The minimum working call
import os, time, requests
BASE = "https://api.minimax.io"
HEAD = {"Authorization": f"Bearer {os.environ['MINIMAX_API_KEY']}",
"Content-Type": "application/json"}
payload = {
"model": "MiniMax-H3",
"content": [{"type": "text", "text": "A boy playing basketball by the sea"}],
"resolution": "2K", # required, and "2K" is the only accepted value
"duration": 5, # required, 4-15, integer
"ratio": "16:9", # required for text-to-video, cannot be "adaptive"
}
task_id = requests.post(f"{BASE}/v2/video_generation",
headers=HEAD, json=payload).json()["task_id"]
while True:
time.sleep(10)
task = requests.get(f"{BASE}/v2/query/video_generation/{task_id}",
headers=HEAD).json()["task"]
if task["status"] == "succeeded":
open("out.mp4", "wb").write(requests.get(task["content"]["url"]).content)
break
if task["status"] in ("failed", "cancelled", "expired"):
raise RuntimeError(task.get("error"))
If you have not picked a route yet, where to run H3 compares MiniMax direct against fal, ComfyUI, Vercel AI Gateway and the reseller tier โ the code above targets MiniMax's own API, which is the base rate everything else marks up.
There are exactly six top-level fields on this endpoint. model, content, resolution and duration are all required; only ratio and callback_url are optional. There is no seed, no prompt_optimizer, no watermark, no n, and no audio toggle. Those exist on the v1 Hailuo endpoints and sending them here is a 400.
The five modes, and the one everyone misses
The mode is implied by what you put in content[], not by a parameter.
# 1. text-to-video โ ratio required, must be concrete
[{"type": "text", "text": prompt}]
# 2. first frame
[{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": U}, "role": "first_frame"}]
# 3. LAST FRAME ONLY โ a valid standalone mode
[{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": U}, "role": "last_frame"}]
# 4. first + last, interpolated between
# 5. reference โ up to 9 images + 3 videos + 3 audio, 12 files total
[{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": A}, "role": "reference_image"},
{"type": "audio_url", "audio_url": {"url": B}, "role": "reference_audio"}]
Most write-ups list four modes. Last-frame-only is a documented standalone mode โ you hand H3 the frame you are cutting on and it invents the approach. Our own validator rejected it until we read the create reference properly.
Two structural rules: frames and references are mutually exclusive in one request, and every request needs a non-empty text item regardless of mode. role is optional on images (an image with no role is treated as the first frame) but required on video and audio items.
Errors are HTTP codes now, not base_resp
If you are porting v1 code, this is the change that breaks everything quietly. v1 returns HTTP 200 with a base_resp.status_code of 1004 or 1026. v2 returns real HTTP status codes:
| Code | Type | Cause |
|---|---|---|
| 400 | bad_request_error | Missing text, ratio on a t2v call, duration outside 4โ15 |
| 401 | authorized_error | Key/host region mismatch |
| 402 | insufficient_balance_error | Top up |
| 422 | unprocessable_entity_error | Sensitive content โ rewrite the prompt |
| 429 | rate_limit_error | Over the concurrency cap |
| 500 | server_error | Retry |
{"type": "error",
"error": {"type": "rate_limit_error",
"message": "rate limit, please retry later (1002)", "http_code": 429},
"request_id": "..."}
The legacy numeric codes ride along inside error.message as a (1002) suffix, and inside task.error.code on a failed task. Worth decoding: 1026 is a sensitive prompt, 1027 is sensitive output.
Only 429 and 5xx are worth retrying. 400, 401, 402 and 422 are deterministic โ a retry loop on those just burns wall-clock. And a task can fail after a 200, so always check status as well as the HTTP code.
Rate limiting is a concurrency cap, not RPM
Two in-flight tasks on the free tier, fifteen on paid. That is a cap on simultaneous jobs, not requests per minute โ different from Hailuo v1's 5/20 RPM. A worker pool sized to your tier is the right shape:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=2) as pool: # 15 on a paid plan
list(pool.map(render_one, shots))
The billing detail that will surprise you
usage.total_seconds = input_seconds + output_seconds, and reference video duration bills as input. A 10-second reference clip on a 5-second render bills 15 seconds, not 5.
Read the real figure off the finished task rather than assuming it equals your duration:
u = task["usage"]
print(u["total_seconds"], u["input_seconds"], u["output_seconds"], u["input_image_count"])
MiniMax has published no official rate card; third parties list 2K from roughly $0.13/sec. Budget against the returned seconds, not the requested ones โ H3 vs Veo 3.1 works through what that means against a model that does publish prices.
Upload large assets instead of inlining them
Base64 inflates a file by about a third and the body caps at 64MB, so a 50MB reference video cannot be inlined at all.
r = requests.post(f"{BASE}/v1/files/upload",
headers={"Authorization": HEAD["Authorization"]},
data={"purpose": "video_generation_input"},
files={"file": open("hero.png", "rb")})
ref = f"mm_file://{r.json()['file']['file_id']}" # retained 7 days
video_generation_input is the correct purpose value โ video_understanding is a different thing for multimodal chat.
How we built this
We wrote a client against MiniMax's published v2 create, query and file-upload references, reconciling each documented limit against the schemas rather than the marketing page. That is where the fifth mode, the HTTP error envelope and the reference-billing rule surfaced.
To be clear about what this is: the payloads and validation are checked against the official spec and exercised offline, not against a live billed account. We have not published render times or output-quality claims here because we have not measured them. Everything above is API behaviour as documented.
What didn't make the cut
A retry-with-jitter wrapper and a webhook receiver. callback_url works โ MiniMax POSTs a challenge your endpoint must echo unchanged within 3 seconds, then pushes on every status change โ but retry behaviour and signing are undocumented, so polling remains the reliable path. We left the receiver out rather than publish an untested handler.
For what the reference stack can actually do once you are connected, see the omni-reference workflow.
Frequently asked questions
โธWhy does my MiniMax API key return 401?
Almost always a region mismatch. MiniMax runs two hosts โ api.minimax.io for global and api.minimaxi.com for mainland China โ and a key issued for one returns 401 authorized_error against the other. Check which console you created the key in before debugging anything else.
โธDoes H3 use the same API as Hailuo 2.3?
No, and this is the biggest migration trap. Hailuo 2.3 uses v1 with a flat payload and a base_resp error envelope, then a separate file-retrieve call to get your video. H3 uses v2 with a multimodal content array, HTTP status codes for errors, and returns the download URL directly on the query response. Code written for v1 will not work.
โธHow long should I poll for?
MiniMax's docs recommend a 10-second interval. Statuses are queued, running, succeeded, failed, cancelled and expired โ treat the last four as terminal. Tasks are only queryable for 7 days, and the download URL is time-limited but renewable: re-issue the same query to get a fresh one.
โธCan I send local files, or do they need to be URLs?
Three forms work: a public https URL, an mm_file://{file_id} reference from the upload endpoint, or a base64 data URI. The request body caps at 64MB total and base64 inflates assets by about a third, so upload anything substantial and pass the mm_file reference instead.
The 5 best AI video finds, every week
New models, tested prompts, and what actually worked in our production โ one short email a week. No spam, unsubscribe anytime.
Written by Jordan Reyes
AI Video Producer
Runs multiple faceless YouTube channels and tests every major AI video model against the same prompts before recommending one. Tracks render time and credit cost like other people track calories.
Explore these topics
Every guide, comparison and prompt library we have on each.





