Search

Search HiAPI models, tools, and resources.

Back to blog
Tutorial

How to Use the Ray 3.2 Image-to-Video API: curl, Python, and a Working Request

hiapitutorialvideorecipe

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 the Authorization: Bearer header. 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 curl and 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:

FieldTypeRequiredAllowed valuesDefault
promptstringyesHow the frame should move (camera, subject motion)—
image_urlstringyesPublic URL of the first frame—
resolutionstringno540p, 720p, 1080p540p

Three things that catch people coming from the text-to-video model:

  1. There is no duration. Clips are a fixed length (~5 seconds). Sending duration doesn't get clamped — it's rejected outright. This is the opposite of Ray 3.2 text-to-video, where duration is a selectable integer (5 or 10).
  2. 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.)
  3. The schema is closed. seed, negative_prompt, prompt_optimizer, and anything else all return a 400 with additional 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 at created, done at completed) — 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:

  1. The output aspect ratio matched the input — square in, square out. That's why there's no aspect_ratio field; frame your image_url the way you want the clip framed.
  2. resolution is a quality tier, not a literal pixel height. A 540p request 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:

SymptomResponseFix
Missing/invalid key401, 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_url400 INVALID_REQUEST, message: "image_url: missing required field"image_url is required for this model — there's no text-only mode.
Wrong enum / extra field400 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 poll404, 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_urlCreate 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.

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.

Generate it with HiAPI

Choose a model, enter your prompt, and see the result.

HiAPI Blog

Related articles

HiAPI

Generate it with HiAPI

Start generating