TypeScript SDK
A step-by-step guide to the TypeScript SDK @spicyapi/sdk — install it, get your first result, and look up every method, option and error in plain language. Python, Go, PHP and Java SDKs are documented on their own pages.
@spicyapi/sdk is the official TypeScript and JavaScript library for SpicyAPI. This page assumes you have never used it. It walks you from an empty folder to a saved image, then describes every function the package offers: what it is for, what you pass in, what comes back, whether it costs money and what can go wrong.
What the SDK is
An SDK is a ready-made code library that you add to your own program. Without it, your program has to build every HTTP request by hand: attach the API key, turn the answer from JSON into data, ask again and again whether a task has finished, retry when the network drops, upload files in three separate steps and check the signature on every webhook. The SDK does all of that for you, so a whole generation fits in one call:
const task = await client.run({ model: modelId, input: { prompt: "A paper lantern at night" } });It suits you if you write, or are about to write, a program in Node.js (JavaScript or TypeScript) that runs on a server, in a script on your own computer, or in a background job. TypeScript users also get autocompletion and type checking for every field.
Is this the right tool?
| You want to… | Use |
|---|---|
| Call SpicyAPI from your own Node.js or TypeScript code | This SDK |
| Generate things without writing code, from a terminal | The CLI |
| Let an AI assistant such as Claude Code or Cursor call SpicyAPI for you | The MCP server and the Agent Skill |
| Use a chat or text model | The official OpenAI client pointed at SpicyAPI — see Text and streaming |
| Use Python, Go, PHP or Java | The official Python SDK, Go SDK, PHP SDK or Java SDK |
| Use any other language | Plain HTTP calls, or a client generated from the API reference |
What it does not do
- It does not run in a browser or a mobile app. It needs Node.js, and your API key must never leave your server: anyone can read a key out of a web page or an app. Let your website call your own backend, and let the backend call SpicyAPI.
- It does not handle chat models. Text generation uses OpenAI-compatible endpoints, and the official
openaipackage already does that well. See Text and streaming. - It does not choose a model or fill in the fields for you. Each model accepts its own fields. The SDK gives you the model list and each model's schema (its list of accepted fields); you decide what to send.
- It cannot cancel a task. Once SpicyAPI accepts a task, it runs to the end. Stopping your program only stops your waiting.
- It does not manage your account. Topping up, creating keys, spend caps and webhook signing keys are set in the console.
Your API key stays on your server
Never put SPICY_API_KEY in browser JavaScript, a mobile app, a public repository, a screenshot or a chat message. A leaked key can spend your balance until you disable it. The SDK reads the key from an environment variable so it never has to appear in your code.
Words used on this page
| Word | What it means here |
|---|---|
| Model ID | The exact name of a model, in the form publisher/model/task. Always copy it from the live catalog (listModels()), never from memory |
| Input | The settings you send with a task, such as prompt or image_url. Every model has its own list of allowed fields, called its schema |
| Task | One generation job. You create it, SpicyAPI works on it in the background, and you collect the result. Each task has an ID that starts with job_ |
| Hold | When a task is accepted, its maximum price is set aside from your balance. 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. |
| Idempotency key | A label you invent for one piece of work, such as order-8814-image-1. If you send the same request twice with the same label, SpicyAPI returns the first task instead of creating and charging a second one |
| Webhook | A web address of yours that SpicyAPI calls when a task finishes, so you do not have to keep asking |
What you can do
Every function below is a method on the client (client.listModels()) unless it says otherwise. "Costs money" means the call can lead to a charge on your balance.
| What you want to do | Method | Costs money? |
|---|---|---|
| Check that SpicyAPI is reachable (no key needed) | getStatus | No |
| See the live model list, your prices and each model's fields | listModels, getModel | No |
| Get the exact price of a request before spending anything | quoteTask | No |
| Start a generation | createTask | Yes |
| Start a generation and wait for the result in one call | run | Yes |
| Look at a task once | getTask | No |
| Wait until a task finishes | waitForTask | No |
| Try a failed task again | retryTask | Yes (a new task) |
| List the tasks created with your key | listTasks | No |
| Check your balance | getBalance | No |
| See what you spent, by day and by model | getUsage | No |
| Upload a local image, video or audio file | uploadFile, uploadBytes, uploadBase64 | No |
| Get a fresh download link for a result | createDownloadUrl | No |
| Delete a finished task's stored files and prompt | purgeTask | No (and no refund) |
| Check that a webhook really came from SpicyAPI | verifyWebhook (a plain function) | No |
| Search the documentation or read the API contract offline | searchDocumentation, readOpenApiContract (plain functions) | No |
Before you start
You need about ten minutes. Each step says what the command does, so nothing runs blind.
Install Node.js 22.13 or newer
Node.js is the program that runs JavaScript outside a browser. Install the LTS version from nodejs.org; any version from 22.13 upwards works. Then open a terminal (Terminal on macOS, PowerShell on Windows) and check:
node -vIt prints the installed version, for example v22.13.0 or v24.8.0. If the number is lower than 22.13, or the command is not found, install Node.js again and open a new terminal window.
Create a project folder and install the SDK
Run these one at a time. They work the same on macOS, Linux and Windows PowerShell.
mkdir spicy-demo
cd spicy-demo
npm init -y
npm pkg set type=module
npm install @spicyapi/sdk| Command | What it does |
|---|---|
mkdir spicy-demo | Creates an empty folder for your project |
cd spicy-demo | Moves the terminal into that folder; the next commands act on it |
npm init -y | Creates package.json, the file that lists your project's settings and libraries |
npm pkg set type=module | Tells Node.js that your files use modern import syntax, which this SDK requires |
npm install @spicyapi/sdk | Downloads the SDK into a node_modules folder and records it in package.json |
The SDK is published only as a modern ES module: import { SpicyClient } from "@spicyapi/sdk" works, while the older require("@spicyapi/sdk") fails with ERR_PACKAGE_PATH_NOT_EXPORTED. If you skip npm pkg set type=module, name your files with the .mjs extension instead.
Create an API key
- Sign in at spicyapi.ai. No account yet? Register at spicyapi.ai/register; if new accounts are paused, that page offers a waitlist.
- Open the console's API keys page and click Create key. Only a name is required.
- Copy the key right away. It starts with
sk-spicy-and is shown only once. If you lose it, create a new one.
You can set a daily spend cap on the same page; new keys have none unless you add one. To run paid tasks you also need a balance, which you add under Billing.
Put the key in an environment variable
An environment variable is a named value that your terminal hands to every program it starts. The SDK looks for one called SPICY_API_KEY, so the key never has to be written into your code.
# Set it for this terminal window (paste your own key)
export SPICY_API_KEY="sk-spicy-paste-your-key-here"
# Check: this should print sk-spicy-
echo "${SPICY_API_KEY:0:9}"The value disappears when you close the window. To keep it on your own computer, add the export line to ~/.zshrc (macOS) or ~/.bashrc (Linux) and open a new window.
Prefer a .env file? Create a file named .env in the project folder containing one line, SPICY_API_KEY=sk-spicy-paste-your-key-here, and start your program with node --env-file=.env yourfile.mjs. The SDK does not read .env files by itself; that flag does. Add .env to your .gitignore file before you ever run git add, so the key is never committed.
On a real server, set SPICY_API_KEY in your hosting provider's secret or environment settings, or pass the key from your secret manager with new SpicyClient({ apiKey }).
Run your first file
In the project folder, create a file named check.mjs with any text editor, paste this in and save:
import { SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
const status = await client.getStatus();
console.log(status.operational ? "SpicyAPI is up" : "SpicyAPI is not reachable right now");
console.log("Checked at", status.checkedAt);Run it:
node check.mjsSpicyAPI is up
Checked at 2026-09-17T08:00:00.000ZThis check needs no key and costs nothing, so it proves Node.js and the SDK are installed correctly.
Using TypeScript? Save the same code as check.ts. Recent Node.js 22 and 24 releases run it directly with node check.ts. If yours complains about the .ts extension, run node --experimental-strip-types check.ts, or npx tsx check.ts.
Your first result
This program does the whole journey: it checks the service, picks an image model your key can use, asks for the exact price and stops. Run it a second time with --yes and it creates the image, waits for it and saves it next to the script.
The --yes run spends money
Without --yes the program only reads: nothing is created and nothing is charged. With --yes it creates one image task, which holds the quoted amount and charges it if the image succeeds.
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { SpicyApiError, SpicyClient } from "@spicyapi/sdk";
// Reads your key from the SPICY_API_KEY environment variable.
const client = new SpicyClient();
async function main() {
// Step 1 — Is SpicyAPI reachable? Free, and needs no key.
const status = await client.getStatus();
if (!status.operational) {
throw new Error("SpicyAPI is not reachable right now. Try again in a few minutes.");
}
// Step 2 — Which image models can this key call? Free.
const catalog = await client.listModels({ modality: "image", includeExamples: true });
const model = catalog.items.find(
(item) => item.enabled && item.available && item.examples?.length,
);
if (!model) throw new Error("No image model is available to this key right now.");
console.log(`Model: ${model.displayName} (${model.model})`);
// Step 3 — Build the input. Start from an example the server has already
// checked, and swap in your own prompt if the model takes one.
const input = { ...model.examples?.[0]?.input };
if ("prompt" in input) input.prompt = "A paper lantern glowing on a rainy street at night";
// Step 4 — Ask for the exact price. Free: nothing is created or reserved.
const request = { model: model.model, input };
const quote = await client.quoteTask(request);
console.log(`Price: $${quote.estimatedCost} USD`);
if (!process.argv.includes("--yes")) {
console.log("Nothing was charged. Run again with --yes to generate the image.");
return;
}
// Step 5 — Create the task and wait for it. This is the step that costs money.
const task = await client.run(
{ ...request, quoteId: quote.quoteId, expectedCost: quote.estimatedCost },
{
idempotencyKey: randomUUID(),
onAccepted: (accepted) => console.log(`Task accepted: ${accepted.taskId}`),
onUpdate: (update) => console.log(`State: ${update.state}`),
},
);
// Step 6 — A failed task is a normal answer, not an exception. Check its settlement.
if (task.state !== "succeeded") {
console.log(`The task ended as ${task.state}: ${task.errorCode} ${task.errorMessage ?? ""}`);
console.log("Check settled and cost for the final charge.");
return;
}
// Step 7 — Download the result now. The link expires after about 20 minutes.
const asset = task.output?.assets?.find((item) => item.url);
if (!asset?.url) throw new Error("The task succeeded but its file is unavailable.");
const download = await fetch(asset.url); // never send your API key here
if (!download.ok) throw new Error(`Download failed with HTTP ${download.status}`);
const extension = asset.mime?.split("/")[1] ?? "png";
await writeFile(`result.${extension}`, Buffer.from(await download.arrayBuffer()));
console.log(`Saved result.${extension}. Charged: $${task.cost}${task.settled ? "" : " (not final yet)"}`);
}
main().catch((error) => {
if (error instanceof SpicyApiError) {
console.error(`SpicyAPI error ${error.code}: ${error.message} (request_id ${error.requestId})`);
} else {
console.error(error);
}
process.exitCode = 1;
});Run it twice:
node first-result.mjs
node first-result.mjs --yesModel: <display name> (<model ID>)
Price: $<amount> USD
Nothing was charged. Run again with --yes to generate the image.
Model: <display name> (<model ID>)
Price: $<amount> USD
Task accepted: job_…
State: running
State: succeeded
Saved result.png. Charged: $<amount>What each step does
getStatus()asks SpicyAPI's health endpoint whether the service is up. It never throws for a network problem; it reportsoperational: falseinstead.listModels()returns the live model catalog with your account's prices. Use only items whoseenabledandavailableare bothtrue.includeExamples: trueadds sample inputs that the server has already validated, which is the safest starting point. The value you send later is the item'smodelfield, the model ID.- The input. Every model accepts different fields; sending a field the model does not know is rejected with error
400. Copying a validated example and changing only the prompt avoids that. To see every field a model accepts, callgetModel()and read itsinputSchema. quoteTask()prices this exact request. It creates nothing and reserves nothing. Amounts are US dollars written as text, such as"0.04", so no rounding creeps in.run()creates the task and then checks on it until it finishes. PassingquoteIdandexpectedCostmeans "only accept this at the price I was shown": if the price changed, the call fails with40901instead of charging a different amount. TheidempotencyKeymakes a network retry safe; in a real app, save it before calling so a restarted program can reuse it.onAcceptedhands you the task ID the moment the task exists — save it, so you can check on the task later even if your program stops.- The task state.
run()returns normally for failed tasks too. 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. Checksettledandcostfor the final charge. - The download. Each result comes with a temporary
url. Download it without your API key and keep your own copy: the link lasts about 20 minutes (callgetTask()again for a fresh one), and SpicyAPI deletes generated files after about 14 days at most. See Retention and destruction.
Video works the same way but can take several minutes; pass a longer timeoutMs to run() (the default wait is 10 minutes). A model ID used in the snippets below appears as MODEL_ID_FROM_CATALOG: it is a placeholder, not a real model. Replace it with the model value of an item from the live catalog — listModels() in the SDK, or the authenticated GET /api/v1/models endpoint — whose enabled and available are both true.
API reference
Each entry below says what the method is for, how to call it, what comes back, whether it can cost money and which errors to expect. Error classes such as SpicyApiError are explained in Handling errors. All amounts are US dollars written as text ("0.04"), and all times are ISO 8601 strings in UTC.
Creating a client
new SpicyClient(options?: SpicyClientOptions)Most programs pass nothing: new SpicyClient() reads the key from SPICY_API_KEY and talks to the production service. An invalid option throws a TypeError immediately, before any request is sent.
Prop
Type
Automatic retries
The SDK retries a request by itself only when doing so cannot cost you twice:
| Call | Retried automatically? |
|---|---|
Reads: getStatus, listModels, getModel, getTask, listTasks, getBalance, getUsage; also quoteTask, purgeTask, commitUploadedFile | Yes |
createTask, run (its create step), retryTask | Only when you pass an idempotencyKey. Without one, a network error is thrown straight to you, because resending could create a second task |
createUploadUrl, the byte upload itself, createDownloadUrl | No |
A retry happens when there is no response at all (network error or local timeout) or the answer is HTTP 408, 429, 500, 502, 503 or 504. The pause is whatever the server's Retry-After header asks for; otherwise 250 ms, then 500 ms, then 1 s, and so on, each varied by up to 25% and capped at maxRetryDelayMs. When every attempt fails, you receive the last error.
Stopping a call with signal
Every method accepts a signal option, a standard AbortSignal. Use AbortSignal.timeout(5_000) for a deadline, or an AbortController to stop on demand. The call then throws the abort reason. Stopping only ends your request or wait: a task SpicyAPI has already accepted keeps running and can still be charged.
getStatus()
Checks whether SpicyAPI is reachable. Handy as the first line of a script or as a health check in your own monitoring.
getStatus(options?: { signal?: AbortSignal }): Promise<ServiceStatus>| Returned field | Meaning |
|---|---|
operational | true when the service is answering. This is the field to use |
health | The raw result of the health check: path, ok, HTTP status, the first 512 characters of body, and error when no response arrived |
readiness | Same shape for a second check. On the public service it answers 404, which is normal and does not mean the service is down |
checkedAt | When the check ran |
Costs money: No. Needs a key: No. Errors: it does not throw for network problems — you get operational: false and the reason in health.error. It throws only if you abort it with signal. See Run your first file for an example.
listModels(options?)
Lists the live model catalog, with your account's prices. This is where every model ID comes from.
listModels(options?: ListModelsOptions): Promise<ModelList>Prop
Type
It returns { total, items }. The most useful fields of each item:
| Field | Meaning |
|---|---|
model | The model ID. Send exactly this value when you create a task |
displayName | A readable name |
modality, tasks | What it produces and which job it does |
provider, family | Who made the model and which product line it belongs to |
enabled, available | Both must be true before you use the model |
pricing | One entry per price tier: price, unit (per_image, per_second, per_request or per_1k_tokens) and variant (which tier, often a resolution). During a discount, regularPrice shows the undiscounted price |
startingPrice | The cheapest tier. For an exact figure, use quoteTask |
quantityField | The input field that sets the billed quantity, such as a duration. Empty means one unit per call |
taskTimeoutSeconds | How long a task may run before it ends as expired |
maxOutputDurationSeconds | The longest output the model allows, when it has a duration |
inputSchema, examples | Present only when you asked for them |
updatedAt, version | Change whenever the model's details change; use them to know when to refresh a saved schema |
Costs money: No. Needs a key: Yes. Errors: SpicyApiError (for example 401 for a key problem, 429 for too many requests), SpicyTransportError.
const { items } = await client.listModels({ modality: "video", task: "image-to-video" });
for (const item of items) {
if (!item.enabled || !item.available) continue;
const from = item.startingPrice ? `from $${item.startingPrice.price} ${item.startingPrice.unit}` : "";
console.log(item.model, "|", item.displayName, "|", from);
}Catalog reads use the account’s read allowance, independently of task creation. Save the list and refresh it occasionally, or after a 400 or 50301, instead of reading it before every task.
getModel(modelId)
Returns one model with its full schema: the list of input fields it accepts, which ones are required and which values are allowed.
getModel(modelId: string, options?: { signal?: AbortSignal }): Promise<ApiModel>The result has every field described under listModels, always including inputSchema and examples. The schema is standard JSON Schema: properties lists each field with its type and often enum (allowed values), minimum / maximum and default; required lists the fields you must send.
Costs money: No. Needs a key: Yes. Errors: SpicyApiError with 404 when no model has that ID; TypeError if the ID is empty.
const model = await client.getModel("MODEL_ID_FROM_CATALOG");
console.log("Required fields:", model.inputSchema?.required);
console.log(JSON.stringify(model.inputSchema?.properties, null, 2));
console.log("A request that is known to work:", model.examples?.[0]?.input);quoteTask(request)
Gives the exact price of a request without creating anything. Use it to show a price to a person, or to refuse work above a budget.
quoteTask(request: { model: string; input: object; callBackUrl?: string }, options?: { signal?: AbortSignal }): Promise<TaskQuote>Pass the same model, input and optional callBackUrl you intend to create the task with.
| Returned field | Meaning |
|---|---|
quoteId | Pass it to createTask or run to lock in this price |
estimatedCost | The amount that will be held if you create the task now |
maxCharge | The most this request can cost |
currency | Always USD |
quantity, unit | The billed quantity, such as "5" and per_second |
expiresAt | The quote is valid for five minutes |
Costs money: No. Needs a key: Yes. Errors: SpicyApiError, for example 400 when the input does not match the model's schema, 50301 when the model is unavailable, 429.
const request = { model: "MODEL_ID_FROM_CATALOG", input: { prompt: "A paper lantern at night" } };
const quote = await client.quoteTask(request);
console.log(`This will cost $${quote.estimatedCost}. Quote valid until ${quote.expiresAt}.`);createTask(request, options?)
Starts a generation and returns immediately with a task ID. It does not wait for the result: follow up with waitForTask, a webhook, or use run to do both in one call.
createTask(request: CreateTaskInput, options?: CreateTaskOptions): Promise<CreateTaskResult>The request:
Prop
Type
The options:
Prop
Type
| Returned field | Meaning |
|---|---|
taskId | The new task's ID. Save it straight away |
state | queued for a new task. If you repeated an idempotency key, the original task's current state |
estimatedCost | The amount now on hold, which is also the most this task can charge |
deadlineAt | When SpicyAPI stops working on the task if it is not finished |
Costs money: Yes. The estimatedCost is held from your balance. If the task succeeds you are charged for actual usage, never more than the hold, and the rest is released. 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.
Errors:
error.code | What it means | What to do |
|---|---|---|
400 | The input does not match the model's schema, or callBackUrl is not allowed | Compare with getModel().inputSchema and fix the request |
401 | The key is missing, malformed or revoked | Check SPICY_API_KEY |
40201 | Your balance cannot cover the hold | Top up under Billing in the console |
40202 | A daily, monthly or lifetime spend cap was reached | Raise the cap on the API keys page, or wait for it to reset |
40301 | This key is not allowed to use this model | Change the key's model list in the console |
409 | This idempotency key was already used for a different request | Use a new key for new work |
40901 | The quote expired, or the price or request changed | Quote again and confirm the new price |
413 | The request body is too large | Upload media files instead of putting them inline |
429 | Too many requests | Wait error.retryAfterSeconds, then try again |
50301 | The model cannot take tasks right now | Try later, or pick another model |
A SpicyTransportError or SpicyTimeoutError means no answer arrived, so you cannot tell whether the task was created. Send the same request with the same idempotency key again: you either create it or get the existing task back. A TypeError means an option was invalid (for example a negative retentionSeconds) and nothing was sent.
import { randomUUID } from "node:crypto";
const idempotencyKey = randomUUID(); // in a real app, save this with your order first
const accepted = await client.createTask(
{ model: "MODEL_ID_FROM_CATALOG", input: { prompt: "A paper lantern at night" } },
{ idempotencyKey },
);
console.log(accepted.taskId, accepted.state, "on hold: $" + accepted.estimatedCost);run(request, options?)
The easiest way to generate something: it calls createTask, then waitForTask, and returns the finished task.
run(request: CreateTaskInput, options?: RunTaskOptions): Promise<TaskRecord>request is the same as for createTask. The options are everything createTask accepts, plus:
Prop
Type
It returns the finished task, with the fields described under getTask. For a successful task it also waits until the result files have finished copying, so output.assets[].url is ready to download.
Costs money: Yes, exactly like createTask. A failed task is returned, not thrown — check task.state.
Errors: everything createTask can throw, plus:
SpicyWaitTimeoutErrorwhentimeoutMsruns out after the task was accepted. The task keeps running on SpicyAPI. CallwaitForTask(error.taskId)later to collect it.SpicyTimeoutErrorwhen time runs out before SpicyAPI confirmed the task. Send the same request with the same idempotency key to find out.- Any error thrown inside your own
onAcceptedoronUpdatestops the waiting; the task itself keeps running. TypeErrorfor an invalidtimeoutMsorintervalMs. These are checked before anything is created.
See Your first result for a complete example.
getTask(taskId)
Reads a task once: its state, its result links, and what it cost.
getTask(taskId: string, options?: { signal?: AbortSignal }): Promise<TaskRecord>| Field | Meaning |
|---|---|
taskId, model | Which task, made with which model |
state | queued or running while it works; succeeded, failed or expired when it is finished (canceled appears only on old records) |
output.assets | The result files, described in the next table. Only for succeeded |
output.text | Text output, for models that return text |
errorCode, errorMessage | Why a task failed. Make decisions on errorCode; the message wording can change |
cost | While settled is false, the amount on hold; once true, the final charge |
settled | Whether cost is final |
createdAt, completedAt, deadlineAt | When it was created, finished, and when it would expire |
input | The input as stored. Removed when the prompt retention period ends |
sourceTaskId | Set on a task created by retryTask, pointing to the original |
contentState | present; expired (removed by retention rules); or purged (you deleted it with purgeTask) |
retention | When the files (outputsExpireAt) and the prompt (promptsExpireAt) will be removed |
Each item in output.assets:
| Field | Meaning |
|---|---|
url | A temporary download link. Download it without your API key |
expiresAt | When that link stops working, normally about 20 minutes. Call getTask again for a fresh link |
mime | The file type, such as image/png or video/mp4 |
width, height, durationSeconds, bytes | Size details, when they apply |
pending | true while the file is still being copied into storage. Check again in a few seconds |
unavailable | true when the file is gone for good. Waiting will not help; generate again |
key | An internal file name, only needed by createDownloadUrl |
A field SpicyAPI has no value for is left out rather than set to null, so treat every field above as optional.
Costs money: No. Errors: SpicyApiError with 404 when no task with that ID is visible to your key.
const task = await client.getTask("job_01k3m8x9q2z4v7n5p6r8s0t1w3"); // the ID you saved
if (task.state === "succeeded") {
for (const asset of task.output?.assets ?? []) console.log(asset.mime, asset.url);
} else if (task.state === "queued" || task.state === "running") {
console.log("Still working. On hold: $" + task.cost);
} else {
console.log(`Finished as ${task.state}: ${task.errorCode}`);
}waitForTask(taskId, options?)
Checks a task repeatedly until it finishes, then returns it. Use it for a task you created earlier, for example after your program restarted.
waitForTask(taskId: string, options?: WaitForTaskOptions): Promise<TaskRecord>Options: timeoutMs (default 600000, up to 86400000), intervalMs, onUpdate and signal, all as described under run.
It returns once state is succeeded, failed, expired or canceled, and for a successful task once no result file is still pending. Every check is one getTask request and counts toward your account's request limit, which is why the default interval slows down over time.
Costs money: No. Errors: SpicyWaitTimeoutError when timeoutMs runs out. It carries taskId, timeoutMs and lastTask (the last state seen). The task is not canceled; you can simply wait again. Also SpicyApiError (for example 404) and SpicyTransportError.
const task = await client.waitForTask("job_01k3m8x9q2z4v7n5p6r8s0t1w3", {
timeoutMs: 30 * 60_000,
onUpdate: (update) => console.log(new Date().toISOString(), update.state),
});
console.log(task.state, task.output?.assets?.[0]?.url);retryTask(taskId, options?)
Runs a failed task again, as a brand-new task with the same model and input. The original task stays exactly as it was, and the new one points back to it through sourceTaskId.
retryTask(taskId: string, options?: { idempotencyKey?: string; signal?: AbortSignal }): Promise<RetryTaskResult>It works on failed and expired tasks (and old canceled ones). A task that succeeded or is still running is refused with 409. The price, your balance and the key's permissions are checked again, and the new task keeps the original task's retention setting.
Because the input is reused unchanged, a retry only helps when the failure was temporary. Read the source task's errorCode first: rate_limited, upstream_unavailable, timeout and upstream_failed are worth one retry after a pause; generation_failed and invalid_asset need a changed request sent with createTask; content_rejected, invalid_request and unsupported_combination should not be retried unchanged. See Common error codes and Errors.
It returns taskId (the new task), sourceTaskId (the original), state, estimatedCost and deadlineAt.
Costs money: Yes. The new task has its own hold and is charged separately if it succeeds. Pass an idempotencyKey so that running your retry code twice creates only one retry.
Errors: 409 (the task cannot be retried), plus the balance, cap and availability errors listed under createTask. If the original input used an uploaded spicy:// file, that file is only usable for one day; upload it again and use createTask instead.
retryTask is not for network errors
If createTask itself failed with a network error, you do not have a task to retry. Send createTask again with the same idempotency key. Use retryTask only for a task whose state you have read as failed or expired.
A complete example is in Retry a failed task.
listTasks(options?)
Lists tasks created with this API key, newest first, one page at a time. Useful to find work after a restart or a missed webhook.
listTasks(options?: ListTasksOptions): Promise<TaskList>Prop
Type
It returns items, hasMore and, when there are more pages, nextCursor. Each item has taskId, model, state, cost, settled, createdAt, deadlineAt and completedAt. Inputs, results and links are not included; call getTask for a task you need.
Costs money: No. Errors: SpicyApiError (for example 400 for an invalid date range), SpicyTransportError.
let cursor;
do {
const page = await client.listTasks({
from: "2026-09-01", // keep the same dates on every page
to: "2026-09-08",
state: "failed",
limit: 100,
cursor,
});
for (const item of page.items) console.log(item.taskId, item.model, item.createdAt);
cursor = page.hasMore ? page.nextCursor : undefined;
} while (cursor);getBalance()
Shows how much money your account has.
getBalance(options?: { signal?: AbortSignal }): Promise<Balance>| Field | Meaning |
|---|---|
available | Money that can fund new tasks. It can be negative when your account uses an approved credit line |
held | Money set aside for tasks that are still running |
total | available plus held |
funding | Optional breakdown: prepaid funds, promotional grants (with the models they apply to and their expiry) and any credit line. Older responses may leave it out |
Costs money: No. You do not need to check the balance before each task: SpicyAPI checks it when you create one and answers 40201 if it is not enough. See Billing for every funding field.
const balance = await client.getBalance();
console.log(`Available: $${balance.available} (on hold: $${balance.held})`);getUsage(options?)
Reports what this API key spent, grouped by day and by model.
getUsage(options?: { from?: string; to?: string; signal?: AbortSignal }): Promise<UsageReport>from and to are UTC dates (YYYY-MM-DD); from is included and to is not. Leave both out for the last seven days, including today. The range can cover at most 92 days.
| Field | Meaning |
|---|---|
totalCalls | Number of tasks in the range |
totalSpend | Final charges only; money still on hold is not counted |
days | One entry per day with calls: day, calls, succeeded, failed, spend |
models | One entry per model: model, calls, succeeded, failed, spend |
Costs money: No. This report is heavier than other reads, so it has an extra limit of 30 requests per minute; use it for reports, not in a loop. Errors: SpicyApiError (429 above that limit), SpicyTransportError.
const usage = await client.getUsage({ from: "2026-09-01", to: "2026-09-08" });
console.log(`${usage.totalCalls} tasks, $${usage.totalSpend} spent`);
for (const row of usage.models) console.log(row.model, row.calls, "$" + row.spend);Uploading files
When a model needs a picture, video or sound as input, you can put a public HTTPS link to the file directly in the input field. For a file on your computer, upload it first. You get back a spicy:// address; put that in the input field instead. Uploading is free.
- Images (JPEG, PNG, WebP, GIF): up to 10 MiB.
- Video (MP4, WebM) and audio (MP3, WAV): up to 90 MiB and 600 seconds long.
- A model can set a lower limit in its schema.
- An uploaded file can be used for one day, and only by your account.
uploadFile(path, options?)
Uploads a file from disk.
uploadFile(path: string, options?: { contentType?: string; signal?: AbortSignal }): Promise<UploadedFile>The file type is detected from the extension: .jpg, .jpeg, .png, .webp, .gif, .mp4, .webm, .mp3 or .wav. For any other name, pass contentType: image/jpeg, image/png, image/webp, image/gif, video/mp4, video/webm, audio/mpeg or audio/wav.
| Returned field | Meaning |
|---|---|
uri | The spicy:// address to put in a task's input |
expiresAt | When the address stops working (one day) |
fileId, status, bytes, contentType, sha256 | Details of what was stored; status is ready |
width, height, durationSeconds | Measured dimensions or length, when they apply |
Costs money: No. Errors: TypeError when the type cannot be detected — its message lists every extension it recognises, contentType is required unless the file extension is one of: gif, jpeg, jpg, png, webp, mp4, webm, mp3, wav; a Node.js ENOENT error when the path is wrong; SpicyUploadError (with status) when storage refused the bytes or the file is larger than allowed (413); SpicyApiError when SpicyAPI refused the upload; SpicyTimeoutError when sending took longer than uploadTimeoutMs.
const file = await client.uploadFile("./photo.jpg");
console.log(file.uri, "can be used until", file.expiresAt);uploadBytes(data, options)
Uploads bytes you already have in memory, such as a file a user sent to your server. A Node.js Buffer works.
uploadBytes(data: Uint8Array, options: { contentType: string; signal?: AbortSignal }): Promise<UploadedFile>contentType is required. The result and errors are the same as for uploadFile.
const response = await fetch("https://example.com/reference.png");
const bytes = new Uint8Array(await response.arrayBuffer());
const file = await client.uploadBytes(bytes, { contentType: "image/png" });uploadBase64(value, options?)
Uploads a file you have as Base64 text: either a data URI such as data:image/png;base64,iVBOR…, or plain Base64 together with contentType.
uploadBase64(value: string, options?: { contentType?: string; signal?: AbortSignal }): Promise<UploadedFile>The encoding and size are checked on your machine first; invalid or oversized data throws a TypeError without uploading anything. The result is the same as for uploadFile.
createUploadUrl and commitUploadedFile
The three helpers above are built from these two lower-level calls. You only need them to send the bytes from somewhere other than your Node.js process.
createUploadUrl(contentType: string, bytes: number, options?: { signal?: AbortSignal }): Promise<UploadTicket>
commitUploadedFile(fileId: string, options?: { signal?: AbortSignal }): Promise<UploadedFile>createUploadUrlreturnsfileId,uploadUrl,method(PUT),headers,maxBytesandexpiresAt.- Send the file with a
PUTtouploadUrl, with exactly the listedheadersand noAuthorizationheader. The declared size must match the file. commitUploadedFile(fileId)checks the stored file and returns thespicy://uri. A file that was uploaded but never committed cannot be used.
The whole flow, including browser uploads, is described in Media.
createDownloadUrl(taskId, key?)
Creates a fresh temporary download link for one result file. You rarely need it: getTask, waitForTask, run and webhooks already include a ready url for every result.
createDownloadUrl(taskId: string, key?: string, options?: { signal?: AbortSignal }): Promise<DownloadTicket>Pass the asset's key to choose a file, or leave it out for the first one. It returns key, url and expiresAt (about 20 minutes).
Costs money: No. Errors: SpicyApiError with 409 while the file is still being copied (wait a few seconds), or 404 when the task has not succeeded or the key does not belong to it.
purgeTask(taskId)
Permanently deletes a finished task's generated media, result payload (the stored result data), prompt and other input text, earlier than the retention period would. Use it when a result must not be kept.
purgeTask(taskId: string, options?: { signal?: AbortSignal }): Promise<PurgeTaskResult>| Returned field | Meaning |
|---|---|
contentState | purged when the content is deleted. present means nothing was removed this time; try again later |
purgedAt | When the content was first deleted |
contentRemovedBy | user when you deleted it |
billingRetained | Always true: the record of what the task cost is kept |
mediaDeletionPending | true while stored files are still being removed in the background, normally for under a minute |
Costs money: No — and it is not a refund. The ledger entry, charged amount, model, state, timestamps and request ID stay on record. Deleting cannot be undone. Calling it again on the same task is safe. Errors: SpicyApiError with 400 when the task is still queued or running, 404 when it does not exist. An accepted task cannot be cancelled and there is no cancellation API, so wait for it with waitForTask, then purge it. See Retention and destruction.
const result = await client.purgeTask("job_01k3m8x9q2z4v7n5p6r8s0t1w3");
console.log(result.contentState, "billing kept:", result.billingRetained);Webhook functions
verifyWebhook and computeWebhookSignature are plain functions, not client methods. They are described with a working example in Webhooks.
Offline helpers and types
These work without a network connection or a key.
| Export | What it gives you |
|---|---|
searchDocumentation(query, limit) | Searches a small built-in index of this documentation site. Returns up to limit entries (1 to 25, default 10), each with slug, title, summary, keywords and url, where url has the form https://docs.spicyapi.ai/docs/<slug>. No match returns an empty array; an empty query returns the first entries |
DOCUMENTATION | That whole built-in index as an array, one entry per documentation page |
readOpenApiContract() | The OpenAPI 3.1 contract bundled with this SDK version, as YAML text |
DEFAULT_API_BASE_URL, DEFAULT_SERVICE_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_UPLOAD_TIMEOUT_MS, DEFAULT_WAIT_TIMEOUT_MS, DEFAULT_WAIT_INTERVAL_MS, MAX_API_RESPONSE_BYTES | The default values described on this page. API responses larger than MAX_API_RESPONSE_BYTES (4 MiB) are refused |
TypeScript types for every request and response are exported too, for example import type { TaskRecord, ApiModel, Balance } from "@spicyapi/sdk".
| Import path | Contains |
|---|---|
@spicyapi/sdk | Everything on this page |
@spicyapi/sdk/webhooks | Only verifyWebhook, computeWebhookSignature and SpicyWebhookVerificationError — a small import for a webhook receiver |
@spicyapi/sdk/openapi | Only the raw OpenAPI types paths, components and operations |
Webhooks
A webhook lets SpicyAPI tell you when a task is finished, instead of your program asking every few seconds. You give each task a callBackUrl, an address on your own server, and SpicyAPI sends the finished task record there.
Why you must verify every webhook
Your webhook address is a public URL: anyone who guesses it can send it a fake message saying "task succeeded, here is the file". If your code believes that message, it might deliver the wrong file to a customer or mark an order as done.
Every real delivery is signed with a secret signing key that only you and SpicyAPI know. verifyWebhook recomputes that signature from the exact bytes you received and compares the two. It also checks the delivery time, so an attacker cannot capture a genuine message and send it to you again hours later. Only act on a message after it passes.
Set it up
Get your signing key
A signing key is created automatically when you register, but its value is never shown, so you need to create one you can copy. In the console, open Webhooks and click Generate (or Rotate if the card says a key is configured). The new key is shown only once: copy it and store it on your server as SPICY_WEBHOOK_SECRET. If you lose it, rotate again.
Rotating has no overlap period: the old key stops signing immediately, and even deliveries still being retried are signed with the new one. Update every running receiver right after you rotate. Details are in Set a signing secret first.
Run a receiver on a public HTTPS address
SpicyAPI can only call an address reachable from the internet that starts with https://. Plain http://, localhost and private network addresses are refused. While developing, expose your local server through a tunnel service that gives it a public HTTPS address, or use polling until you deploy. The full rules are in Constraints on the callback URL.
Send callBackUrl with your tasks
Add callBackUrl: "https://your-app.example.com/hooks/spicy" to the request you pass to createTask. See Get results by webhook.
A minimal receiver
This receiver uses only Node.js's built-in http module, so there is nothing else to install. Save it as receiver.mjs and start it with node receiver.mjs.
import { createServer } from "node:http";
import { SpicyWebhookVerificationError, verifyWebhook } from "@spicyapi/sdk/webhooks";
const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET first");
// Delivery IDs you have already handled. Use a database table in production,
// so the list survives a restart.
const handled = new Set();
const server = createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/hooks/spicy") {
response.writeHead(404).end();
return;
}
if (request.headers["x-webhook-payload-version"] !== "2") {
response.writeHead(400).end();
return;
}
// 1. Read the body as raw bytes. Do not parse it before verifying.
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const rawBody = Buffer.concat(chunks);
// 2. Check the signature and the timestamp.
let event;
try {
event = verifyWebhook({
rawBody,
secret,
timestamp: String(request.headers["x-webhook-timestamp"] ?? ""),
signature: String(request.headers["x-webhook-signature"] ?? ""),
payloadVersion: 2,
});
} catch (error) {
const reason = error instanceof SpicyWebhookVerificationError ? error.reason : String(error);
console.warn("Rejected a webhook:", reason);
response.writeHead(401).end();
return;
}
// 3. Answer quickly. SpicyAPI waits at most 15 seconds, then retries.
response.writeHead(204).end();
// 4. The same delivery can arrive more than once. Handle each one once.
if (!event.deliveryId || handled.has(event.deliveryId)) return;
handled.add(event.deliveryId);
if (!("data" in event.payload)) return; // version 2 bodies keep the task under data
const task = event.payload.data; // the same fields getTask() returns
console.log("Task", task.taskId, "is", task.state);
if (task.state === "succeeded") {
console.log("Result:", task.output?.assets?.[0]?.url);
}
});
server.listen(8080, () => console.log("Listening on http://localhost:8080/hooks/spicy"));What matters in it:
- Raw bytes. The signature covers the exact bytes SpicyAPI sent. If a framework parses the JSON first and you turn it back into text, the bytes change and every check fails. In Express, use
express.raw({ type: "application/json" })on this route. - Answer fast. Any
2xxanswer means "received". No answer within 15 seconds, or any other status, makes SpicyAPI retry several times over the following hours. Do slow work after answering. - Duplicates happen. Retries and network hiccups can deliver the same message twice.
event.deliveryIdstays the same across retries, so remember the IDs you have handled. - Success lives in
state. A delivery for a failed task is still a valid delivery. Readtask.state, never the envelope'scode. - In production, save the verified event to your database first and answer
2xxonly after the save succeeds, and ignore task IDs your application did not create. The Webhooks guide has a database-backed version.
Test your receiver before going live
computeWebhookSignature produces a real signature, so you can send yourself a signed test delivery. With receiver.mjs running in one terminal and the same SPICY_WEBHOOK_SECRET set in another, run:
import { computeWebhookSignature } from "@spicyapi/sdk";
const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET first");
const body = JSON.stringify({
code: 200,
msg: "success",
request_id: "whd_local_test_1",
data: {
taskId: "job_local_test",
state: "succeeded",
cost: "0.01",
settled: true,
output: { assets: [{ url: "https://example.com/result.png" }] },
},
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = computeWebhookSignature("job_local_test", timestamp, body, secret);
const response = await fetch("http://localhost:8080/hooks/spicy", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Timestamp": timestamp,
"X-Webhook-Signature": signature,
"X-Webhook-Payload-Version": "2",
},
body,
});
console.log("Your receiver answered", response.status); // expect 204Run it twice: the receiver prints the task once, because the second delivery has the same request_id. Change one character of body after signing and the receiver answers 401.
verifyWebhook(options)
verifyWebhook(options: VerifyWebhookOptions): VerifiedWebhookProp
Type
It returns payload (the parsed body; for version 2 the task is in payload.data), payloadVersion, taskId, deliveryId (the version 2 request_id, identical across retries) and timestamp. The function is synchronous: no await needed.
When verification fails it throws SpicyWebhookVerificationError, whose reason is one of invalid_signature, stale_timestamp, invalid_timestamp, invalid_json, invalid_payload, body_too_large or invalid_secret. Answer 401 and do not act on the message.
computeWebhookSignature(taskId, timestamp, rawBody, secret) returns the Base64 signature for a body. You need it only for tests like the one above. The full signing algorithm, for other languages, is in Webhooks.
TypeScript tip: payload is typed as either format. Narrow it with "data" in event.payload, as the receiver does, or pass the expected type: verifyWebhook<components["schemas"]["TaskRecordEnvelope"]>(…) with import type { components } from "@spicyapi/sdk".
Handling errors
Things go wrong in two different ways, and they need different handling:
- A call fails. The SDK throws an error: your key was wrong, your balance was too low, the network dropped. Catch it with
try/catch. - A task fails. The call worked, but the generation did not. Nothing is thrown: the returned task has
state: "failed"and anerrorCode. 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. Checksettledandcostfor the final charge.
The error classes
| Class | When you see it | Useful fields |
|---|---|---|
SpicyApiError | SpicyAPI answered with an error, such as a rejected key or a low balance | code (the business code to act on, such as 40201), status (HTTP status), message, requestId, retryAfterSeconds, rateLimitLimit, rateLimitRemaining |
SpicyTransportError | No usable answer arrived: network failure, DNS problem, or a response larger than 4 MiB | message, cause (the underlying error) |
SpicyTimeoutError | A single request took longer than timeoutMs (or uploadTimeoutMs). It is a kind of SpicyTransportError | timeoutMs |
SpicyWaitTimeoutError | run or waitForTask ran out of time. The task is still running | taskId, timeoutMs, lastTask |
SpicyUploadError | Storage refused an uploaded file, or it was too large | status |
SpicyWebhookVerificationError | A webhook failed verification | reason |
TypeError | The call itself was invalid — no key set, an option out of range, an empty ID. Nothing was sent | message |
Decide what to do from code, never from message: messages are for people, their wording can change, and they follow your account's error-language setting. Include requestId whenever you contact support; it lets us find the exact request.
import { randomUUID } from "node:crypto";
import {
SpicyApiError,
SpicyClient,
SpicyTransportError,
SpicyWaitTimeoutError,
} from "@spicyapi/sdk";
const client = new SpicyClient();
const MODEL_ID = "MODEL_ID_FROM_CATALOG"; // a real ID from listModels()
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");
const idempotencyKey = randomUUID(); // keep it: you need the same one to try again
try {
const task = await client.run(
{ model: MODEL_ID, input: { prompt: "A paper lantern on a rainy street" } },
{ idempotencyKey, onAccepted: ({ taskId }) => console.log("Task ID:", taskId) },
);
if (task.state === "succeeded") console.log("Done:", task.output?.assets?.[0]?.url);
else console.log(`Finished as ${task.state} (${task.errorCode}). Cost: ${task.cost}; settled: ${task.settled}.`);
} catch (error) {
if (error instanceof SpicyWaitTimeoutError) {
// Your wait ended, the task did not. Check on it later with the same ID.
console.log(`Still running: ${error.taskId}. Call waitForTask() again later.`);
} else if (error instanceof SpicyApiError) {
console.log(`SpicyAPI said no: ${error.code} (HTTP ${error.status}) ${error.message}`);
console.log(`Quote this if you contact support: ${error.requestId}`);
if (error.code === 40201) console.log("Your balance is too low. Top up in the console.");
if (error.code === 40202) console.log("A spend cap was reached. Raise it on the API keys page.");
if (error.code === 429) console.log(`Too many requests. Wait ${error.retryAfterSeconds ?? 1} s.`);
} else if (error instanceof SpicyTransportError) {
// Network failure or local timeout: the task may or may not exist.
// Run the same request again with the SAME idempotencyKey to find out safely.
console.log(`Network problem (${error.message}). Retry with key ${idempotencyKey}.`);
} else {
throw error; // a TypeError here is a bug in the call itself, such as a missing key
}
}Common error codes
error.code | Meaning | Worth retrying? |
|---|---|---|
400 | Invalid request, usually an input field the model does not accept | No. Fix the request |
401 | Key missing, malformed, revoked or expired | No. Check the key |
40201 | Balance too low for the hold | No. Top up first |
40202 | A spend cap was reached | No. Raise the cap, or wait for the daily or monthly reset |
40301 | This key may not use this model | No. Change the key's model list |
404 | No such model or task for this key | No |
409 | Conflicts with the current state, such as an idempotency key reused for different work | Depends; see Errors |
40901 | Quote expired or price changed | Quote again, then confirm |
429 | Too many requests | Yes, after retryAfterSeconds. The SDK already retries reads for you |
500 | Something broke on SpicyAPI's side | Yes, with a pause |
50301 | The model is unavailable right now | Yes, later — or choose another model |
When a task fails, task.errorCode is one of invalid_request, unsupported_combination, content_rejected, rate_limited, upstream_unavailable, generation_failed, timeout, invalid_asset or upstream_failed. retryTask sends the original input again unchanged, so pick the next step by code:
task.errorCode | What to do |
|---|---|
rate_limited, upstream_unavailable, timeout, upstream_failed | Wait, then retryTask once. If a timeout repeats, try a shorter duration or a lower resolution with createTask |
generation_failed | Change the prompt or the image and send a new task with createTask; the same input usually fails the same way |
invalid_asset | Upload the file again and send a new task with createTask using the new uri; a retry would reuse the old file |
invalid_request, unsupported_combination | Do not retry unchanged. Change the settings, then use createTask |
content_rejected | Do not retry unchanged. The selected model declined the request under its own policy; change the prompt or choose a different model |
The full table is in Errors.
Keep your results
- A result
urlworks for about 20 minutes. CallgetTaskagain to get a fresh one. - Generated files are deleted after about 14 days at most, sooner if your account or
retentionSecondssays so. Uploaded input files last one day, and stored prompts 30 days. - Download what you want to keep into your own storage as soon as a task succeeds. Do not store the temporary
urlitself in your database. Details: Retention and destruction.
Common recipes
The recipes use MODEL_ID_FROM_CATALOG as a placeholder. Before running one, replace it with the model value of a matching item from the live catalog (listModels() or GET /api/v1/models) whose enabled and available are both true. Each recipe stops with a clear message if you forget.
Image to video: upload a local picture first
import { randomUUID } from "node:crypto";
import { SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
// Replace with an image-to-video model ID from the live catalog:
// await client.listModels({ modality: "video", task: "image-to-video" })
const MODEL_ID = "MODEL_ID_FROM_CATALOG";
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");
// 1. Upload the local picture. Free. The spicy:// address works for one day.
const file = await client.uploadFile("./photo.jpg");
console.log("Uploaded:", file.uri);
// 2. Print the model's schema: which field takes the image, which fields are required.
const { inputSchema } = await client.getModel(MODEL_ID);
console.log(JSON.stringify(inputSchema, null, 2));
// 3. Create the video and wait for it. Video often takes several minutes.
const task = await client.run(
{
model: MODEL_ID,
input: {
prompt: "Slow push-in, light rain, neon reflections on wet stone",
image_url: file.uri, // use the field name the schema above shows
},
},
{
idempotencyKey: randomUUID(),
timeoutMs: 30 * 60_000,
onAccepted: ({ taskId }) => console.log("Task ID (save it):", taskId),
},
);
console.log(task.state, task.output?.assets?.[0]?.url ?? task.errorCode);The printed schema is the authority on field names and required fields; a video model may also require a duration or a resolution, and the price often depends on them. If your image is already online at a public HTTPS address, skip the upload and put that address in the image field directly. Run quoteTask with the same request first if you want to see the price.
Run a batch with limited concurrency
Starting hundreds of tasks at once can hit your spend caps and request limit. This script runs a list of prompts with at most two tasks in progress at a time.
import { SpicyApiError, SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
const MODEL_ID = "MODEL_ID_FROM_CATALOG"; // a real ID from listModels()
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");
// Change the batch name whenever you change the prompts.
const BATCH_NAME = "lanterns-2026-09-17";
const prompts = [
"A paper lantern on a rainy street",
"A paper lantern floating on a lake",
"A paper lantern in a snowy forest",
"A paper lantern above a night market",
];
const CONCURRENCY = 2; // how many tasks may run at the same time
const results = prompts.map((prompt) => ({ prompt, outcome: "not started", detail: "" }));
let nextIndex = 0;
async function worker() {
while (nextIndex < prompts.length) {
const index = nextIndex++;
try {
const task = await client.run(
{ model: MODEL_ID, input: { prompt: prompts[index] } },
{
// Same batch name + same position = same key. If the script crashes and you
// run it again within 24 hours, you get the existing tasks back instead of
// paying for them twice.
idempotencyKey: `${BATCH_NAME}-${index}`,
timeoutMs: 20 * 60_000,
},
);
results[index].outcome = task.state;
results[index].detail = task.output?.assets?.[0]?.url ?? task.errorCode ?? "";
} catch (error) {
results[index].outcome = "error";
results[index].detail = error instanceof Error ? error.message : String(error);
// Out of balance (40201) or over a spend cap (40202): stop starting new tasks.
if (error instanceof SpicyApiError && (error.code === 40201 || error.code === 40202)) {
nextIndex = prompts.length;
}
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
console.table(results);- Why the keys are built from the batch name. An idempotency key tied to "this batch, item 3" means a rerun after a crash reuses the tasks already created. A random key on every run would pay for the same image again.
- Why the batch name must change with the prompts. Reusing a key for a different request is refused with
409. - Choosing
CONCURRENCY. Every running task holds part of your balance, and every status check counts toward your account's request limit. Start small and raise it once the batch runs cleanly.
Get results by webhook instead of polling
For a web app or a background worker, waiting inside a request is wasteful. Create the task with a callBackUrl, store the task ID, and let the receiver handle the result when it arrives.
import { randomUUID } from "node:crypto";
import { SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
const MODEL_ID = "MODEL_ID_FROM_CATALOG"; // a real ID from listModels()
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");
// In a real app, store this key with the customer's order before calling.
const idempotencyKey = randomUUID();
const accepted = await client.createTask(
{
model: MODEL_ID,
input: { prompt: "A paper lantern on a rainy street" },
callBackUrl: "https://your-app.example.com/hooks/spicy",
},
{ idempotencyKey },
);
console.log(`Accepted ${accepted.taskId}; $${accepted.estimatedCost} is on hold.`);
// Save accepted.taskId now. When the task finishes, SpicyAPI calls your receiver.Keep a safety net for deliveries that never arrive, for example because your server was down through every retry: once in a while, call getTask for tasks you saved that are well past their deadlineAt without a webhook, or page through listTasks. Webhook delivery history, with a manual redeliver button, is on the console's Webhooks page.
Retry a failed task
import { SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
const taskId = process.argv[2]; // run as: node retry.mjs job_...
if (!taskId) throw new Error("Usage: node retry.mjs <taskId>");
// A retry sends the original input again unchanged, so only temporary failures are worth it.
// For every other code, change the request and use createTask instead.
const WORTH_ONE_RETRY = new Set(["rate_limited", "upstream_unavailable", "timeout", "upstream_failed"]);
const source = await client.getTask(taskId);
if (
(source.state !== "failed" && source.state !== "expired") ||
!WORTH_ONE_RETRY.has(source.errorCode ?? "")
) {
console.log(`Not retrying: the task is ${source.state} (${source.errorCode ?? "no error code"}).`);
} else {
// A fixed key per retry attempt: running this script twice creates one retry, not two.
const retry = await client.retryTask(source.taskId, {
idempotencyKey: `retry-${source.taskId}-attempt-1`,
});
console.log(`New task ${retry.taskId}; $${retry.estimatedCost} is on hold.`);
const finished = await client.waitForTask(retry.taskId, { timeoutMs: 20 * 60_000 });
console.log("The retry finished as", finished.state);
}It retries only failures that can go differently next time — a busy or temporarily unavailable service, a timeout, or an unknown error — and only once, with a fixed idempotency key. For invalid_asset, upload the file again and create a new task, because the retry would reuse the expired upload. For generation_failed, change the prompt or the image and create a new task. For content_rejected, invalid_request and unsupported_combination, change the request rather than retrying it. If the one retry fails too, stop and look at the error; for a repeated timeout, try a shorter duration or a lower resolution with createTask. See Errors.
Troubleshooting and FAQ
Source
The package is MIT-licensed and developed in the open at github.com/spicyapi-ai/spicy-sdk. That is where to read the code behind any method on this page, report behaviour that differs from what is described here, or propose a change.
Next steps
Model catalog and schemas
How to read a model's inputSchema and choose valid inputs
Asynchronous tasks
Task states, polling and what each field means
Webhooks
Delivery format, retry schedule and verification in other languages
Media
Uploads, Base64 limits and result downloads
Errors
Every error code and which ones are worth retrying
Billing
Holds, settlement, spend caps and your balance
Idempotency
Retry safely without paying twice
Command-line tool
The same features from a terminal, without writing code
MCP server
Let an AI coding assistant use SpicyAPI for you
Python SDK
The same workflow from Python, with no runtime dependencies
Go SDK
The same workflow from Go, on the standard library alone
PHP SDK
The same workflow from PHP, with no library dependencies
Java SDK
The same workflow from Java, with a sealed error taxonomy

