Asynchronous tasks
The createTask and recordInfo contracts, what each of the six states means, and how to choose between callbacks and polling.
Generation is asynchronous: submitting and collecting are two separate calls.
Images take seconds; video routinely takes minutes. A synchronous endpoint would leave your process parked on a connection, and any hiccup would leave you with neither a result nor a safe way to retry — you would not know whether the previous attempt was already running.
Splitting the two makes the task ID the anchor: reconnect, and keep asking about the same ID.
1. Create a task
POST /api/v1/jobs/createTaskHeaders
| Header | Required | Notes |
|---|---|---|
Authorization | Yes | Bearer sk-spicy-… |
Content-Type | Yes | application/json |
Idempotency-Key | No | Strongly recommended. See Idempotency |
Body
Prop
Type
`mature` is a request, not a switch
Registered accounts can request mature-capable models by default with no additional third-party age gate. Passing true still checks that the model supports mature mode and that this key has not disabled it. A failed check rejects the request — we never silently downgrade to standard mode. Integrators remain responsible for adult-only, consensual and lawful use, including local rules for real people and minors. See Content policy.
callBackUrl is validated at creation time. Anything that is not http/https, or whose hostname resolves to a private address, is rejected with 400.
Response
HTTP/1.1 202 AcceptedThe transport status is 202 Accepted. The body still uses the standard envelope, so its successful business code remains 200.
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "queued",
"estimatedCost": "0.008"
},
"request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}| Field | Notes |
|---|---|
taskId | Task ID, prefixed job_. Everything downstream needs it |
state | Always queued |
estimatedCost | Amount held at acceptance, e.g. "0.008" (US dollars, as a string), and the maximum this task can charge. Unused funds are released; provider overage is absorbed by the platform |
Money fields are strings, but they carry no currency symbol
estimatedCost, cost and the three balance fields are plain decimal strings like "0.008": no $, no thousands separators. parseFloat / float() work directly on them.
They are strings rather than JSON numbers because a JSON number lands as a float64 in most languages, and 0.008 has no exact binary representation. Once a price has been through a float, you can no longer say what was actually charged — parse with a decimal type (decimal.Decimal, BigDecimal) if the number is going into your books.
The currency is always USD. See Billing.
Examples
curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8814-render-1" \
-d '{
"model": "kie/minimax-h3-spicy",
"input": {
"endpoint": "image-to-video",
"prompt": "slow dolly in, rain on the window",
"first_frame_url": "inputs/usr_01k3m8x9q2z4v7n5p6r8s0t1w3/2026/08/28/a91f4c2e5b7d16809e3f2a4c8d0b61e7.png",
"duration": 5,
"resolution": "768p"
},
"mature": true,
"callBackUrl": "https://your-app.example.com/hooks/spicy"
}'const res = await fetch('https://api.spicyapi.ai/api/v1/jobs/createTask', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPICY_API_KEY}`,
'Content-Type': 'application/json',
// One key per logical request; reuse it across retries
'Idempotency-Key': 'order-8814-render-1',
},
body: JSON.stringify({
model: 'kie/z-image-spicy',
input: { prompt: 'a folded paper lantern, hard side light' },
callBackUrl: 'https://your-app.example.com/hooks/spicy',
}),
});
const body = await res.json();
if (res.status !== 202 || body.code !== 200) throw new Error(`${res.status} ${body.code} ${body.msg}`);
console.log(body.data.taskId);import os, requests
res = requests.post(
"https://api.spicyapi.ai/api/v1/jobs/createTask",
headers={
"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
"Content-Type": "application/json",
# One key per logical request; reuse it across retries
"Idempotency-Key": "order-8814-render-1",
},
json={
"model": "kie/z-image-spicy",
"input": {"prompt": "a folded paper lantern, hard side light"},
"callBackUrl": "https://your-app.example.com/hooks/spicy",
},
)
body = res.json()
if res.status_code != 202 or body["code"] != 200:
raise RuntimeError(f'{res.status_code} {body["code"]} {body["msg"]}')
print(body["data"]["taskId"])2. States
queued ──→ running ──┬──→ succeeded
├──→ failed
└──→ expiredAn accepted task is irreversible: neither queued nor running has a cancellation action. The API
can still read historical canceled records, but no new user or operator action creates that state.
| State | Meaning | Terminal | Billing |
|---|---|---|---|
queued | Created, funds held, waiting to be picked up | No | Held, not settled |
running | Submitted upstream, awaiting the result | No | Held, not settled |
succeeded | Upstream returned a result | Yes | Settled against actual usage, capped at the hold |
failed | Upstream returned a failure | Yes | Fully released |
canceled | Historical compatibility only; no cancellation action is available | Yes | Fully released |
expired | Exceeded the model's maximum runtime | Yes | Fully released |
Failed tasks are not billed — that is a commitment, not best effort
Every terminal state other than succeeded releases the hold. Even if our process dies mid-flight, a sweeper releases it on the next pass. You do not need to do anything, and you do not need to come to us to reconcile.
Test for terminal state as state is not in {queued, running}, rather than enumerating terminal values. This safely handles historical canceled records and prevents a future terminal state from causing endless polling.
3. Query a task
GET /api/v1/jobs/recordInfo?taskId=job_…One required query parameter, taskId. A task belonging to someone else returns 404 — the same response as one that does not exist, so error differences cannot be used to enumerate other people's task IDs.
Response fields
| Field | Type | Notes |
|---|---|---|
taskId | string | Task ID |
model | string | Model identifier |
state | string | See the table above |
input | object | Your parameters, echoed back. Dropped once the retention window passes |
output | object | Artefact description. Only meaningful when succeeded |
errorCode | string | Machine-readable failure reason. Present on failure only |
errorMessage | string | Human-readable failure reason. Present on failure only |
cost | string | The settled amount, or the hold if not yet settled; the final charge cannot exceed that hold |
settled | boolean | Whether billing is final. While false, cost can still change |
createdAt | string | RFC 3339 |
completedAt | string | RFC 3339. Terminal states only |
There are no URLs in `output`
output.assets[].key is an object key, not a link. Download addresses are issued on demand by /common/download-url.
That is deliberate: recordInfo responses get pasted into tickets, log systems and chat windows. Putting a long-lived link in there would hand artefact access control to the hope that nobody forwards the JSON.
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "kie/z-image-spicy",
"state": "succeeded",
"input": { "prompt": "a folded paper lantern, hard side light" },
"output": {
"assets": [
{
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
"mime": "image/png",
"width": 1024,
"height": 1024,
"bytes": 1483920
}
]
},
"cost": "0.008",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:11Z"
}
}{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "kie/z-image-spicy",
"state": "failed",
"errorCode": "upstream_failed",
"errorMessage": "生成失败,费用已退回",
"cost": "0",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:39Z"
}
}A successful query is not a successful task
Both responses carry code: 200 — the query succeeded. The task's fate lives in data.state. The business code describes the delivery; state describes the task. Do not conflate them.
A webhook body is byte-for-byte this shape: the same code: 200 envelope, the same data fields. Whatever parses this section parses a callback unchanged — two outlets, one parser.
The shape of output
Image and video artefacts arrive in output.assets[]; text models put their result in output.text.
| Field | Type | Notes |
|---|---|---|
key | string | object key, used to sign a download URL. Empty while pending |
mime | string | content type, e.g. image/png, video/mp4 |
width · height | integer | pixel dimensions. Absent on audio and text |
durationSeconds | number | length in seconds. Video and audio only |
bytes | integer | size in bytes |
pending | boolean | see below |
unavailable | boolean | see below |
Fields we do not have are omitted — not null, not 0. Parse them as optional.
Two flags need handling:
pending: true— the artefact is still being copied from upstream into our storage andkeyis empty for now. Try again in a few seconds; callingdownload-urlright now returns409.unavailable: true— the copy failed repeatedly and the upstream link has since expired. This asset is gone for good; waiting will not help, so regenerate.
4. Callbacks first, polling as a fallback
| Callback | Polling | |
|---|---|---|
| Latency | The moment the task finishes | Your polling interval |
| Requests | 1 per task, made by us | N per task, made by you |
| Consumes your rate limit | No | Yes — see Rate limits |
| Needs a public endpoint | Yes | No |
Use callbacks in production. A three-minute video task polled once a second is 180 requests; against the default budget of 20 per 10 seconds, that single task eats a large share of your allowance — and 179 of those answers are "still running".
Polling makes sense in two situations: local development, and services with no public ingress.
Back off when polling
recordInfo and createTask draw from the same bucket. The harder you poll, the fewer tasks you can submit — worst case, polling starves submissions and createTask starts returning 429.
A shape that works:
- Start at 2–3 seconds. Anything shorter is wasted; even the fastest image models take a few seconds.
- Multiply by 1.5, capped at 10–15 seconds.
- Set an overall timeout. Do not write a loop that can poll forever.
async function waitForTask(taskId, { timeoutMs = 10 * 60 * 1000 } = {}) {
const deadline = Date.now() + timeoutMs;
let wait = 2000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, wait));
wait = Math.min(wait * 1.5, 15000);
const res = await fetch(
`https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${taskId}`,
{ headers: { Authorization: `Bearer ${process.env.SPICY_API_KEY}` } },
);
const body = await res.json();
// Being rate-limited is not a task failure — honour Retry-After
if (body.code === 429) {
wait = Number(res.headers.get('retry-after') ?? 1) * 1000;
continue;
}
if (body.code !== 200) throw new Error(`${body.code} ${body.msg}`);
// Terminal means "not one of the two in-flight states"
const { state } = body.data;
if (state !== 'queued' && state !== 'running') return body.data;
}
throw new Error('timed out waiting for task');
}import os, time, requests
BASE = "https://api.spicyapi.ai/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}"}
def wait_for_task(task_id: str, timeout: float = 600.0) -> dict:
deadline = time.monotonic() + timeout
wait = 2.0
while time.monotonic() < deadline:
time.sleep(wait)
wait = min(wait * 1.5, 15.0)
res = requests.get(f"{BASE}/jobs/recordInfo", headers=AUTH, params={"taskId": task_id})
body = res.json()
# Being rate-limited is not a task failure — honour Retry-After
if body["code"] == 429:
wait = float(res.headers.get("retry-after", 1))
continue
if body["code"] != 200:
raise RuntimeError(f'{body["code"]} {body["msg"]}')
# Terminal means "not one of the two in-flight states"
if body["data"]["state"] not in ("queued", "running"):
return body["data"]
raise TimeoutError("timed out waiting for task")5. Check your balance
GET /api/v1/chat/creditcurl https://api.spicyapi.ai/api/v1/chat/credit \
-H "Authorization: Bearer $SPICY_API_KEY"{
"code": 200,
"msg": "success",
"data": {
"available": "128.42",
"held": "0.36",
"total": "128.78"
}
}available can fund new tasks, held is locked by tasks in flight, total is the sum. See Billing.

