spicyapiDocs
Main content

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/createTask

Headers

HeaderRequiredNotes
AuthorizationYesBearer sk-spicy-…
Content-TypeYesapplication/json
Idempotency-KeyNoStrongly 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 Accepted

The 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"
}
FieldNotes
taskIdTask ID, prefixed job_. Everything downstream needs it
stateAlways queued
estimatedCostAmount 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
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"
  }'
JavaScript
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);
Python
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
                     └──→ expired

An 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.

StateMeaningTerminalBilling
queuedCreated, funds held, waiting to be picked upNoHeld, not settled
runningSubmitted upstream, awaiting the resultNoHeld, not settled
succeededUpstream returned a resultYesSettled against actual usage, capped at the hold
failedUpstream returned a failureYesFully released
canceledHistorical compatibility only; no cancellation action is availableYesFully released
expiredExceeded the model's maximum runtimeYesFully 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

FieldTypeNotes
taskIdstringTask ID
modelstringModel identifier
statestringSee the table above
inputobjectYour parameters, echoed back. Dropped once the retention window passes
outputobjectArtefact description. Only meaningful when succeeded
errorCodestringMachine-readable failure reason. Present on failure only
errorMessagestringHuman-readable failure reason. Present on failure only
coststringThe settled amount, or the hold if not yet settled; the final charge cannot exceed that hold
settledbooleanWhether billing is final. While false, cost can still change
createdAtstringRFC 3339
completedAtstringRFC 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.

succeeded
{
  "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"
  }
}
failed
{
  "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: 200the 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.

FieldTypeNotes
keystringobject key, used to sign a download URL. Empty while pending
mimestringcontent type, e.g. image/png, video/mp4
width · heightintegerpixel dimensions. Absent on audio and text
durationSecondsnumberlength in seconds. Video and audio only
bytesintegersize in bytes
pendingbooleansee below
unavailablebooleansee 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 and key is empty for now. Try again in a few seconds; calling download-url right now returns 409.
  • 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

CallbackPolling
LatencyThe moment the task finishesYour polling interval
Requests1 per task, made by usN per task, made by you
Consumes your rate limitNoYes — see Rate limits
Needs a public endpointYesNo

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.
JavaScript
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');
}
Python
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/credit
curl
curl 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.

On this page