
You want to turn one still image into a short, coherent video clip from your own code. The happyhorse-1.1/image-to-video model on hiapi does exactly that: hand it one image URL plus an optional motion prompt, and it returns a 720p or 1080p MP4 in seconds. This recipe is the shortest working path — real request bodies, real error messages, both curl and Python — so you can ship today instead of fighting field names.
sk-... and goes in the Authorization: Bearer ... header.Model pricing per video second is listed on the hiapi pricing page; the rest of the catalogue lives at hiapi models — check both before turning up batch volume.
Every video on hiapi goes through the asynchronous /v1/tasks endpoint: you POST a creation request, get back a taskId, then either poll GET /v1/tasks/{taskId} or receive a callback when it finishes.
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": "happyhorse-1.1/image-to-video",
"input": {
"image_urls": ["https://example.com/your-still.jpg"],
"prompt": "the cat slowly turns its head toward the camera and blinks",
"resolution": "1080p",
"duration": 5
}
}'
A successful response looks like:
{
"code": 200,
"data": { "taskId": "tk-hiapi-01KW3D..." },
"message": "success"
}
Now poll for the result (every 5 seconds is plenty — most clips finish inside two minutes):
curl "https://api.hiapi.ai/v1/tasks/tk-hiapi-01KW3D..." \
-H "Authorization: Bearer sk-your-key"
While it is running you get "status": "handling". When it finishes you get:
{
"code": 200,
"data": {
"status": "success",
"model": "happyhorse-1.1/image-to-video",
"output": [
{
"type": "video",
"url": "https://temp.hiapi.ai/.../01KW....mp4",
"expireAt": 1783131245
}
],
"taskId": "tk-hiapi-01KW3D..."
}
}
That url is what you download — and you should download it immediately, because expireAt is a Unix timestamp marking when temp storage drops it.
This is a complete, runnable script — drop in your key and image URL.
import os
import time
import requests
API = "https://api.hiapi.ai/v1/tasks"
TOKEN = os.environ["HIAPI_KEY"] # sk-...
def create_video(image_url: str, prompt: str, duration: int = 5,
resolution: str = "1080p") -> str:
"""Submit an image-to-video task. Returns the taskId."""
r = requests.post(
API,
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"},
json={
"model": "happyhorse-1.1/image-to-video",
"input": {
"image_urls": [image_url], # exactly one URL
"prompt": prompt, # optional
"resolution": resolution, # "720p" or "1080p"
"duration": duration, # int, 3..15
},
},
timeout=60,
)
body = r.json()
if body.get("code") != 200:
raise RuntimeError(f"create failed: {body}")
return body["data"]["taskId"]
def wait_for_video(task_id: str, timeout_s: int = 600,
poll_s: int = 5) -> str:
"""Block until the task succeeds, fails, or times out. Returns the MP4 URL."""
deadline = time.time() + timeout_s
while time.time() < deadline:
r = requests.get(
f"{API}/{task_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
task = r.json().get("data") or {}
status = task.get("status")
if status == "success":
return task["output"][0]["url"]
if status == "fail":
raise RuntimeError(f"task failed: {task.get('error')}")
time.sleep(poll_s)
raise TimeoutError(f"task {task_id} did not finish in {timeout_s}s")
def download(url: str, path: str) -> None:
with requests.get(url, stream=True, timeout=120) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(1 << 16):
f.write(chunk)
if __name__ == "__main__":
task_id = create_video(
image_url="https://example.com/your-still.jpg",
prompt="the dancer spins once and ends in a pose",
duration=5,
resolution="1080p",
)
print("submitted:", task_id)
mp4_url = wait_for_video(task_id)
download(mp4_url, "out.mp4")
print("saved out.mp4")
The whole thing is ~50 lines and it is the entire production-ready surface — no SDK, no background queue, no streaming protocol. Run it with HIAPI_KEY=sk-... python video.py.
Polling is fine for one-offs and tests. For server workloads you want the platform to call you back when the clip is ready — saves the loop, scales with no extra work, and the request flow returns instantly.
Add a callback object at the root of the request body (not inside input):
curl -X POST "https://api.hiapi.ai/v1/tasks" \
-H "Authorization: Bearer sk-your-key" \
-H "Content-Type: application/json" \
-d '{
"model": "happyhorse-1.1/image-to-video",
"input": {
"image_urls": ["https://example.com/still.jpg"],
"prompt": "slow zoom out from the subject",
"resolution": "1080p",
"duration": 6
},
"callback": {
"url": "https://your-app.example.com/webhooks/hiapi",
"when": "final"
}
}'
When the task reaches a terminal state, hiapi POSTs the same task object you would have polled (status, output[].url, error, etc.) to your URL. when: "final" means you only get the success-or-failure callback — no progress noise.
A few rules that save real debugging hours:
taskId may arrive twice (network retries are real). Look up the task in your DB before processing.expireAt field; stream it into your own bucket on first receipt or you will lose it.2xx quickly, then do heavy work async. Slow webhooks risk retries.taskId in a delayed job that polls 10 minutes later, in case the webhook gets lost. If you keep hitting this, see why hiapi task callbacks may not fire for the common causes.These are the exact strings the API returns; matching them in your code is more reliable than guessing.
| Situation | HTTP | Body shape |
|---|---|---|
| Wrong or revoked key | 401 | {"error":{"code":"permission_denied","message":"This API key cannot use the selected model...","type":"hiapi_error"}} |
| Missing required field | 400 | {"code":400,"error_code":"INVALID_REQUEST","message":"invalid input: image_urls: missing required field \"image_urls\""} |
Bad resolution | 400 | ... resolution: value must be one of '720p', '1080p' |
| Duration out of range | 400 | ... duration: minimum: got 2, want 3 / duration: maximum: got 30, want 15 |
Wrong image_urls length | 400 | ... image_urls: maxItems: got 2, want 1 (or minItems: got 0, want 1) |
Unknown field at root or in input | 400 | ... additional properties 'identity_preserving' not allowed |
| Task itself failed after submission | 200 (task GET) with data.status == "fail" and data.error.{code,message} |
The auth and validation errors mean you should not retry; everything else (5xx from the platform, network timeouts mid-poll, transient task fail with retryable codes) is fair game for a small backoff.
You have four knobs that actually matter — anything else (seed, aspect_ratio, fps, cfg_scale, negative_prompt) is rejected as an unknown field today. Stay inside this set:
image_urls — one HTTPS URL. The aspect ratio and framing of the output follow this image.prompt — a short motion description ("she smiles and tilts her head"). Cinematic and verb-driven prompts beat adjective lists.resolution — "720p" for prototyping and thumbnails, "1080p" for delivery.duration — integer seconds, 3 to 15. Longer clips cost more time and money; pick the shortest that tells the story.For projects that need a starting and ending frame, see happyhorse-1.1 reference-to-video. For pure text-to-video without a source still, happyhorse-1.1 text-to-video is the sibling recipe. Browse the rest at the hiapi models catalogue or in the recipes section of the blog.
Can I pass two images for first-frame + last-frame control?
Not on happyhorse-1.1/image-to-video — image_urls is capped at one element (maxItems: 1). Use happyhorse-1.1/reference-to-video if you need that, or build with happyhorse-1.1 reference-to-video.
What MP4 resolution do I actually get back?
The output respects the aspect ratio of your input image and the resolution flag you set. "1080p" gives you the higher of the two; "720p" is faster and cheaper for tests.
Why does my key get 401 permission_denied even though it works on other models?
Some models are gated per key. Check the model's page on the hiapi dashboard — if happyhorse-1.1/image-to-video is not in your allowlist, generate a new key or contact support with the request_id in the error.
Is there a streaming/SSE response? No. Every video model uses the asynchronous task pattern in this article. Pick polling or callback based on whether you control the server.
Where do I see the actual cost per clip?
On the hiapi pricing page. Costs are per video second, so the duration you pass directly drives spend — keep dev/test clips at duration: 3 to save budget.
The output URL stopped working after a day.
That is expected — the URL is temp storage and the response includes an expireAt Unix timestamp. Always download the MP4 (or stream it through your own CDN) the moment the task succeeds.
Key Takeaways