Python
SDKs
HiAPI’s official SDKs submit a generation task, poll it until it finishes, and return a typed task object containing the output URLs — with opt-in idempotent retries, typed errors, and a webhook verification helper, so you don’t have to implement polling, backoff, or response parsing yourself.
Go
Go 1.21+. Standard-library-only client; every task API call takes a context.Context, and the client is safe for concurrent use by multiple goroutines.
go get github.com/HiAPIAI/hiapi-go · v0.2.1 · pkg.go.dev · GitHub
Java
Java 11+. JDK-only client (java.net.http.HttpClient) with immutable response objects and no runtime dependencies to manage.
ai.hiapi:hiapi · v0.2.1 · Maven Central · GitHub
Node.js
Don’t see your language? Call the Unified Async API directly with any HTTP client — it’s what every SDK wraps under the hood.
What every SDK gives you
Section titled “What every SDK gives you”- One call, start to finish.
runsubmits a task and polls it for you — or usecreate/retrieve/list/wait(waitForin Java) directly for full control over the lifecycle. - Opt-in idempotent retries. Pass an idempotency key and a dropped connection becomes safe to retry, instead of risking a second, duplicate-billed task. Off by default — you choose when to use it.
- Model routes, no string-building. Where a model offers more than one processing option, pick it with a plain parameter instead of hand-assembling a
model@routename. - Typed errors. Common failures — bad auth, an unavailable model, a timeout, a conflicting idempotency key — map to their own catchable error per language, so you’re not parsing a generic HTTP exception for the common cases.
- Webhook verification. A helper checks the callback signature and timestamp for you — no hand-rolled HMAC comparison.
Quickstart
Section titled “Quickstart”Before you start: install the SDK above, then get an API key from your HiAPI account and make sure it has balance — a generation request bills your account, so check the model’s price on its model page first.
Submit a task, follow its progress, and read the result:
from hiapi import HiAPI
# api_key is shown here for clarity; in production, read it from an# environment variable (HIAPI_API_KEY) instead of hardcoding it.client = HiAPI(api_key="sk-...")
task = client.tasks.run( model="happyhorse-1-0", input={"prompt": "a cyan glass data center entrance", "duration": 5, "resolution": "720p"}, on_update=lambda t: print("status:", t.status),)
for out in task.output: print(out.type, out.url)package main
import ( "context" "fmt" "log"
hiapi "github.com/HiAPIAI/hiapi-go")
func main() { // apiKey is shown here for clarity; in production, read it from an // environment variable (HIAPI_API_KEY) instead of hardcoding it. client, err := hiapi.New("sk-...") if err != nil { log.Fatal(err) }
task, err := client.Tasks.Run(context.Background(), hiapi.RunParams{ Model: "happyhorse-1-0", Input: map[string]any{"prompt": "a cyan glass data center entrance", "duration": 5, "resolution": "720p"}, OnUpdate: func(t *hiapi.Task) { log.Println("status:", t.Status) }, }) if err != nil { log.Fatal(err) }
for _, out := range task.Output { fmt.Println(out.Type, out.URL) }}import ai.hiapi.HiAPI;import ai.hiapi.Task;import ai.hiapi.Output;import ai.hiapi.RunOptions;import java.util.Map;
public class Quickstart { public static void main(String[] args) { // apiKey is shown here for clarity; in production, read it from an // environment variable (HIAPI_API_KEY) instead of hardcoding it. HiAPI client = new HiAPI("sk-...");
Task task = client.tasks().run( "happyhorse-1-0", Map.of( "prompt", "a cyan glass data center entrance", "duration", 5, "resolution", "720p" ), RunOptions.builder() .onUpdate(t -> System.out.println("status: " + t.getStatus())) .build() );
for (Output out : task.getOutput()) { System.out.println(out.getType() + " " + out.getUrl()); } }}import { HiAPI } from "hiapi_ai";// Run this ESM example as .mjs or with "type": "module" in package.json.// For CommonJS, replace the import with:// const { HiAPI } = require("hiapi_ai"); and save the file as .cjs.
async function main() { // apiKey is shown here for clarity; in production, read it from an // environment variable (HIAPI_API_KEY) instead of hardcoding it. const client = new HiAPI({ apiKey: "sk-..." });
const task = await client.tasks.run({ model: "happyhorse-1-0", input: { prompt: "a cyan glass data center entrance", duration: 5, resolution: "720p" }, onUpdate: (t) => console.log("status:", t.status), });
for (const out of task.output) { console.log(out.type, out.url); }}
main().catch((err) => { console.error(err); process.exitCode = 1;});run polls until the task succeeds, fails, or the client-side timeout expires (10 minutes by default). A timeout stops the SDK from polling — it does not cancel the task, which may still finish and bill your account. On a timeout, use the task ID to call retrieve later instead of submitting the same request again. For full control over the lifecycle, use create / retrieve / list / wait (waitFor in Java) directly — see each SDK’s README: Python · Go · Java · Node.js.
The output URLs above are temporary — HiAPI auto-deletes them about 7 days after creation. In Node.js, pass storage: "persistent" when creating the task to keep its outputs long-term (billed by size; insufficient balance silently downgrades to "temp" — check task.storage for the tier actually used). The Python, Go, and Java SDKs don’t expose this at creation time yet; to keep an output past that window, promote it (or manage it from the dashboard) before it expires. See Output Storage for retention tiers and pricing.
You now have a working call. The sections below cover what you’ll want once it’s running in production: selecting a model route, making retries safe, verifying webhook callbacks, and handling errors.
Model routes
Section titled “Model routes”Some model pages list more than one processing option — e.g. ext — at different prices or availability. Pass the exact route value shown on that page as a parameter, instead of hand-assembling a model@route name:
created = client.tasks.create( model="gpt-image-2/text-to-image", route="ext", # preferred over the old "model@ext" suffix spelling input={"prompt": "..."},)created, err := client.Tasks.Create(context.Background(), hiapi.CreateParams{ Model: "gpt-image-2/text-to-image", Route: "ext", // preferred over Model: "...@ext" Input: map[string]any{"prompt": "..."},})CreatedTask created = client.tasks().create( "gpt-image-2/text-to-image", Map.of("prompt", "..."), CreateOptions.builder().route("ext").build() // preferred over "...@ext");const created = await client.tasks.create({ model: "gpt-image-2/text-to-image", route: "ext", // preferred over putting the route in the model string input: { prompt: "..." },});Omitting the route (or passing "default") uses the model’s default route. An unknown route is rejected — before a task is created or billed — with a 400 listing the available routes. The older x@ext suffix spelling still works if you have it in existing code.
Idempotent retries
Section titled “Idempotent retries”Pass an idempotency key (sent as the Idempotency-Key header, ≤255 bytes) so a retried submission can’t create a second, duplicate-billed task. Use one stable key per logical job — for example, derived from your own order or job ID:
created = client.tasks.create( model="seedance-2.0", input={"prompt": "..."}, idempotency_key="order-8472:video", # a stable key you derive per job)if created.idempotent_replay: print("this returned the task from an earlier request; no new task was created")created, err := client.Tasks.Create(context.Background(), hiapi.CreateParams{ Model: "seedance-2.0", Input: map[string]any{"prompt": "..."}, IdempotencyKey: "order-8472:video",})if err != nil { log.Fatal(err)}if created.IdempotentReplay { log.Println("this returned the task from an earlier request; no new task was created")}CreatedTask created = client.tasks().create( "seedance-2.0", Map.of("prompt", "..."), CreateOptions.builder().idempotencyKey("order-8472:video").build());if (created.isIdempotentReplay()) { System.out.println("this returned the task from an earlier request; no new task was created");}const created = await client.tasks.create({ model: "seedance-2.0", input: { prompt: "..." }, idempotencyKey: "order-8472:video", // a stable key you derive per job});if (created.idempotentReplay) { console.log("this returned the task from an earlier request; no new task was created");}How the guarantee works: the first accepted request for a key creates and bills one task. Retrying the same request body with the same key returns that same task (idempotent_replay is true) and does not create or bill a second one. Keys are cleaned up roughly 24 hours after creation — treat that as “about a day,” not a precise cutoff you can time requests against — after which the same request creates a new task. Pick a key that’s unique to the job, not one you intend to reuse indefinitely.
With a key set, the SDK also retries submission on network errors, and retries 409 IDEMPOTENCY_KEY_PROCESSING (the first request with this key is still in flight) up to the client’s configured retry limit — it does not wait indefinitely. Reusing a key with a different request body fails with a dedicated, non-retryable error (see the table below): that means the key was generated incorrectly and needs fixing, not retrying as-is.
Webhooks
Section titled “Webhooks”Pass callback.url (a public HTTPS URL) when you create a task and HiAPI sends a POST there when the task finishes, success or fail — no polling needed. If you also set a webhook signing key in your HiAPI account settings, that request is signed; verify it against the raw request body before trusting it:
# Flask examplefrom flask import Flask, requestfrom hiapi import HiAPI, WebhookVerificationError
app = Flask(__name__)app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 # 1 MiB — reject oversized bodies before reading themclient = HiAPI(api_key="sk-...", webhook_secret="whsec_...")
@app.post("/hiapi/callback")def callback(): try: task = client.webhooks.verify(request.get_data(), request.headers) except WebhookVerificationError: return "", 400
# Process idempotently — e.g. upsert your own record keyed by # task.task_id — so a duplicate delivery of this event is harmless. if task.succeeded and task.output: print(task.output[0].url) return "", 200 # ack only after your side effect has succeededpackage main
import ( "io" "log" "net/http"
hiapi "github.com/HiAPIAI/hiapi-go")
var client *hiapi.Client
func main() { var err error client, err = hiapi.New("sk-...", hiapi.WithWebhookSecret("whsec_...")) if err != nil { log.Fatal(err) } http.HandleFunc("/hiapi/callback", handler) log.Fatal(http.ListenAndServe(":8080", nil))}
const maxBodyBytes = 1 << 20 // 1 MiB — HiAPI payloads are well under this
func handler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) if err != nil { w.WriteHeader(http.StatusRequestEntityTooLarge) return } task, err := client.Webhooks.Verify(body, r.Header, hiapi.VerifyParams{}) if err != nil { w.WriteHeader(http.StatusBadRequest) return }
// Process idempotently — e.g. upsert your own record keyed by // task.TaskID — so a duplicate delivery of this event is harmless. if task.Succeeded() && len(task.Output) > 0 { log.Println(task.Output[0].URL) } w.WriteHeader(http.StatusOK) // ack only after your side effect has succeeded}HiAPI client = HiAPI.builder() .apiKey("sk-...") .webhookSecret("whsec_...") // SAME key set in your account settings .build();
// Configure your server/framework to cap the request body size (e.g. a// few hundred KB) before this line runs — HiAPI payloads are small, and// an unauthenticated caller shouldn't be able to force an unbounded read.byte[] rawBody = readRawRequestBody(); // do NOT re-serializeMap<String, String> headers = readRequestHeaders();
try { Task task = client.webhooks().verify(rawBody, headers);
// Process idempotently — e.g. upsert your own record keyed by // task.getTaskId() — so a duplicate delivery of this event is harmless. if (task.isSucceeded() && !task.getOutput().isEmpty()) { System.out.println(task.getOutput().get(0).getUrl()); } respond(200, ""); // ack only after your side effect has succeeded} catch (WebhookVerificationException e) { respond(400, ""); // bad signature or stale timestamp}// node:http example — with a framework, hand verify() the RAW body bytes// (e.g. express.raw({ type: "application/json" }) in Express).import { createServer } from "node:http";import { HiAPI, WebhookVerificationError } from "hiapi_ai";
const client = new HiAPI({ apiKey: "sk-...", webhookSecret: "whsec_..." });const maxBodyBytes = 1024 * 1024; // 1 MiB — reject oversized bodies before verifying
async function processTask(task) { // Replace this with an atomic, idempotent upsert keyed by task.taskId. // Persist the terminal state whether it is success or fail. console.log("task:", task.taskId, task.status, task.output[0]?.url);}
async function handleWebhook(rawBody, headers, res) { let task; try { task = client.webhooks.verify(rawBody, headers); } catch (err) { if (err instanceof WebhookVerificationError) { res.writeHead(400).end(); return; } console.error(err); res.writeHead(500).end(); return; }
try { await processTask(task); res.writeHead(200).end(); // ack only after the side effect has succeeded } catch (err) { console.error(err); res.writeHead(500).end(); // non-2xx asks HiAPI to redeliver }}
createServer((req, res) => { if (req.method !== "POST" || req.url !== "/hiapi/callback") { res.writeHead(404).end(); return; } const chunks = []; let size = 0; let bodyRejected = false; req.on("data", (chunk) => { if (bodyRejected) return; size += chunk.length; if (size > maxBodyBytes) { bodyRejected = true; res.writeHead(413).end(); req.destroy(); return; } chunks.push(chunk); }); req.on("error", (err) => { console.error(err); if (!res.headersSent) res.writeHead(400).end(); else if (!res.writableEnded) res.destroy(); }); req.on("end", () => { if (bodyRejected || res.writableEnded) return; void handleWebhook(Buffer.concat(chunks), req.headers, res).catch((err) => { console.error(err); if (!res.headersSent) res.writeHead(500).end(); else if (!res.writableEnded) res.destroy(); }); });}).listen(3000);verify checks the signature and rejects timestamps outside a 300-second window — it does not deduplicate deliveries. Callbacks are sent at least once and can arrive concurrently, so make your handler idempotent, as in the examples above: key your side effect on the task ID (e.g. an upsert) and return 2xx only after it has succeeded. Duplicates are then harmless, and a failed or crashed handler is simply redelivered — redelivery acts as your retry.
One thing not to do: writing a permanent “already handled” marker before processing looks like a tidy dedup, but it loses events — if the process crashes after writing the marker, the redelivery sees “already handled” and skips the event forever. If you need to stop two concurrent deliveries from running an expensive side effect twice, use a processing state with an expiry/lease and mark it done only after success, rather than a permanent up-front claim.
Errors
Section titled “Errors”Common failures map to their own catchable error per language — pick your language above to see the exact type. Two boundaries to keep in mind:
- The error-code rows below apply to synchronous non-2xx API responses. A task that fails during polling always surfaces as the polled-failure error —
TaskFailed(Python),*TaskFailedError(Go),TaskFailedException(Java),TaskFailedError(Node.js) — read itscodefield for the underlying reason (which may beTASK_TIMEOUT,STORAGE_UNAVAILABLE, etc.). - Less common responses without a dedicated type — e.g.
402insufficient balance,403, or429after retries are exhausted — surface as each SDK’s base API error, which carries the HTTP status and raw response.
| When | Python | Go | Java | Node.js |
|---|---|---|---|---|
| 401 — bad/missing API key | AuthenticationError | ErrAuthentication | AuthenticationException | AuthenticationError |
| 404 — unknown task, or not yours | NotFoundError | ErrNotFound | NotFoundException | NotFoundError |
INVALID_REQUEST — fix the request | InvalidRequestError | ErrInvalidRequest | InvalidRequestException | InvalidRequestError |
MODEL_UNAVAILABLE — retry or switch model | ModelUnavailableError | ErrModelUnavailable | ModelUnavailableException | ModelUnavailableError |
TASK_FAILED — the submission was rejected synchronously | TaskFailedError | ErrTaskFailedSync | APIException (check getErrorCode()) | TaskFailedSyncError |
TASK_TIMEOUT — the upstream task itself timed out (server-side) | TaskTimeoutError | ErrTaskTimeout | TaskTimeoutException | TaskTimeoutError |
STORAGE_UNAVAILABLE — output storage error | StorageUnavailableError | ErrStorageUnavailable | StorageUnavailableException | StorageUnavailableError |
| 503 — platform busy (retried automatically) | ServiceUnavailableError | ErrServiceUnavailable | ServiceUnavailableException | ServiceUnavailableError |
| 409 — same idempotency key still in flight (retried up to the retry limit) | IdempotencyKeyProcessingError | ErrIdempotencyKeyProcessing | IdempotencyKeyProcessingException | IdempotencyKeyProcessingError |
| 422 — key reused with a different body (not retryable) | IdempotencyKeyMismatchError | ErrIdempotencyKeyMismatch | IdempotencyKeyMismatchException | IdempotencyKeyMismatchError |
a polled task ended in status=fail | TaskFailed | *TaskFailedError | TaskFailedException | TaskFailedError |
run / wait exceeded its client-side timeout | PollTimeout | *PollTimeoutError | PollTimeoutException | PollTimeoutError |
| network failure (retried automatically for reads only — not a keyless submit) | APIConnectionError | *ConnectionError | APIConnectionException | APIConnectionError |
run was aborted immediately after creating a task | — | — | — | RunAbortedError |
run failed while polling or running onUpdate after creation | — | — | — | RunFailedError |
In Go, the Err* values are sentinels for errors.Is — match categories that way, and use errors.As to pull the full *APIError (status, error code, raw body) when you need it. In Node.js, mind the two similar names: TaskFailedSyncError is the synchronous TASK_FAILED rejection, while TaskFailedError is a polled task ending in fail — the reverse of Python, where TaskFailedError is the synchronous one. Node.js RunAbortedError and RunFailedError both carry the created task’s taskId; that task may still run and bill, so call retrieve(taskId) later instead of resubmitting it. RunFailedError.cause preserves the polling, callback, or abort error that interrupted run. 429/503 responses, and network errors on idempotent calls, are retried automatically with backoff. See each SDK’s README for the full error hierarchy and client configuration: Python · Go · Java · Node.js.
Next steps
Section titled “Next steps”- Authentication — get an API key and understand request auth.
- Model catalog — browse models, parameters, and per-model pricing.
- Unified Async API — the underlying
/v1/taskscontract every SDK wraps.