
.mp4.POST https://api.hiapi.ai/v1/tasks with "model": "hailuo-2.3/image-to-video", then GET /v1/tasks/<taskId> until it reaches a terminal status.prompt and image_url — a single public URL to your first frame (not an array).duration is a string, not a number: "6" or "10". Passing 6 as an integer is rejected with a 400.motion_strength, camera_movement, or resolution parameters — they're rejected with a 400. Motion is controlled entirely through the prompt.You have a still image — a product shot, a character render, a photo — and you want hailuo-2.3 to animate it into a video clip that starts exactly from that frame. hailuo-2.3 is known for physically plausible motion (cloth, hair, body mechanics), which makes it a strong pick for bringing static images to life.
You need two things:
sk-... and go in the Authorization: Bearer header.image_url, so it must be accessible without authentication — an S3/R2/CDN link works; a localhost path or a signed URL that has expired does not.Like every generation model on hiapi, this one runs on the async task interface: you POST a task, get a taskId back immediately, and collect the result later by polling or via callback. There is no synchronous endpoint for video — clips take a while to render, longer than typical HTTP timeouts.
The full input schema for hailuo-2.3/image-to-video — verified against the live API:
| Field | Type | Required | Notes |
|---|---|---|---|
prompt | string | ✅ | Describes the motion, not the scene — the scene comes from your image. |
image_url | string | ✅ | One public URL, the first frame. Singular — not image_urls. |
duration | string | optional | "6" or "10" (seconds). Must be a string. |
prompt_optimizer | boolean | optional | Lets the platform expand your prompt before generation. |
The schema is strict: anything not in this table is rejected. Send motion_strength, camera_movement, or resolution and you get exactly this back:
{
"code": 400,
"error_code": "INVALID_REQUEST",
"message": "invalid input: <root>: additional properties 'resolution', 'camera_movement', 'motion_strength' not allowed"
}
Two consequences worth internalizing:
Create the task:
curl -X POST https://api.hiapi.ai/v1/tasks \
-H "Authorization: Bearer sk-<your-key>" \
-H "Content-Type: application/json" \
-d '{
"model": "hailuo-2.3/image-to-video",
"input": {
"prompt": "The woman turns toward the camera and smiles; wind gently moves her hair; slow cinematic push-in",
"image_url": "https://your-cdn.example.com/first-frame.jpg",
"duration": "6",
"prompt_optimizer": true
}
}'
The response (abridged) contains the task id under data.taskId:
{ "data": { "taskId": "..." } }
Poll it:
curl -s https://api.hiapi.ai/v1/tasks/<taskId> \
-H "Authorization: Bearer sk-<your-key>"
While the clip renders, data.status reports a non-terminal state. When it flips to "success", the video URL is at data.output[0].url; if it's "fail", data.error.code and data.error.message say why.
Download the file as soon as the task succeeds. Output URLs are signed and carry an expiry (expireAt) — store the bytes on your own storage, never hotlink the task URL.
The same flow as a complete script — create, poll, download:
import time
import requests
API_BASE = "https://api.hiapi.ai/v1/tasks"
API_KEY = "sk-<your-key>"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
def create_task() -> str:
payload = {
"model": "hailuo-2.3/image-to-video",
"input": {
"prompt": (
"The cat stretches, hops off the windowsill, and walks toward "
"the camera; soft afternoon light, subtle handheld camera sway"
),
"image_url": "https://your-cdn.example.com/first-frame.jpg",
"duration": "6", # string, not int: "6" or "10"
"prompt_optimizer": True,
},
}
r = requests.post(API_BASE, json=payload, headers=HEADERS, timeout=30)
task_id = (r.json().get("data") or {}).get("taskId")
if not task_id:
raise RuntimeError(f"create failed ({r.status_code}): {r.text[:300]}")
return task_id
def wait_task(task_id: str, timeout_s: int = 900, poll_s: int = 10) -> dict:
deadline = time.time() + timeout_s
while time.time() < deadline:
r = requests.get(f"{API_BASE}/{task_id}", headers=HEADERS, timeout=30)
task = r.json().get("data") or {}
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} not finished after {timeout_s}s")
def main() -> None:
task_id = create_task()
print("task created:", task_id)
task = wait_task(task_id)
url = task["output"][0]["url"]
# The URL is signed and expires -- download immediately, store the bytes yourself.
video = requests.get(url, timeout=120).content
with open("hailuo-output.mp4", "wb") as f:
f.write(video)
print("saved hailuo-output.mp4")
if __name__ == "__main__":
main()
Video tasks run noticeably longer than image tasks, so the script polls every 10 seconds with a generous 15-minute ceiling. Don't poll more aggressively than every ~5 seconds — it doesn't make the render faster.
Since there are no motion parameters, the prompt is your only steering wheel — and for hailuo-2.3 that's enough. Three rules of thumb:
"a beautiful woman in a red dress" wastes the prompt; "she turns her head slowly to the left as the camera pushes in" uses it."slow push-in", "orbit right", "handheld sway", "static camera" — these belong in the prompt text, not in a (nonexistent) camera_movement field.prompt_optimizer help when in doubt. With "prompt_optimizer": true the platform expands terse prompts before generation — good default for short inputs. Turn it off when you want strict adherence to carefully engineered wording.Prefer callbacks over polling. Attach a callback when creating the task and hiapi will POST the terminal result to your endpoint, so you hold no open loops:
{
"model": "hailuo-2.3/image-to-video",
"input": { "...": "..." },
"callback": { "url": "https://your-app.example.com/hooks/hiapi", "when": "final" }
}
"when": "final" fires once, on the terminal state. Polling is fine for scripts and notebooks; callbacks are the right shape for servers — video renders are long enough that a worker blocked on polling is real money.
Handle these errors explicitly:
401 with "code": "permission_denied" — bad key, or the key isn't allowed to use this model. The message includes a request_id you can send to support.400 INVALID_REQUEST — schema violations. The message is precise, e.g. duration: got number, want string or image_url: missing required field "image_url". Log it verbatim.402 on task creation — insufficient balance. Note that video bills more per task than images, so a key that still runs image tasks fine can start failing video creation first. Top up, or check your usage in the dashboard."fail" — generation-side failure; inspect data.error. A common cause for image-to-video specifically: image_url wasn't fetchable (private bucket, expired signed URL).Be idempotent around retries. If your process dies between create and download, a naive restart creates (and pays for) a second render. Persist the taskId as soon as create_task returns, and on restart resume polling instead of re-creating.
What durations does hailuo-2.3 image-to-video support?
Two: "6" and "10" seconds, passed as strings. Any other value — or the same value as a number — returns 400 INVALID_REQUEST.
Why does "duration": 6 fail when "duration": "6" works?
The schema types duration as a string enum. The API tells you exactly that: duration: got number, want string. It's the single most common 400 on this endpoint.
Can I set the output resolution or aspect ratio?
No. The endpoint exposes no resolution or aspect_ratio field, and sending one is a 400 — the schema rejects unknown properties rather than ignoring them.
How do I control camera movement or motion strength?
Through the prompt. There are no camera_movement or motion_strength parameters — write the camera and motion directions as plain language ("slow push-in", "she walks toward the camera").
Can I pass multiple reference images?
No — image_url is a single string, the first frame. This differs from some other models on hiapi that take an image_urls array; schemas are per-model, so always check the model page.
What image formats work for the first frame? Standard web formats (JPEG/PNG) served from a publicly reachable URL. If the platform can't fetch the URL, the task fails after creation — so validate the link is publicly accessible before submitting.
How is this different from hailuo-2.3/text-to-video?
Text-to-video invents the scene from your prompt alone; image-to-video pins the first frame to your image and animates from it. The task lifecycle is identical — only the input schema differs (image_url is required here, and text-to-video has no image field at all).
What does it cost? Video is billed per second of output, and rates change — check the hailuo-2.3/image-to-video model page and the pricing page for current numbers.
Key Takeaways