Ray 3.2 image-to-video animates a still frame: you hand it one image plus a prompt describing the motion, and it returns a short clip that starts from your picture. On hiapi it's the model id ray-3.2/image-to-video, called through the same unified async task API as every other video model — POST /v1/tasks, then poll or receive a callback.
It's a different endpoint from Ray 3.2 text-to-video, not just a different prompt. The input schema is smaller and stricter, and two controls you may be used to from the text model are gone. This guide gives you one request that works on the first try: the exact schema (verified against the live API on 2026-10-20), copy-paste curl, a complete Python submit/poll/download script, and the production details — callbacks, idempotency, and the real error responses, including the one failure mode that doesn't show up where you'd expect.
What you'll build, and what you need
Goal: submit a first-frame image + a motion prompt, poll the task, and download a silent .mp4 that animates your image.
Prerequisites:
- A hiapi API key — grab one from your dashboard. Keys look like
sk-...and go in theAuthorization: Bearerheader. New to the task API? Start with the quickstart. - A publicly reachable image URL for the first frame (more on this below). Any HTTP client — we'll show
curland Python (requests).
Ray 3.2 image-to-video is billed per clip. Because the clip length is fixed (see below), resolution is the only cost lever you control — a 1080p clip costs several times a 540p one. Check pricing for current rates and prototype at 540p while you iterate on your prompt.
The exact input schema (two required fields, one optional)
Everything goes inside an input object. The schema is strict — it rejects any field it doesn't recognize. Verified field-by-field against the live API:
| Field | Type | Required | Allowed values | Default |
|---|---|---|---|---|
prompt | string | yes | How the frame should move (camera, subject motion) | — |
image_url | string | yes | Public URL of the first frame | — |
resolution | string | no | 540p, 720p, 1080p | 540p |
Three things that catch people coming from the text-to-video model:
- There is no
duration. Clips are a fixed length (~5 seconds). Sendingdurationdoesn't get clamped — it's rejected outright. This is the opposite of Ray 3.2 text-to-video, wheredurationis a selectable integer (5 or 10). - There is no
aspect_ratio. The output frame follows your input image, so there's nothing to set. In the text model you pick an aspect; here the picture decides it. (Confirmed below with a real run: a square input produced a square clip.) - The schema is closed.
seed,negative_prompt,prompt_optimizer, and anything else all return a 400 withadditional properties '<field>' not allowed. Don't port a request body from another model and assume extra keys are ignored.
What the validation actually returns
These are real responses from the live endpoint — invalid values submitted deliberately, no task created, nothing billed:
// POST with no image_url
{"code":400,"data":null,"error_code":"INVALID_REQUEST",
"message":"invalid input: image_url: missing required field \"image_url\""}
// POST with "resolution": "2k"
{"code":400,"data":null,"error_code":"INVALID_REQUEST",
"message":"invalid input: resolution: value must be one of '540p', '720p', '1080p'"}
// POST with a "duration" field
{"code":400,"data":null,"error_code":"INVALID_REQUEST",
"message":"invalid input: <root>: additional properties 'duration' not allowed"}
// POST with an "aspect_ratio" field
{"code":400,"data":null,"error_code":"INVALID_REQUEST",
"message":"invalid input: <root>: additional properties 'aspect_ratio' not allowed"}
The message names the exact problem field, so the API itself is your schema reference when you forget.
Preparing the first frame
image_url must be a URL the hiapi worker can fetch — a public link, not a local path and not a data: base64 blob. The image sets the aspect ratio and the opening composition of the clip, so start from the frame you actually want the motion to begin on.
One gotcha worth stating up front, because it's easy to miss: a bad image_url is not caught when you create the task. The create call validates the shape of your request (is it a string?), not whether the URL resolves. An unreachable or 404 image URL returns a perfectly normal 200 with a taskId — and then the task flips to fail a few seconds later. We'll see the exact failure payload in the troubleshooting section; the takeaway is to always branch on the task's terminal status, never assume a create-200 means success.
Minimal working example: curl
Create the task:
curl -s -X POST https://api.hiapi.ai/v1/tasks \
-H "Authorization: Bearer sk-YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "ray-3.2/image-to-video",
"input": {
"prompt": "slow cinematic push-in, gentle parallax, soft drifting light, the subject breathing almost imperceptibly",
"image_url": "https://your-cdn.example.com/first-frame.jpg",
"resolution": "540p"
}
}'
The create response carries a task id:
{ "code": 200, "data": { "taskId": "tk-hiapi-01M5..." }, "message": "success" }
Poll it until it reaches a terminal state:
curl -s https://api.hiapi.ai/v1/tasks/tk-hiapi-01M5... \
-H "Authorization: Bearer sk-YOUR_KEY"
While the job runs, data.status moves through in-progress states (you'll see values like handling, then archiving). The two terminal states are success and fail. On success:
{
"code": 200,
"data": {
"taskId": "tk-hiapi-01M5...",
"status": "success",
"created": 1792435735,
"completed": 1792435781,
"storage": "temp",
"output": [
{ "type": "video", "artifactId": "278504",
"expireAt": 1793040580,
"url": "https://temp.hiapi.ai/.../clip.mp4" }
]
},
"message": "success"
}
Download data.output[0].url immediately. It's a signed URL on temp.hiapi.ai with an expireAt (a Unix timestamp, roughly a week out) — persist the bytes to your own storage rather than hot-linking it. Ray 3.2 returns a silent MP4; there's no audio track to wait for.
Complete Python script (submit → poll → download)
import time
import requests
API_BASE = "https://api.hiapi.ai/v1"
API_KEY = "sk-YOUR_KEY"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
def create_task(image_url: str, prompt: str, resolution: str = "540p") -> str:
resp = requests.post(
f"{API_BASE}/tasks",
headers=HEADERS,
json={
"model": "ray-3.2/image-to-video",
"input": {
"prompt": prompt,
"image_url": image_url, # public URL of the first frame (required)
"resolution": resolution, # "540p" | "720p" | "1080p"
# no "duration" and no "aspect_ratio" — both are rejected
},
},
timeout=30,
)
resp.raise_for_status()
return resp.json()["data"]["taskId"]
def wait_task(task_id: str, timeout_s: int = 900, poll_interval: int = 8) -> dict:
"""Poll until terminal. Tolerates transient network blips by re-polling
the SAME saved task_id instead of aborting the whole job."""
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
resp = requests.get(f"{API_BASE}/tasks/{task_id}", headers=HEADERS, timeout=30)
resp.raise_for_status()
except requests.RequestException:
time.sleep(poll_interval) # a dropped poll is not a failed task
continue
task = resp.json()["data"]
if task["status"] == "success":
return task
if task["status"] == "fail":
err = task.get("error") or {}
raise RuntimeError(f"task failed: {err.get('code')}: {err.get('message')}")
time.sleep(poll_interval) # any non-terminal status = still running
raise TimeoutError(f"task {task_id} not finished after {timeout_s}s")
task_id = create_task(
image_url="https://your-cdn.example.com/first-frame.jpg",
prompt="slow cinematic push-in, gentle parallax, soft drifting light",
)
print("task created:", task_id)
task = wait_task(task_id)
video_url = task["output"][0]["url"]
# Output URLs expire — download right away and store the bytes yourself.
video = requests.get(video_url, timeout=120).content
with open("ray32-i2v.mp4", "wb") as f:
f.write(video)
print(f"saved ray32-i2v.mp4 ({len(video) / 1e6:.1f} MB)")
Run it with pip install requests, swap in your key and a real image_url, and you'll have an .mp4 on disk when the task completes.
What a real run looked like
To make the numbers concrete, here's an actual 540p run against the live API on 2026-10-20. First frame: a 1600×1600 (square) image; prompt: a slow cinematic push-in.
- The task moved
handling → archiving → success, finishing in about 45 seconds of server-side work (create atcreated, done atcompleted) — call it ~60 seconds wall-clock including an 8-second poll loop. Video tasks run in the tens of seconds to a few minutes, not instantly; budget a generous deadline. - The output was a 720×720 MP4, ~1.3 MB, 5.04 seconds, silent.
Two things that run tells you and the param table doesn't:
- The output aspect ratio matched the input — square in, square out. That's why there's no
aspect_ratiofield; frame yourimage_urlthe way you want the clip framed. resolutionis a quality tier, not a literal pixel height. A540prequest on a 1600×1600 input came back 720×720 — the tier scales to the input's shape rather than forcing a fixed dimension. If you need exact output dimensions, resize the delivered clip yourself.
Production notes: callbacks, idempotency, errors
Prefer a callback over polling
For anything beyond a script, register a webhook at create time instead of polling:
{
"model": "ray-3.2/image-to-video",
"input": {
"prompt": "...",
"image_url": "https://your-cdn.example.com/first-frame.jpg",
"resolution": "720p"
},
"callback": { "url": "https://your-app.example.com/hooks/hiapi", "when": "final" }
}
With "when": "final" hiapi calls your endpoint once, when the task reaches a terminal state — no poll traffic, no completions missed between intervals. Your handler then does a GET /v1/tasks/<id> to fetch output[0].url. Polling is still fine for CLIs and notebooks that can't expose an HTTPS endpoint.
Make retries idempotent
Persist the taskId the moment the create call returns. If your process crashes mid-generation, resume by polling the stored id — don't re-create the task, or you'll pay for a second clip. Only re-create when the original create request failed before returning a task id. The tolerant poll loop above already follows this rule: a dropped network request re-polls the same id instead of starting over.
The errors you'll actually see
Every row below is a verified live response shape:
| Symptom | Response | Fix |
|---|---|---|
| Missing/invalid key | 401, nested {"error":{"code":"permission_denied","type":"hiapi_error","request_id":"...","message":"This API key is invalid..."}} | Check the Authorization: Bearer sk-... header and that the key has access. Quote the request_id if you contact support. |
Missing image_url | 400 INVALID_REQUEST, message: "image_url: missing required field" | image_url is required for this model — there's no text-only mode. |
| Wrong enum / extra field | 400 INVALID_REQUEST, flat {"code":400,"error_code":"INVALID_REQUEST","message":"..."} | Read the field named in message. For duration/aspect_ratio it'll say additional properties ... not allowed — remove them. |
| Bad task id on poll | 404, flat {"code":404,"data":null,"message":"task not found"} (note: no error_code on a 404) | Wrong id, or you're polling with a different account's key. |
Unreachable image_url | Create returns 200 + a taskId; the task later ends data.status:"fail" with data.error:{"code":"INVALID_REQUEST","message":"Invalid request parameters for this model..."} | This is the trap. The create call can't tell a dead URL from a live one. Verify your image is publicly reachable, and always read the terminal data.status. |
That last row is worth internalizing: the poll HTTP envelope stays 200 even for a failed job — the failure lives in data.status and data.error, not the HTTP code. Failed tasks are refunded, but your code still has to notice them. Branch on data.status == "fail", don't trust the 200.
Related resources
- Ray 3.2 Image-to-Video model page — playground, live status, and the full per-resolution price table
- Ray 3.2 Text-to-Video model page — the sibling endpoint; start from text instead of an image (it adds
durationandaspect_ratio, and dropsimage_url) - How to use the Hailuo 2.3 image-to-video API — another image-to-video recipe on the same task API, a useful schema contrast
- How to use the grok-imagine image-to-video API — a third i2v model for comparison
- hiapi pricing — per-clip video rates by resolution
FAQ
What's the model id, and which endpoint do I call?
ray-3.2/image-to-video, submitted to POST https://api.hiapi.ai/v1/tasks with parameters under input. It's asynchronous — you get a taskId back and poll GET /v1/tasks/<id> (or register a callback).
What does the input require?
Exactly two required fields — prompt (the motion) and image_url (a public URL for the first frame) — plus an optional resolution (540p/720p/1080p). Anything else is rejected.
Why can't I set the duration or aspect ratio?
Clip length is fixed (~5 seconds), so there's no duration field, and the output aspect ratio is inherited from your input image, so there's no aspect_ratio field. Both return a 400 if you send them. If you need a selectable duration, use Ray 3.2 text-to-video instead.
My create call returned 200 but no video ever came. Why?
Most often an unreachable image_url. The create call only validates the request shape, so a dead URL still returns a taskId — then the task ends in fail. Poll to the terminal state and read data.error. Make sure the image is publicly fetchable (no auth, no local path, no base64).
Does the clip have audio? No — Ray 3.2 image-to-video returns a silent MP4. Add sound in post or with a separate audio model.
How much does one clip cost?
It's billed per clip, and since the length is fixed, resolution is the only lever — a 1080p clip costs several times a 540p one. Rates change, so check the pricing page rather than hardcoding a number, and prototype at 540p.
How long should I wait before giving up?
Tens of seconds to a few minutes is normal. Keep a non-terminal data.status in your poll loop and use a generous deadline (10–15 minutes), or register a callback with "when": "final" so hiapi notifies you instead of you guessing an interval.





