Tutorial
How to Call hailuo-2.3-fast/image-to-video via the HiAPI API: curl and Python
A first working request for the fast, low-cost image-to-video tier — from model ID to downloaded MP4
What hailuo-2.3-fast/image-to-video gives you
hailuo-2.3-fast/image-to-video is the speed-optimized tier of HiAPI's image-to-video lineup. You give it a single still frame plus a text prompt describing the motion you want, and it returns a short video clip that animates forward from that frame. The "fast" in the model ID is the point: this tier trades some of the fine-grained fidelity of heavier video models for lower latency and lower cost per render, which makes it a good fit for iteration and volume.
Pick this model when you need a plausible first working clip rather than a hero shot: storyboard previews, social-first loops, background motion plates, or the draft pass of a pipeline where a heavier model handles the final render. If your output needs long-duration shots, complex camera choreography, or tight identity preservation across many frames, route those jobs to a heavier tier instead — and keep the same request shape so switching is a config change.
Everything below is built around one request you can actually run. The goal is a downloaded MP4 on disk, not a screenshot of a success response.
The request at a glance
A video render takes seconds to minutes, so the API is asynchronous: you submit a job, get a task identifier back, then poll that task until it reports completion and hands you the output. That means every integration has two phases, submit and poll, and only the submit phase differs between models.
The submit request has four things you control:
- Endpoint and method — a
POSTto the HiAPI video generation endpoint. - Auth header — your key as
Authorization: Bearer $HIAPI_API_KEY. Keep the key in an environment variable; never inline it in committed code. - Model ID — the literal string
hailuo-2.3-fast/image-to-video. This is what selects the tier. - Inputs — a
promptdescribing the motion, and a first-frame image. The image is supplied as a publicly reachable URL or as a data URI, depending on what the endpoint accepts for your account; a URL is the simplest to start with. The JSON body is sent asContent-Type: application/json.
Keep those four fields in one place in your codebase — a dict, a config file, an env-driven builder — and swapping to another HiAPI video model later becomes a one-line edit rather than a rewrite. The response is a small JSON envelope whose task identifier is the only field you need to persist; everything else is status metadata.
Working curl request
This is the whole submit step. Replace the image URL with one your account can reach, and export your key first.
export HIAPI_API_KEY="your-key-here"
curl -sS -X POST "https://api.hiapi.ai/v1/video/generations" \
-H "Authorization: Bearer $HIAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hailuo-2.3-fast/image-to-video",
"prompt": "slow push in, gentle breeze moving the fabric, film grain",
"image": "https://example.com/first-frame.jpg"
}'
A successful response is JSON containing a task identifier, in the usual shape of {"id": "task_...", "status": "queued"}. The exact envelope fields can change, so parse defensively: read the task ID by key rather than by position, and treat unknown fields as informational.
If you are debugging, add -i to see the HTTP status line. A 401 almost always means the auth header is missing or the variable expanded to an empty string — run echo $HIAPI_API_KEY before blaming the payload. A 400 usually points at the body: an unreachable image URL, a missing model, or a field name that does not match the endpoint.
Copy the returned task ID into a shell variable so the next steps do not require retyping it:
TASK_ID=$(curl -sS -X POST "https://api.hiapi.ai/v1/video/generations" \
-H "Authorization: Bearer $HIAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"hailuo-2.3-fast/image-to-video","prompt":"slow push in","image":"https://example.com/first-frame.jpg"}' \
| python -c 'import sys,json; print(json.load(sys.stdin)["id"])')
echo "$TASK_ID"
The same request in Python
The Python version keeps the payload byte-for-byte equivalent, so a curl result and a Python result are directly comparable when you are chasing a bug.
import os
import requests
API_BASE = "https://api.hiapi.ai/v1"
MODEL = "hailuo-2.3-fast/image-to-video"
def submit_video(prompt: str, image_url: str) -> str:
resp = requests.post(
f"{API_BASE}/video/generations",
headers={"Authorization": f"Bearer {os.environ['HIAPI_API_KEY']}"},
json={
"model": MODEL,
"prompt": prompt,
"image": image_url,
},
timeout=60,
)
resp.raise_for_status()
return resp.json()["id"]
Three habits matter here. os.environ[...] (not .get) fails loudly when the variable is missing, which is the failure you want. The explicit timeout stops a hung socket from parking a worker indefinitely. And raise_for_status() turns a 4xx body into an exception you can log, instead of a silent KeyError on the next line when the ID is absent.
The function returns only the task ID on purpose. Submission and waiting are separate concerns; mixing them is how polling loops end up untestable. Note the model ID appears once, at module level — the single place to change when you move to another HiAPI video model.
Polling the task until the video is ready
Status checks are GET requests against the task resource. A queued or running task returns a non-terminal status; a finished one returns a terminal status with the output attached.
import time
def wait_for_video(task_id: str, timeout: float = 600.0) -> dict:
deadline = time.monotonic() + timeout
delay = 2.0
url = f"{API_BASE}/video/generations/{task_id}"
while True:
r = requests.get(
url,
headers={"Authorization": f"Bearer {os.environ['HIAPI_API_KEY']}"},
timeout=30,
)
r.raise_for_status()
task = r.json()
if task.get("status") == "succeeded":
return task
if task.get("status") in {"failed", "cancelled"}:
raise RuntimeError(f"task {task_id} ended: {task}")
if time.monotonic() >= deadline:
raise TimeoutError(f"task {task_id} still {task.get('status')} after {timeout}s")
time.sleep(delay)
delay = min(delay * 1.5, 15.0)
This is a retry loop, not a sleep. Backoff starts at two seconds and grows toward a fifteen-second ceiling, so short renders finish quickly while long ones do not hammer the endpoint. The deadline is checked before sleeping, so total wall time stays under the ceiling instead of overshooting by one interval. Use time.monotonic(), not time.time(), so clock adjustments cannot extend the loop.
Treat failed and cancelled as distinct from "keep waiting": terminal-but-unsuccessful. Log the whole task object when that happens — it is the only diagnostic you get, and reproducing a failure from just an ID is painful. Treat an unknown status string as non-terminal and keep polling, so a new intermediate state does not crash your worker.
Downloading and handling the output
A completed task carries one or more output URLs. Fetch the bytes and write them somewhere you control.
def download_video(task: dict, dest: str) -> str:
url = task["output"][0]["url"] if isinstance(task.get("output"), list) else task["output"]["url"]
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
with open(dest, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 20):
f.write(chunk)
return dest
Stream the download rather than calling .content; a video can be large enough that buffering it whole in memory is wasteful in a worker handling several jobs. The output URL can live in a slightly different shape depending on the model tier, so probe for a list before indexing — the branch above covers both cases.
The important rule: treat the returned URL as a short-lived handle, not storage. Copy the file to your own bucket or local path as soon as the task succeeds, and serve from there. Persisting the URL in your database and fetching it days later is how you get 403s and empty dashboards. For object storage, the same streamed byte loop feeds straight into your client's upload method — no intermediate temp file required if your SDK accepts a file-like object.
Practical tips and common pitfalls
- Auth header, not query param. The key goes in
Authorization: Bearer .... A key pasted into a URL is a key in your access logs. - Image reachability beats image correctness. A
400on submit is far more often a URL the API cannot fetch — localhost, a private bucket, a hotlink-protected host, a link that expires — than a problem with the prompt. Test the URL in a fresh browser session before debugging anything else. - Content type on the body. Send JSON with
Content-Type: application/json. When posting with curl, forget the-Hand the server may not parse your body at all. - Polling too tight. A
while Truewith no sleep will get you rate-limited and will not make the render finish sooner. Backoff plus a deadline is the whole trick. - No timeout anywhere. Set timeouts on submit, poll, and download calls. A missing timeout turns a slow provider into a stuck queue in your own system.
- Prompt for motion, not description. Describe what changes between frames — camera movement, subject action, ambient elements — and keep it short. Restating the still image adds nothing.
- One config, many models. Keep the model ID, prompt, and image input in a single payload builder. Moving from this fast tier to a heavier one then costs one string.
The fastest way to validate the setup end to end is to run the curl submit, paste the task ID into the polling loop, and confirm an MP4 lands on disk — once that works, everything after is packaging.
Related articles
View all articles →-
Tutorial
How to Use hailuo-2.3/text-to-video via the HiAPI API: curl, Python, and a Working Request
11 min read -
Tutorial
Kandinsky 6.0 Pro Text-to-Video on HiAPI: curl and Python Tutorial
A request-first walkthrough: build the prompt-to-clip call, submit it, poll for completion, and download the result
11 min read -
Tutorial
How to Use kling-4.0/text-to-video via the HiAPI API: curl, Python, and a Working Request
A developer walkthrough for cinematic prompting, synchronized audio, async polling, and output download
11 min read