
When you call the hiapi unified task API (POST /v1/tasks), every successful job
returns a result that looks roughly like this:
{
"id": "task_01H...",
"status": "succeeded",
"output": [
{
"url": "https://cdn-provider.example/abc.png",
"expireAt": "2026-06-28T14:05:00Z"
}
]
}
The output[i].url is a short-lived signed URL. After the moment in expireAt
passes, the URL stops working and you cannot fetch the asset again from that link —
the task is already billed and the original task record stays in your dashboard,
but the underlying file is gone from that signed URL. Symptoms you will see in the
browser or in curl once it has expired include HTTP 403, an XML body that says
AccessDenied / Request has expired, or simply a generic 404 from the CDN.
If you hot-linked the URL into a customer-facing page or stored it in your DB as
the canonical asset path, that page or row is now broken. The fix is to download
the bytes immediately after status becomes succeeded and host them yourself
(R2, S3, your own CDN, a static folder — whatever works).
In rough order of how often we see it on support tickets:
articles.cover_image,
assets.video_url) without downloading the bytes. The row keeps the dead URL
forever.GET /v1/tasks/{id} is
not the same process that consumes the asset, and the asset is fetched many
minutes — or hours — later from a queue.expireAt as "long enough". The exact TTL is set by the upstream
provider and varies by model; the only safe assumption is "minutes, not days".GET /v1/tasks/{id} until
status == "succeeded" (or failed / cancelled).output[*].url from the success payload and download the bytes in
the same function call, before doing anything else.expireAt as a deadline you can plan against. Treat the
signed URL as something you read once and then throw away.A single end-to-end check that creates an image task, waits for it, and writes
the bytes to disk. Replace HIAPI_API_KEY with the key from your
dashboard.
API_KEY="$HIAPI_API_KEY"
# 1. Create the task. Note the model id is the bare id — do NOT append /text-to-image.
TASK=$(curl -s -X POST https://api.hiapi.ai/v1/tasks \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-image-2",
"input": { "prompt": "a red ceramic teapot on a wooden table, soft light" }
}')
TASK_ID=$(echo "$TASK" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
# 2. Poll until terminal.
while :; do
R=$(curl -s -H "Authorization: Bearer $API_KEY" \
https://api.hiapi.ai/v1/tasks/$TASK_ID)
STATUS=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["status"])')
echo "status=$STATUS"
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] || [ "$STATUS" = "cancelled" ] && { echo "$R"; exit 1; }
sleep 3
done
# 3. Download bytes IMMEDIATELY — do not store the URL anywhere.
URL=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["output"][0]["url"])')
curl -sL "$URL" -o "$TASK_ID.png"
echo "saved $TASK_ID.png"
The Python equivalent (using requests) is the same shape: create → poll →
requests.get(output[0]["url"]).content → write to your bucket. The key rule is
that the URL is consumed exactly once, in the same code path that produced it.
If step 1 returns HTTP 400 with unknown model or similar, double-check the
model id format. Bare ids like gpt-image-2 work; suffixed paths like
gpt-image-2/text-to-image do not — see the model page at
/en/models/gpt-image-2. If step 1
returns HTTP 401 with permission_denied, your key is wrong or revoked — fix
that first (401 troubleshooting).
/v1/tasks is listed here with its supported inputs.How long does the signed URL last? It varies by model and provider. Treat it as "a few minutes" and design for that. If you need a long-lived URL, host the file yourself.
Can I refresh the URL by calling GET /v1/tasks/{id} again? No. The task
record remains, but the output[*].url is the same expired signed URL — calling
the endpoint again does not mint a new one.
Do I get charged again if I re-run the task to recover a lost asset? Yes, re-running creates a new billable task. That is exactly why downloading once at the end of the polling loop is the cheap path.
Can I pass the URL to a downstream model (e.g. as a reference image)? Only while it is still valid. The safer pattern is to download, upload to your own storage, then pass your own URL to the next call.
Why does hiapi not host the asset for me? The unified task API forwards to upstream providers and returns whatever signed URL they hand back. Re-hosting every output would be an extra storage layer most users do not want; the contract is "you get bytes once, do what you want with them".
My link still works hours later — is the doc wrong? No, some providers issue long-TTL URLs and you happened to get one. Other providers issue 5-minute URLs. Do not let one lucky case set your expectation.
Key Takeaways