
You have a still image and you want a short video clip from it. This guide wires up hailuo-2.3-fast/image-to-video through the hiapi task API: the exact input schema (verified against the live validator), a copy-paste curl request, a complete Python script that polls to completion, and the production details — callbacks, idempotent retries, and the error responses you'll actually see.
sk-... and go in the Authorization: Bearer header.requests.Per-second pricing for this model is listed on the pricing page.
hailuo-2.3-fast/image-to-video uses the unified async task endpoint: POST /v1/tasks to create, GET /v1/tasks/<taskId> to poll. The input schema is strict — unknown fields are rejected with a 400, not silently ignored.
| Field | Type | Required | Notes |
|---|---|---|---|
prompt | string | ✅ | Describes the motion you want |
image_url | string | ✅ | Singular — one URL string, not an image_urls array |
duration | string | optional | "6" or "10" — a string, not a number |
prompt_optimizer | boolean | optional | Let the backend rewrite your prompt for motion |
Three gotchas the validator will catch you on:
duration must be a string. Sending "duration": 6 returns 400 with duration: got number, want string. Send "duration": "6".image_url, singular. Many other video models on the platform (Grok Imagine, Kling) take an image_urls array. This one takes a single string.resolution, aspect_ratio, seed, watermark, or end_image_url. All of them come back as additional properties ... not allowed. Output framing follows your source image; motion control is prompt-only.curl -s -X POST https://api.hiapi.ai/v1/tasks \
-H "Authorization: Bearer sk-YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hailuo-2.3-fast/image-to-video",
"input": {
"prompt": "The camera slowly pushes in as steam rises from the coffee cup, soft morning light",
"image_url": "https://your-cdn.example.com/coffee.jpg",
"duration": "6"
}
}'
Success response:
{
"code": 200,
"data": { "taskId": "tk-hiapi-01KX4W3SVW8S8EHD5VX45JH95V" },
"message": "success"
}
Then poll:
curl -s https://api.hiapi.ai/v1/tasks/tk-hiapi-01KX4W3SVW8S8EHD5VX45JH95V \
-H "Authorization: Bearer sk-YOUR_KEY"
While the video renders, data.status is "handling":
{
"code": 200,
"data": {
"taskId": "tk-hiapi-01KX4W3SVW8S8EHD5VX45JH95V",
"model": "hailuo-2.3-fast/image-to-video",
"status": "handling",
"storage": "temp",
"completed": 0,
"created": 1783648872
},
"message": "success"
}
Terminal states are "success" (video URL in data.output[0].url) and "fail" (details in data.error).
import os
import time
import requests
API_BASE = "https://api.hiapi.ai/v1/tasks"
TOKEN = os.environ["HIAPI_API_KEY"] # sk-...
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
def create_task(prompt: str, image_url: str, duration: str = "6") -> str:
resp = requests.post(
API_BASE,
headers={**HEADERS, "Content-Type": "application/json"},
json={
"model": "hailuo-2.3-fast/image-to-video",
"input": {
"prompt": prompt,
"image_url": image_url,
"duration": duration, # string: "6" or "10"
},
},
timeout=60,
)
body = resp.json()
if resp.status_code != 200 or not body.get("data", {}).get("taskId"):
raise RuntimeError(f"create failed: {resp.status_code} {body}")
return body["data"]["taskId"]
def wait_task(task_id: str, timeout_s: int = 600, poll_s: int = 5) -> dict:
deadline = time.time() + timeout_s
while time.time() < deadline:
task = requests.get(f"{API_BASE}/{task_id}", headers=HEADERS, timeout=30).json()["data"]
status = task.get("status")
if status == "success":
return task
if status == "fail":
err = task.get("error") or {}
raise RuntimeError(f"task failed: {err.get('code')} {err.get('message')}")
time.sleep(poll_s)
raise TimeoutError(f"task {task_id} still running after {timeout_s}s")
if __name__ == "__main__":
task_id = create_task(
prompt="The cat blinks and turns its head toward the window, gentle breeze in its fur",
image_url="https://your-cdn.example.com/cat.jpg",
duration="6",
)
print("task:", task_id)
task = wait_task(task_id)
video_url = task["output"][0]["url"]
print("video:", video_url)
# The URL is on temp storage with an expiry — download it now, don't hot-link it.
mp4 = requests.get(video_url, timeout=120).content
with open("output.mp4", "wb") as f:
f.write(mp4)
print(f"saved output.mp4 ({len(mp4)} bytes)")
Two things this script gets right that quick hacks miss:
storage: "temp" means the output URL carries an expiry (expireAt). Persist the bytes to your own storage; never store the returned URL in your database.fail as terminal. Re-polling a failed task won't revive it — create a new task instead.For anything beyond a script, register a webhook at creation time and skip the poll loop entirely:
{
"model": "hailuo-2.3-fast/image-to-video",
"input": { "prompt": "...", "image_url": "https://...", "duration": "6" },
"callback": { "url": "https://your-app.example.com/hooks/hiapi", "when": "final" }
}
With "when": "final" your endpoint is called once, when the task reaches success or fail. Rule of thumb: polling is fine for CLIs, batch jobs, and anything ephemeral; callbacks win once you have a server that's already listening — video tasks run minutes, and a poll loop is a held connection slot and a wasted worker for exactly that long.
The create call is not idempotent by itself — every POST /v1/tasks that validates makes a new (billable) task. If your job runner retries on timeouts, store the taskId you got back before acting on the result, keyed by your own request id. On retry, if a taskId already exists for that key, poll it instead of creating a duplicate.
| Symptom | Meaning | Fix |
|---|---|---|
401 — {"error":{"code":"permission_denied","type":"hiapi_error"}} | Bad key, or the key can't use this model | Check the key in your dashboard; the response includes a request_id for support |
400 INVALID_REQUEST — duration: got number, want string | JSON number instead of string | "duration": "6" |
400 INVALID_REQUEST — additional properties '...' not allowed | You sent a field outside the schema | Strip it — the message names each offending field |
400 INVALID_REQUEST — missing required field "prompt" / "image_url" | Empty or misnamed input | Both fields are required |
Task reaches fail after creation | Runtime failure — commonly an image URL the backend couldn't fetch | The validator doesn't fetch your image at creation time; make sure the URL is publicly reachable |
That last row deserves emphasis: creation-time validation is schema-only. A typo'd or private image_url gives you a healthy-looking taskId and a task that dies minutes later — so alert on fail, not just on HTTP errors.
Can I set the resolution or aspect ratio?
No. The schema for hailuo-2.3-fast/image-to-video rejects resolution and aspect_ratio as unknown fields. Framing follows your source image; if you need a specific aspect ratio, crop the input image first.
What durations are supported?
Exactly two: "6" and "10" seconds, passed as strings. Anything else returns 400 with value must be one of '6', '10'.
Can I pass multiple reference images?
No — the field is a single image_url string. Models that accept image arrays (e.g. Grok Imagine's image_urls) use a different schema; don't copy request bodies between models without checking.
How do I control the motion?
Through prompt only — there are no motion-strength or camera parameters. Describe the movement explicitly ("camera slowly pushes in", "hair sways in the wind"). Optionally set "prompt_optimizer": true to let the backend expand your prompt.
Is there a slower, higher-quality variant?
Yes — hailuo-2.3/image-to-video (without -fast) is also live on the platform, and hailuo-2.3/text-to-video covers the no-source-image case. Check each variant's schema before switching; input fields are not guaranteed to match.
How much does it cost? Billing is per second of generated video and varies by model; see current rates on the pricing page.
Do I need an API key to try it?
Yes — all task API calls require a Bearer sk-... key. You can create one in the dashboard and test in the model page playground first.