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.
In plain terms:
- You send the job and immediately get a
taskIdback. Nothing is finished yet; the price of the job is set aside from your balance (a hold). - You come back later and ask "is this
taskIddone?" — that is polling; asking every few seconds is plenty. Or you give us a web address and we send the result there when it is done — that is a callback (a webhook). - When
stateissucceeded, the file link is indata.output.assets[0].url. Open or download it within about 20 minutes; if the link has expired, ask again for a fresh one. A succeeded task is charged no more than its hold; failed and expired tasks normally release the hold, with model-page exceptions for certain charged content refusals. Readsettledandcostfor the final charge.
Why not one call? 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/createTaskThis call spends money
An accepted task holds estimatedCost from your balance straight away and cannot be canceled. To see the price first without creating anything, quote the request. Your key's spend caps limit how much it can spend; see Billing.
Headers
| Header | Required | Notes |
|---|---|---|
Authorization | Yes | Bearer sk-spicy-… |
Content-Type | Yes | application/json |
Idempotency-Key | No | Strongly recommended: it makes a retry after a network error safe. See Idempotency |
X-Spicy-Retention | No | Keep this task's outputs and prompt for a shorter time, in seconds. See Retention and destruction |
Body
Prop
Type
callBackUrl must be a public HTTPS address that our servers can reach over the internet. Plain http:// URLs, an explicit port other than 443 or 80, URLs with a username or password, private or loopback IP addresses, and hostnames that do not resolve are all rejected, because the callback body carries your prompt and signed result links. http://localhost therefore does not work while you develop locally: use polling, or expose your machine through a tunnel that gives you a public HTTPS address. Invalid values return HTTP 400 with business code: 400, msg: "Invalid callback URL", and a request_id. The error does not echo your URL, hostname, IP address, or DNS details. We check the destination again when connecting; see Webhooks.
Spell it callBackUrl, with a capital B
Only input is validated field by field. An unrecognized top-level field such as callback_url or webhookUrl is currently ignored, so the task is accepted but no callback is ever sent. If you expect a callback and none arrives, check this spelling first.
Response
HTTP/1.1 202 Accepted
Location: /api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3
Retry-After: 2The transport status is 202 Accepted. The body still uses the standard envelope, so its successful business code remains 200. Location is the address to poll, and Retry-After: 2 is the suggested wait in seconds before the first query.
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "queued",
"estimatedCost": "0.008",
"deadlineAt": "2026-08-28T09:42:04Z"
},
"request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}| Field | Notes |
|---|---|
taskId | Task ID, prefixed job_. Everything downstream needs it, so save it |
state | queued for a new task; the original task's current state on a same-key idempotent replay |
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; no later amount is collected above the hold |
deadlineAt | The latest time the server keeps working on this task. If it has not finished by then, it ends as expired and nothing is charged. Use it to decide how long to wait |
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.
When creation fails
These are the errors a first integration usually meets. Nothing is held or charged when createTask returns an error.
| HTTP | code | What it usually means | What to do |
|---|---|---|---|
| 400 | 400 | A field in input is not in the model's schema, a value is out of range, or callBackUrl is invalid | Compare with the model's inputSchema; msg names the problem |
| 400 | 40004 | Every field is valid, but this combination cannot be served right now | Change the combination, for example the resolution or duration |
| 401 | 401 | The API key is missing, wrong or disabled | Check the Authorization header |
| 402 | 40201 | Not enough balance to hold estimatedCost | Add funds in the console, then resend with the same Idempotency-Key |
| 402 | 40202 | A spend cap on this key, team or account was reached | Raise the cap in the console, or wait for a daily or monthly cap to reset |
| 403 | 40301 | This key is not allowed to call this model | Use another key, or change the key's model restrictions |
| 409 | 409 / 40901 | An idempotency conflict, or a quote that no longer matches | See Idempotency and Quotes |
| 413 | 413 | The request body is too large, usually because of inline media | Upload the file and pass its spicy:// URI; see Media |
| 429 | 429 | Too many requests in a short time | Wait for Retry-After, then resend with the same key |
| 503 | 50301 | The model is temporarily unavailable | Retry later with the same key |
Full list: Errors.
Before you start: pick a model and a valid input
The examples below need a real model ID and an input that model accepts. The catalog can give you both: includeExamples=1 adds inputs that have already passed each model's schema. This needs jq, a small command-line JSON tool; change image to video or audio for other kinds of model.
curl -s "https://api.spicyapi.ai/api/v1/models?modality=image&includeExamples=1" \
-H "Authorization: Bearer $SPICY_API_KEY" \
| jq -c '.data.items[] | select(.enabled and .available) | {model, input: .examples[0].input}'Each line it prints is a ready-made request body with two fields, model and input. Pick one. If a line shows "input": null, that model has no stored example: read its inputSchema from GET /api/v1/models/{model} and write the input yourself. See Model catalog and schemas.
Examples
MODEL_ID_FROM_CATALOG below is only a placeholder: replace the whole request body with the line you picked from the live catalog. Do not send the placeholder literally.
# Paste one line printed by the command above between the single quotes.
TASK='{"model":"MODEL_ID_FROM_CATALOG","input":{"prompt":"a folded paper lantern, hard side light"}}'
# One new key per new task. After a network error, rerun only the curl command
# so the same key is sent again and no second task is created.
IDEMPOTENCY_KEY="$(uuidgen)"
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: $IDEMPOTENCY_KEY" \
--data "$TASK"You should see the 202 body shown above, with code: 200 and a taskId. Keep the taskId: it is how you collect the result. To receive a callback, add "callBackUrl":"https://…" with your own HTTPS address inside the JSON.
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: 'MODEL_ID_FROM_CATALOG',
input: { prompt: 'a folded paper lantern, hard side light' },
// Your own public HTTPS endpoint; delete this line to poll instead
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, body.data.deadlineAt);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": "MODEL_ID_FROM_CATALOG",
"input": {"prompt": "a folded paper lantern, hard side light"},
# Your own public HTTPS endpoint; delete this line to poll instead
"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"], body["data"]["deadlineAt"])A 202 response is not always queued
The first accepted request creates a queued task. If the same API key replays the same request with the same Idempotency-Key, this 202 returns the original task's current state. It may already be running or terminal, but no second task or hold is created.
2. States
queuedrunningsucceededfailedexpiredAn 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 | The model is generating; awaiting the result | No | Held, not settled |
succeeded | Generation succeeded | Yes | Settled against actual usage, capped at the hold |
failed | Generation failed | Yes | Normally fully released; certain content_rejected outcomes explicitly disclosed on the model page are charged at the accepted quote. Check settled and cost |
canceled | Historical compatibility only; no cancellation action is available | Yes | Fully released |
expired | Exceeded the model's maximum runtime | Yes | Fully released |
Read the settlement, not just the task state
Failed or expired tasks normally release the full hold. Some content refusals are charged at the accepted quote only when the model page explicitly discloses this exception. Check settled and cost for the final charge. A charged refusal still has state: "failed" and errorCode: "content_rejected". A recovery process completes any outstanding settlement or release automatically.
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.
2.5 Skip the polling loop with wait
Append ?wait=<seconds> to createTask and we hold the connection open until the task reaches a terminal state, so your first call does not need a polling loop at all.
curl -X POST "https://api.spicyapi.ai/api/v1/jobs/createTask?wait=30" \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"MODEL_ID_FROM_CATALOG","input":{"prompt":"a lighthouse at dusk"}}'| Outcome | You get |
|---|---|
| Terminal state within the budget | 200 with the task record — the same shape recordInfo returns |
| Budget runs out | The ordinary 202, identical to omitting wait. Poll from there |
| You disconnect | Nothing changes for the task. It keeps running and is billed as usual |
A terminal state is not necessarily a success
200 here means the task finished, not that it succeeded. Check data.state — failed and expired arrive with the same status code, exactly as they do from recordInfo.
The ceiling is 60 seconds. Larger values are clamped rather than rejected, and anything that is not a positive integer is ignored rather than rejected — a typo in an optimisation parameter should not fail a generation that would otherwise succeed.
The ceiling exists because of the 100-second edge timeout in front of the API. Past that line the connection is cut by the CDN rather than by us, and you would get a 524 carrying neither a task ID nor any resumable state — while the task exists and your funds are already held. 60 seconds leaves room for the request itself.
Use it for images, not for video
Video models routinely run for minutes. wait will simply time out, you will pay for the held connection in latency, and you still end up polling. It is worth using when the model typically finishes in seconds — most image and edit endpoints — and a waste otherwise. There is no way to make it the default, by design.
For production integrations, webhooks remain the better answer: no connection is held at all, and you find out about tasks that take longer than any wait budget you would have chosen.
3. Query a task
GET /api/v1/jobs/recordInfo?taskId=job_…One required query parameter, taskId. A task can be read only with the same API key that created it. Querying with a different key — even another key on your own account, or the replacement after you rotate a key — returns 404, exactly like a task that does not exist, so error differences cannot be used to enumerate other people's task IDs. The same rule applies to download-url and retry. Before you rotate a key, finish collecting the results of tasks created with the old one.
curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3" \
-H "Authorization: Bearer $SPICY_API_KEY"Querying is free. Replace the example ID with your own taskId.
Response fields
| Field | Type | Notes |
|---|---|---|
taskId | string | Task ID |
sourceTaskId | string | Only on a task created by retry: the task it was retried from |
model | string | Model identifier |
state | string | See the table above |
input | object | Validated parameters; inline images become platform file URIs. Omitted after input retention expires |
output | object | Artefact description. Only meaningful when succeeded |
errorCode | string | Present when state is failed or expired (an expired task has timeout). One of nine values — see Task failure codes. Branch on this, never on errorMessage |
errorMessage | string | Human-readable failure reason, in English unless another language is selected. 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 |
deadlineAt | string | Execution deadline stored at acceptance; older webhook events may omit it |
completedAt | string | RFC 3339. Terminal states only |
contentState | string | present, expired (removed by a retention period) or purged (you destroyed it). See Retention and destruction |
contentRemovedBy | string | user or system; omitted while the content is still there |
purgedAt | string | RFC 3339. Only after you destroyed the content |
retention | object | outputsExpireAt (when outputs are deleted), promptsExpireAt (when the prompt is erased) and source (which setting decided) |
Four distinct deadlines
- Your own waiting time is yours to choose. Stopping the wait does not cancel the remote task: keep the
taskIdand query that same task later to resume. deadlineAtis the server's execution deadline. Idempotent replay does not extend it; an explicit retry creates a new task with its own deadline. Read the terminal state andsettledrather than inferring a refund from a local timer.output.assets[].expiresAtexpires a temporary result link, normally after 20 minutes. Querying again refreshes the link without creating a new task.- Generated media is retained for at most about 14 days after completion; platform-uploaded inputs for at most one day. Those are platform caps: you can set shorter periods on Retention and destruction, or destroy one task's content on demand. Refreshing a link does not extend the underlying media's retention.
If you use the JavaScript SDK, client.run({ model, input }, { idempotencyKey, onAccepted, timeoutMs }) submits once and polls with backoff from 2 to at most 10 seconds; its timeoutMs is only the local waiting time, so save the ID from onAccepted. See SDK. Prefer webhooks for background jobs. After verifying a complete result callback, use its result directly without another task query.
Use the result URL directly
Ready assets include url and expiresAt alongside key and media metadata. Fetch output.assets[].url directly without API credentials — for example curl -L -o result.png "<url>". Poll again when the URL expires; /common/download-url remains optional for existing integrations. URLs normally last 20 minutes and cannot extend the 14-day result retention period.
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "succeeded",
"input": { "prompt": "a folded paper lantern, hard side light" },
"output": {
"assets": [
{
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
"url": "https://example.r2.cloudflarestorage.com/results/result.png?X-Amz-Signature=SIGNATURE_FROM_RESPONSE",
"expiresAt": "2026-08-28T09:32:11Z",
"mime": "image/png",
"width": 1024,
"height": 1024,
"bytes": 1483920
}
]
},
"cost": "0.008",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"deadlineAt": "2026-08-28T09:42:04Z",
"completedAt": "2026-08-28T09:12:11Z",
"contentState": "present"
},
"request_id": "req_01k3m8xa1b2c3d4e5f6g7h8j9k0m"
}{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "failed",
"errorCode": "upstream_failed",
"errorMessage": "Generation failed; the charge has been refunded",
"cost": "0",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"deadlineAt": "2026-08-28T09:42:04Z",
"completedAt": "2026-08-28T09:12:39Z",
"contentState": "present"
},
"request_id": "req_01k3m8xb2c3d4e5f6g7h8j9k0m1n"
}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 uses this same structure: 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
output has two independent halves. Generated files — images, video, audio — arrive in output.assets[]. Text results arrive in output.text, as a plain string.
Most models fill exactly one of the two, and a model can fill both. Which one you get is a property of the endpoint, not of its modality: a speech-to-text endpoint is grouped under audio in the catalog because that is what it reads, and it answers with text.
`output.assets` is not always there
An endpoint whose result is text has no assets key at all — not an empty array. Code that opens a result with task.output.assets[0].url throws on those tasks rather than returning something you can check. Read output.text first when it is present, and treat assets as optional:
const { text, assets = [] } = task.output ?? {};
if (text) console.log(text);
for (const asset of assets) console.log(asset.url);There is nothing to download for a text result and no link to expire, but the text is part of the task's output all the same: it is removed on the same schedule as any other result, and contentState moves to expired with it. Copy out anything you need to keep. A result that arrives in several parts — the segments of a transcript, for example — is joined into one string with newlines between the parts.
The rest of this section describes assets[].
| Field | Type | Notes |
|---|---|---|
key | string | compatibility identifier for optional download-link refresh. Empty while pending |
url | string | Direct signed GET URL. Omitted when pending or unavailable; do not attach an API key |
expiresAt | string | RFC 3339 expiry for this URL; poll again to refresh within retention |
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 |
layer | object | Only on layer decomposition results: zIndex (integer), boundingBox ([x1, y1, x2, y2], optional) and name (optional). 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 into our storage, andkeyandurlare empty for now. Query again in a few seconds; callingdownload-urlwithout akeyright now returns409.unavailable: true— the asset has expired or is permanently unavailable after transfer failure. This asset is gone for good; waiting will not help, so regenerate. If none of a successful task's files could be stored, that task is not charged.
Layer decomposition results add one more field, layer. The first asset is the base image (the source with the separated elements removed, zIndex 0); every other asset is a transparent PNG layer. Stack them by ascending zIndex to rebuild the picture. boundingBox is [x1, y1, x2, y2] in base-image pixels: scale the layer to fill that box and it lands back in place. Models that do not report positions omit it, and the layers still work as standalone cut-outs. name is the model's own label and appears only when it gives one. Billing counts every returned image, the base included.
4. Callbacks first, polling as a fallback
Polling means your code asks recordInfo again and again until the task is finished. A callback means we send the finished task to your server once. Both give you the same data.
| 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 HTTPS endpoint | Yes | No |
Use callbacks in production. A three-minute video task polled once a second is 180 requests with almost no new information. The current 1,000-per-10-second open-API fuse does not constrain normal paid generation, but tight polling creates needless traffic, and a runaway loop drains its own read allowance until recordInfo starts returning 429.
Polling makes sense in two situations: local development, and services with no public ingress.
Back off when polling
recordInfo draws from the read bucket and createTask from the write bucket, so polling cannot starve submissions. Poll too hard, though, and recordInfo itself starts returning 429: you lose sight of your tasks until the bucket refills.
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.
- Stop at the task's own deadline, not at a fixed number of minutes. Some video models may run for up to an hour, so a fixed 10-minute timeout gives up on tasks that are still working normally. Use
deadlineAtfrom thecreateTaskresponse, plus a few minutes for the server to record the outcome.
Giving up is not a failure
When your wait ends, you have only stopped waiting. The task keeps running and may still succeed and be charged. Do not submit it again with a new Idempotency-Key — that creates and pays for a second task. Query the same taskId later, or find it again in task history.
// created is the data object returned by createTask.
async function waitForTask(created) {
// Stop a few minutes after the server's own deadline.
const giveUpAt = Date.parse(created.deadlineAt) + 5 * 60 * 1000;
let wait = 2000;
while (Date.now() < giveUpAt) {
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=${encodeURIComponent(created.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;
}
// Not a failure: query this taskId again later instead of resubmitting.
throw new Error(`stopped waiting for ${created.taskId}`);
}
// created = body.data from the createTask example above
const task = await waitForTask(created);
if (task.state === 'succeeded') console.log(task.output.assets[0].url);
else console.log(task.state, task.errorCode);import os, time, requests
from datetime import datetime
BASE = "https://api.spicyapi.ai/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}"}
def wait_for_task(created: dict) -> dict:
"""created is the data object returned by createTask."""
# Stop a few minutes after the server's own deadline.
deadline = datetime.fromisoformat(created["deadlineAt"].replace("Z", "+00:00"))
give_up_at = deadline.timestamp() + 5 * 60
wait = 2.0
while time.time() < give_up_at:
time.sleep(wait)
wait = min(wait * 1.5, 15.0)
res = requests.get(f"{BASE}/jobs/recordInfo", headers=AUTH, params={"taskId": created["taskId"]})
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"]
# Not a failure: query this taskId again later instead of resubmitting.
raise TimeoutError(f'stopped waiting for {created["taskId"]}')
# created = body["data"] from the createTask example above
task = wait_for_task(created)
if task["state"] == "succeeded":
print(task["output"]["assets"][0]["url"])
else:
print(task["state"], task.get("errorCode"))5. Check your balance
GET /api/v1/chat/credit returns available (what can fund new tasks), held (locked by tasks in flight) and total. available can be negative while approved credit is in use, and promotional funds listed under funding.grants may pay only for some models. Full details: Checking your balance.
curl https://api.spicyapi.ai/api/v1/chat/credit \
-H "Authorization: Bearer $SPICY_API_KEY"Task history and recovery
GET /api/v1/jobs lists visible tasks created by the current API key. Use it to find work after an application restart or a missed callback. Other keys' tasks and hidden records are excluded. Responses contain task IDs, model, state, cost, settlement state, timestamps and, when one was recorded, the requestId of the call that created the task (for a text call, the same value as its X-Request-Id header); they do not include prompts, input, output or media links. Fetch a selected task with recordInfo to obtain its result.
Filter by state, an exact catalog model or alias, and UTC from / to dates (YYYY-MM-DD, inclusive/exclusive). The default is seven days through tomorrow UTC; the maximum range is 92 days. limit defaults to 20 and cannot exceed 100.
Leave out filters you do not use: an empty value such as model=, a repeated parameter, or an unknown parameter returns 400. A query that takes longer than five seconds returns 503; retry with a shorter date range.
curl --fail-with-body \
'https://api.spicyapi.ai/api/v1/jobs?from=2026-09-01&to=2026-09-07&state=failed&limit=20' \
-H "Authorization: Bearer $SPICY_API_KEY"Read data.items. If data.hasMore is true, pass data.nextCursor as the next request's cursor, keeping all filters and explicit dates unchanged. The ordering is descending createdAt, then task ID. Pages reflect live task state and are not a frozen snapshot. Do not construct cursors yourself. Listing history never creates or retries a task.
The same feature is available through client.listTasks(...), spicyapi tasks list, and the read-only MCP tool spicyapi_tasks_list. Keep USD cost as a decimal string; when settled is false it is still the held estimate, not a final charge.
If the creation response was lost, first retry the unchanged request with its original Idempotency-Key. An empty history page is not permission to create the same paid work again with a new key.

