Multi-Reference editing with the unified /v1/tasks interface — schema, code, and production notes

POST https://api.hiapi.ai/v1/tasks with "model": "flux-2/image-to-image", then GET /v1/tasks/<taskId> until it reaches a terminal status.prompt, image_urls (1–8 public URLs — this is the Multi-Reference feature), aspect_ratio, and resolution (1K or 2K).strength or fidelity don't exist and are rejected with a 400. Fidelity is controlled by your reference images, your prompt, and resolution — not by a separate knob.Goal: send one or more reference images plus a text prompt to flux-2, and get back an edited/restyled image you can download — from curl or Python, with no SDK.
Prerequisites:
sk-... and go in the Authorization: Bearer header.image_urls server-side, so localhost links or private-bucket URLs won't work — use a public bucket, CDN, or presigned URL.curl, or Python 3.8+ with requests (pip install requests).flux-2/image-to-image runs on hiapi's unified task interface. The input object takes exactly these fields:
| Field | Type | Required | Notes |
|---|---|---|---|
prompt | string | yes | What to change or generate. Minimum 3 characters. |
image_urls | string[] | yes | 1–8 public image URLs. More than one = Multi-Reference: flux-2 blends identity, product, or style cues across all of them. |
aspect_ratio | string | yes | One of 1:1, 4:3, 3:4, 16:9, 9:16, 3:2, 2:3, or auto (follow the reference image). |
resolution | string | yes | 1K or 2K. |
Anything else is rejected. For example, sending a strength or fidelity field returns:
{
"code": 400,
"error_code": "INVALID_REQUEST",
"message": "invalid input: <root>: additional properties 'fidelity', 'strength' not allowed"
}
That's worth internalizing: flux-2's image-to-image mode has no denoise-strength dial. How faithful the output stays to your references ("high fidelity" editing vs. loose restyling) is driven by how you write the prompt — e.g. "keep the product, its label and proportions exactly as in the reference; only replace the background" — plus resolution: "2K" for detail-critical work.
Step 1 — 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": "flux-2/image-to-image",
"input": {
"prompt": "Place this product on a white marble countertop with soft morning light, keep the product itself unchanged",
"image_urls": ["https://your-cdn.example.com/product.jpg"],
"aspect_ratio": "1:1",
"resolution": "2K"
}
}'
A successful create returns a task ID in data.taskId.
Step 2 — poll until it finishes:
curl -s https://api.hiapi.ai/v1/tasks/TASK_ID \
-H "Authorization: Bearer sk-YOUR_KEY"
Keep polling every few seconds while data.status is non-terminal. On "status": "success", the image URL is at data.output[0].url.
Step 3 — download the result immediately:
curl -s -o result.jpg "OUTPUT_URL"
Output URLs carry an expireAt timestamp — they are temporary. Download the bytes (or copy them to your own storage) as soon as the task succeeds; don't hot-link the task output in production.
A complete script — create, poll, download — with nothing but requests:
import time
import requests
API_KEY = "sk-YOUR_KEY"
BASE = "https://api.hiapi.ai/v1/tasks"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def create_task() -> str:
resp = requests.post(BASE, headers=HEADERS, json={
"model": "flux-2/image-to-image",
"input": {
"prompt": ("Combine the person from the first image with the jacket "
"from the second image, studio lighting, neutral backdrop"),
"image_urls": [
"https://your-cdn.example.com/person.jpg",
"https://your-cdn.example.com/jacket.jpg",
],
"aspect_ratio": "3:4",
"resolution": "2K",
},
}, timeout=30)
body = resp.json()
task_id = (body.get("data") or {}).get("taskId")
if not task_id:
raise RuntimeError(f"create failed: {body}")
return task_id
def wait_task(task_id: str, timeout_s: int = 600, poll_s: int = 5) -> dict:
deadline = time.time() + timeout_s
while time.time() < deadline:
task = requests.get(f"{BASE}/{task_id}", headers=HEADERS,
timeout=30).json().get("data") or {}
status = task.get("status")
if status == "success":
return task
if status == "fail":
err = task.get("error") or {}
raise RuntimeError(f"task failed: {err.get('code')} {err.get('message')}")
time.sleep(poll_s)
raise TimeoutError(f"task {task_id} still running after {timeout_s}s")
task_id = create_task()
print("task:", task_id)
task = wait_task(task_id)
url = task["output"][0]["url"] # temporary URL — save it now
img = requests.get(url, timeout=120).content
with open("result.jpg", "wb") as f:
f.write(img)
print("saved result.jpg,", len(img), "bytes")
Note the two-image image_urls — that's Multi-Reference in practice. Up to 8 references are accepted; the model composes across them (person + garment, product + background style, character + pose reference, and so on).
Callbacks instead of polling. For anything beyond a script, register a callback when you create the task so hiapi pushes the terminal result to you:
{
"model": "flux-2/image-to-image",
"input": { "...": "..." },
"callback": { "url": "https://your-app.example.com/hooks/hiapi", "when": "final" }
}
With "when": "final" you get exactly one POST when the task reaches a terminal state. Rule of thumb: polling is fine for CLIs, batch jobs, and anything short-lived; callbacks are better for user-facing apps where a worker holding a poll loop per image doesn't scale. If you can't expose a public callback endpoint, poll — just back off to 5s+ intervals.
Make retries safe. Treat task creation as the side effect it is: store the returned taskId against your own job record before acting on the result, and on retry, re-check the stored task's status instead of blindly creating a new task (each successful create bills separately). If your job runner may fire twice, dedupe on your own job ID first.
Handle the errors you'll actually see:
401 permission_denied — bad key, or the key lacks access to this model. The body looks like:
{"error": {"code": "permission_denied",
"message": "This API key cannot use the selected model. ...",
"type": "hiapi_error"}}
Check the key in your dashboard; our invalid API key checklist covers the usual causes.
400 INVALID_REQUEST — schema violation. The message names every offending field at once (missing required fields, bad enum values, unknown extras), so read it fully rather than fixing one field per retry.
Task status: "fail" — the task was accepted but generation failed; the error is inside data.error. Handle it in your poll/callback path, not at create time.
Expired output URL — if a download 4xx's minutes after success, you waited too long. Re-check expireAt and always download immediately.
How many reference images can I pass to flux-2 image-to-image?
Between 1 and 8, as public URLs in image_urls. Passing more than 8 fails validation (maxItems: got N, want 8).
Is there a strength or fidelity parameter?
No. The input schema accepts only prompt, image_urls, aspect_ratio, and resolution; unknown fields are rejected with a 400. Control fidelity through prompt phrasing (state explicitly what must stay unchanged) and use 2K when detail matters.
What resolutions and aspect ratios are supported?
resolution is 1K or 2K. aspect_ratio is one of 1:1, 4:3, 3:4, 16:9, 9:16, 3:2, 2:3, or auto — auto follows your reference image, which is usually what you want for edits.
Can I send base64 images instead of URLs?
No — image_urls takes URLs, and they must be publicly reachable so the API can fetch them. Upload to a bucket or CDN first and pass the resulting URL (presigned URLs work).
How much does flux-2 image-to-image cost per image? Pricing varies by resolution — check the flux-2/image-to-image model page and the pricing page for current per-image rates.
How long do generated image URLs stay valid?
Output URLs are temporary and include an expireAt field. Download the image bytes as soon as the task succeeds and store them yourself.
What's the difference between flux-2/image-to-image and flux-2/text-to-image? Same model family, different modes: text-to-image generates from a prompt alone, while image-to-image requires 1–8 reference images and edits or composes from them. Their input schemas differ, so the request bodies aren't interchangeable.
Key Takeaways