
This page walks you through the smallest request that produces a video with happyhorse-1.1/text-to-video, what every input field actually accepts, and the two ways to wait for the result (polling and callback). Everything below has been run against the live API so the request shapes and error messages are verbatim.
A single function that takes a text prompt and returns a downloadable MP4 URL. The model is happyhorse-1.1/text-to-video, served through the unified /v1/tasks endpoint. You submit a task, get a taskId back instantly, and then either poll until it's done or let hiapi POST the final result to your webhook.
Prerequisites:
Keep the key out of client-side code and out of git. Read it from an env var:
export HIAPI_API_KEY="sk-..."
The only required input field is prompt. Everything else has a default.
curl -X POST https://api.hiapi.ai/v1/tasks \
-H "Authorization: Bearer $HIAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "happyhorse-1.1/text-to-video",
"input": {
"prompt": "a calm ocean wave at sunset, slow motion, golden hour"
}
}'
Response:
{
"code": 200,
"data": { "taskId": "tk-hiapi-01KW23VDG2EZ20J7RQH8S67MNC" },
"message": "success"
}
That taskId is your handle to the job. Hold on to it.
import os
import urllib.request
import json
API_KEY = os.environ["HIAPI_API_KEY"]
def submit_task(prompt: str, **input_overrides) -> str:
body = {
"model": "happyhorse-1.1/text-to-video",
"input": {"prompt": prompt, **input_overrides},
}
req = urllib.request.Request(
"https://api.hiapi.ai/v1/tasks",
method="POST",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
data=json.dumps(body).encode("utf-8"),
)
with urllib.request.urlopen(req) as resp:
payload = json.loads(resp.read())
if payload.get("code") != 200:
raise RuntimeError(payload)
return payload["data"]["taskId"]
task_id = submit_task("a calm ocean wave at sunset, slow motion, golden hour")
print(task_id) # e.g. tk-hiapi-01KW23VDG2EZ20J7RQH8S67MNC
The schema is small and strict — unknown fields are rejected with a 400, not silently dropped. Here is what the API actually accepts inside input, taken straight from the validation errors:
| Field | Type | Required | Allowed values | Default behavior |
|---|---|---|---|---|
prompt | string | yes | any non-empty text describing the scene | — |
aspect_ratio | string | no | 16:9, 9:16, 3:4, 4:3, 4:5, 5:4, 1:1, 9:21, 21:9 | model default |
resolution | string | no | 720p, 1080p | model default |
duration | integer | no | 3 to 15 (seconds) | model default |
Things that look obvious but are not accepted today: size, audio, seed, negative_prompt. Sending any of those returns additional properties '<field>' not allowed. If you've worked with other hiapi models — qwen-image-2.0 uses size, z-image uses aspect_ratio — note that happyhorse-1.1/text-to-video follows the aspect-ratio family, not the explicit-size family.
A request that uses every supported knob:
curl -X POST https://api.hiapi.ai/v1/tasks \
-H "Authorization: Bearer $HIAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "happyhorse-1.1/text-to-video",
"input": {
"prompt": "a vintage paper plane gliding above neon Tokyo at night, cinematic, 24fps",
"aspect_ratio": "16:9",
"resolution": "1080p",
"duration": 8
}
}'
The model is priced per second of generated video — see the pricing page for the current rate. duration is the cost lever, not resolution. Bumping from 5s to 15s triples the bill; flipping from 720p to 1080p does not, at the time of writing. During development, default to duration: 3 and a low resolution; promote to 1080p only on the final approved prompt.
The simplest way to retrieve output is to GET the task by id until status is terminal.
import time
import urllib.request
import json
def get_task(task_id: str) -> dict:
req = urllib.request.Request(
f"https://api.hiapi.ai/v1/tasks/{task_id}",
headers={"Authorization": f"Bearer {API_KEY}"},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())["data"]
def wait_for_task(task_id: str, timeout_s: int = 600, poll_interval_s: int = 10) -> dict:
deadline = time.time() + timeout_s
while time.time() < deadline:
task = get_task(task_id)
status = task["status"]
if status == "success":
return task
if status == "fail":
raise RuntimeError(f"task failed: {task}")
time.sleep(poll_interval_s)
raise TimeoutError(f"task {task_id} still {status} after {timeout_s}s")
task = wait_for_task(submit_task("a quiet coffee shop on a rainy afternoon"))
video_url = task["output"][0]["url"]
print(video_url)
A few important details:
status cycles queued → handling → archiving → success (or fail). Don't treat anything before success or fail as terminal.duration and current queue depth. A 5-second 720p job typically finishes in a few minutes; budget more for 1080p at 15s.output[].url lives in temporary storage. Download the bytes immediately and re-host them; do not store the URL itself and serve it to end users — it expires.import urllib.request
def download(video_url: str, out_path: str) -> None:
with urllib.request.urlopen(video_url) as resp, open(out_path, "wb") as f:
f.write(resp.read())
download(video_url, "ocean.mp4")
Polling is fine for scripts and notebooks, but in a real backend you don't want a worker tied up for several minutes per video. Pass a callback object on submit, and hiapi will POST the finished task body to your URL exactly once.
curl -X POST https://api.hiapi.ai/v1/tasks \
-H "Authorization: Bearer $HIAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "happyhorse-1.1/text-to-video",
"input": {
"prompt": "an astronaut planting a flag on a glass beach",
"aspect_ratio": "16:9",
"resolution": "1080p",
"duration": 6
},
"callback": {
"url": "https://example.com/hooks/hiapi",
"when": "final"
}
}'
Only when: "final" is currently supported — send any other value and you'll get:
{ "code": 400, "error_code": "INVALID_REQUEST",
"message": "invalid callback.when: only 'final' is supported" }
Three rules keep the callback path safe:
taskId. If you receive the same callback twice — and you will, eventually, after a retry — re-handling must be a no-op.200 OK from the webhook as soon as you've enqueued the payload. Do the actual download / re-upload / database write in a background job. Slow webhooks invite timeouts and re-deliveries.taskId you submit in your own database with created_at. Run a sweeper that polls /v1/tasks/{id} for any row older than ~30 minutes with no terminal status yet. Callbacks are a fast path, not the only path.A minimal FastAPI receiver:
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/hooks/hiapi")
async def hiapi_callback(req: Request):
body = await req.json()
task = body.get("data", body) # callback wraps the same shape as GET
task_id = task["taskId"]
if task["status"] == "success":
url = task["output"][0]["url"]
# enqueue, don't download here
await queue.put({"task_id": task_id, "url": url})
return {"ok": True}
The error envelope is always the same shape — {code, error_code, message, data} — so you can build one handler. The ones worth special-casing for this model:
| HTTP | error_code | Trigger | Fix |
|---|---|---|---|
| 400 | INVALID_REQUEST | Unknown field, bad enum value, duration outside 3–15 | Inspect message, it names the field — e.g. aspect_ratio: value must be one of '16:9'... |
| 401 | — | Missing or wrong key, or permission_denied | Re-check the Authorization: Bearer sk-... header |
| 402 | — | Balance can't cover the estimated cost | Top up at hiapi.ai/en/pricing, then retry |
| 429 | — | You're hammering the endpoint | Back off and retry with jitter |
A real validation failure looks like this — copy it as your test fixture:
{
"code": 400,
"data": null,
"error_code": "INVALID_REQUEST",
"message": "invalid input: aspect_ratio: value must be one of '16:9', '9:16', '3:4', '4:3', '4:5', '5:4', '1:1', '9:21', '21:9'; resolution: value must be one of '720p', '1080p'; duration: maximum: got 99, want 15"
}
Notice how every field is reported in a single response. Validate locally where you can, but lean on this when you can't.
/v1/tasks APIPOST /v1/tasks, GET /v1/tasks/:id, callbacks, and the error envelopeDoes happyhorse-1.1/text-to-video produce audio?
Not via the task API — the request schema currently has no audio field, and sending one returns additional properties 'audio' not allowed. The output is a silent MP4. Layer your soundtrack downstream.
What aspect ratios are usable for vertical / short-form video?
For TikTok and Reels-style output, 9:16 is the canonical choice. The API also accepts 4:5 (Instagram in-feed portrait) and 9:21 (ultra-tall) if you need something narrower.
What's the minimum and maximum duration?
duration is an integer between 3 and 15 seconds, inclusive. Send 0 and you'll see duration: minimum: got 0, want 3; send 99 and you'll see duration: maximum: got 99, want 15. Pick the lowest value that captures the motion you need — duration is what you pay for.
Can I pass a seed for reproducible outputs?
Not on this model right now. The request {"input": {"prompt": "...", "seed": 42}} fails with additional properties 'seed' not allowed. If reproducibility matters, log the full request body and retry with the same prompt; you'll get a similar — but not pixel-identical — result.
Is negative_prompt supported?
No. Steer outputs by adding constraints to prompt itself (e.g. "...clean composition, no text, no watermarks").
How long do output URLs stay valid?
The task's storage field is temp for the default plan, and the output[].url is a short-lived signed link. Download the file and re-host it the moment the task hits success. Don't store the original URL and serve it later.
How do I cap spend during development?
Two cheap habits: keep duration: 3 until you've locked the prompt, and submit with resolution: "720p" until the storyboard is approved. Promote to 1080p and longer durations only for final renders.
Polling or callback — which should I use?
Both, in that order. Start with polling because it's one fewer moving part. Move to callbacks the first time you have a backend that can't afford to block a worker on wait_for_task. Keep polling as the fallback sweeper either way; callbacks are best-effort, not guaranteed.
Why does my call return 402 even though I have credits? The API estimates the upper bound of the task cost — duration × per-second price — and reserves it up-front. If your balance can't cover that estimate, you get 402 immediately. Top up, retry, and only the actual cost is charged on success.
Key Takeaways