spicyapiDocs
Main content

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 codeThis SDK
Generate things without writing code, from a terminalThe CLI
Let an AI assistant such as Claude Code or Cursor call SpicyAPI for youThe MCP server and the Agent Skill
Use a chat or text modelThe official OpenAI client pointed at SpicyAPI — see Text and streaming
Use Python, Go, PHP or JavaThe official Python SDK, Go SDK, PHP SDK or Java SDK
Use any other languagePlain 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 openai package 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

WordWhat it means here
Model IDThe exact name of a model, in the form publisher/model/task. Always copy it from the live catalog (listModels()), never from memory
InputThe settings you send with a task, such as prompt or image_url. Every model has its own list of allowed fields, called its schema
TaskOne 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_
HoldWhen 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 keyA 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
WebhookA 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 doMethodCosts money?
Check that SpicyAPI is reachable (no key needed)getStatusNo
See the live model list, your prices and each model's fieldslistModels, getModelNo
Get the exact price of a request before spending anythingquoteTaskNo
Start a generationcreateTaskYes
Start a generation and wait for the result in one callrunYes
Look at a task oncegetTaskNo
Wait until a task finisheswaitForTaskNo
Try a failed task againretryTaskYes (a new task)
List the tasks created with your keylistTasksNo
Check your balancegetBalanceNo
See what you spent, by day and by modelgetUsageNo
Upload a local image, video or audio fileuploadFile, uploadBytes, uploadBase64No
Get a fresh download link for a resultcreateDownloadUrlNo
Delete a finished task's stored files and promptpurgeTaskNo (and no refund)
Check that a webhook really came from SpicyAPIverifyWebhook (a plain function)No
Search the documentation or read the API contract offlinesearchDocumentation, 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 -v

It 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
CommandWhat it does
mkdir spicy-demoCreates an empty folder for your project
cd spicy-demoMoves the terminal into that folder; the next commands act on it
npm init -yCreates package.json, the file that lists your project's settings and libraries
npm pkg set type=moduleTells Node.js that your files use modern import syntax, which this SDK requires
npm install @spicyapi/sdkDownloads 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

  1. Sign in at spicyapi.ai. No account yet? Register at spicyapi.ai/register; if new accounts are paused, that page offers a waitlist.
  2. Open the console's API keys page and click Create key. Only a name is required.
  3. 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:

check.mjs
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.mjs
SpicyAPI is up
Checked at 2026-09-17T08:00:00.000Z

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

first-result.mjs
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 --yes
Model: <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

  1. getStatus() asks SpicyAPI's health endpoint whether the service is up. It never throws for a network problem; it reports operational: false instead.
  2. listModels() returns the live model catalog with your account's prices. Use only items whose enabled and available are both true. includeExamples: true adds sample inputs that the server has already validated, which is the safest starting point. The value you send later is the item's model field, the model ID.
  3. 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, call getModel() and read its inputSchema.
  4. 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.
  5. run() creates the task and then checks on it until it finishes. Passing quoteId and expectedCost means "only accept this at the price I was shown": if the price changed, the call fails with 40901 instead of charging a different amount. The idempotencyKey makes a network retry safe; in a real app, save it before calling so a restarted program can reuse it. onAccepted hands you the task ID the moment the task exists — save it, so you can check on the task later even if your program stops.
  6. 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. Check settled and cost for the final charge.
  7. 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 (call getTask() 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:

CallRetried automatically?
Reads: getStatus, listModels, getModel, getTask, listTasks, getBalance, getUsage; also quoteTask, purgeTask, commitUploadedFileYes
createTask, run (its create step), retryTaskOnly 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, createDownloadUrlNo

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 fieldMeaning
operationaltrue when the service is answering. This is the field to use
healthThe raw result of the health check: path, ok, HTTP status, the first 512 characters of body, and error when no response arrived
readinessSame shape for a second check. On the public service it answers 404, which is normal and does not mean the service is down
checkedAtWhen 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:

FieldMeaning
modelThe model ID. Send exactly this value when you create a task
displayNameA readable name
modality, tasksWhat it produces and which job it does
provider, familyWho made the model and which product line it belongs to
enabled, availableBoth must be true before you use the model
pricingOne 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
startingPriceThe cheapest tier. For an exact figure, use quoteTask
quantityFieldThe input field that sets the billed quantity, such as a duration. Empty means one unit per call
taskTimeoutSecondsHow long a task may run before it ends as expired
maxOutputDurationSecondsThe longest output the model allows, when it has a duration
inputSchema, examplesPresent only when you asked for them
updatedAt, versionChange 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 fieldMeaning
quoteIdPass it to createTask or run to lock in this price
estimatedCostThe amount that will be held if you create the task now
maxChargeThe most this request can cost
currencyAlways USD
quantity, unitThe billed quantity, such as "5" and per_second
expiresAtThe 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 fieldMeaning
taskIdThe new task's ID. Save it straight away
statequeued for a new task. If you repeated an idempotency key, the original task's current state
estimatedCostThe amount now on hold, which is also the most this task can charge
deadlineAtWhen 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.codeWhat it meansWhat to do
400The input does not match the model's schema, or callBackUrl is not allowedCompare with getModel().inputSchema and fix the request
401The key is missing, malformed or revokedCheck SPICY_API_KEY
40201Your balance cannot cover the holdTop up under Billing in the console
40202A daily, monthly or lifetime spend cap was reachedRaise the cap on the API keys page, or wait for it to reset
40301This key is not allowed to use this modelChange the key's model list in the console
409This idempotency key was already used for a different requestUse a new key for new work
40901The quote expired, or the price or request changedQuote again and confirm the new price
413The request body is too largeUpload media files instead of putting them inline
429Too many requestsWait error.retryAfterSeconds, then try again
50301The model cannot take tasks right nowTry 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:

  • SpicyWaitTimeoutError when timeoutMs runs out after the task was accepted. The task keeps running on SpicyAPI. Call waitForTask(error.taskId) later to collect it.
  • SpicyTimeoutError when 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 onAccepted or onUpdate stops the waiting; the task itself keeps running.
  • TypeError for an invalid timeoutMs or intervalMs. 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>
FieldMeaning
taskId, modelWhich task, made with which model
statequeued or running while it works; succeeded, failed or expired when it is finished (canceled appears only on old records)
output.assetsThe result files, described in the next table. Only for succeeded
output.textText output, for models that return text
errorCode, errorMessageWhy a task failed. Make decisions on errorCode; the message wording can change
costWhile settled is false, the amount on hold; once true, the final charge
settledWhether cost is final
createdAt, completedAt, deadlineAtWhen it was created, finished, and when it would expire
inputThe input as stored. Removed when the prompt retention period ends
sourceTaskIdSet on a task created by retryTask, pointing to the original
contentStatepresent; expired (removed by retention rules); or purged (you deleted it with purgeTask)
retentionWhen the files (outputsExpireAt) and the prompt (promptsExpireAt) will be removed

Each item in output.assets:

FieldMeaning
urlA temporary download link. Download it without your API key
expiresAtWhen that link stops working, normally about 20 minutes. Call getTask again for a fresh link
mimeThe file type, such as image/png or video/mp4
width, height, durationSeconds, bytesSize details, when they apply
pendingtrue while the file is still being copied into storage. Check again in a few seconds
unavailabletrue when the file is gone for good. Waiting will not help; generate again
keyAn 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>
FieldMeaning
availableMoney that can fund new tasks. It can be negative when your account uses an approved credit line
heldMoney set aside for tasks that are still running
totalavailable plus held
fundingOptional 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.

FieldMeaning
totalCallsNumber of tasks in the range
totalSpendFinal charges only; money still on hold is not counted
daysOne entry per day with calls: day, calls, succeeded, failed, spend
modelsOne 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 fieldMeaning
uriThe spicy:// address to put in a task's input
expiresAtWhen the address stops working (one day)
fileId, status, bytes, contentType, sha256Details of what was stored; status is ready
width, height, durationSecondsMeasured 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>
  1. createUploadUrl returns fileId, uploadUrl, method (PUT), headers, maxBytes and expiresAt.
  2. Send the file with a PUT to uploadUrl, with exactly the listed headers and no Authorization header. The declared size must match the file.
  3. commitUploadedFile(fileId) checks the stored file and returns the spicy:// 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 fieldMeaning
contentStatepurged when the content is deleted. present means nothing was removed this time; try again later
purgedAtWhen the content was first deleted
contentRemovedByuser when you deleted it
billingRetainedAlways true: the record of what the task cost is kept
mediaDeletionPendingtrue 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.

ExportWhat 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
DOCUMENTATIONThat 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_BYTESThe 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 pathContains
@spicyapi/sdkEverything on this page
@spicyapi/sdk/webhooksOnly verifyWebhook, computeWebhookSignature and SpicyWebhookVerificationError — a small import for a webhook receiver
@spicyapi/sdk/openapiOnly 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.

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 2xx answer 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.deliveryId stays 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. Read task.state, never the envelope's code.
  • In production, save the verified event to your database first and answer 2xx only 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:

send-test.mjs
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 204

Run 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): VerifiedWebhook

Prop

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:

  1. 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.
  2. A task fails. The call worked, but the generation did not. Nothing is thrown: the returned task has state: "failed" and an errorCode. 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.

The error classes

ClassWhen you see itUseful fields
SpicyApiErrorSpicyAPI answered with an error, such as a rejected key or a low balancecode (the business code to act on, such as 40201), status (HTTP status), message, requestId, retryAfterSeconds, rateLimitLimit, rateLimitRemaining
SpicyTransportErrorNo usable answer arrived: network failure, DNS problem, or a response larger than 4 MiBmessage, cause (the underlying error)
SpicyTimeoutErrorA single request took longer than timeoutMs (or uploadTimeoutMs). It is a kind of SpicyTransportErrortimeoutMs
SpicyWaitTimeoutErrorrun or waitForTask ran out of time. The task is still runningtaskId, timeoutMs, lastTask
SpicyUploadErrorStorage refused an uploaded file, or it was too largestatus
SpicyWebhookVerificationErrorA webhook failed verificationreason
TypeErrorThe call itself was invalid — no key set, an option out of range, an empty ID. Nothing was sentmessage

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.

handle-errors.mjs
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.codeMeaningWorth retrying?
400Invalid request, usually an input field the model does not acceptNo. Fix the request
401Key missing, malformed, revoked or expiredNo. Check the key
40201Balance too low for the holdNo. Top up first
40202A spend cap was reachedNo. Raise the cap, or wait for the daily or monthly reset
40301This key may not use this modelNo. Change the key's model list
404No such model or task for this keyNo
409Conflicts with the current state, such as an idempotency key reused for different workDepends; see Errors
40901Quote expired or price changedQuote again, then confirm
429Too many requestsYes, after retryAfterSeconds. The SDK already retries reads for you
500Something broke on SpicyAPI's sideYes, with a pause
50301The model is unavailable right nowYes, 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.errorCodeWhat to do
rate_limited, upstream_unavailable, timeout, upstream_failedWait, then retryTask once. If a timeout repeats, try a shorter duration or a lower resolution with createTask
generation_failedChange the prompt or the image and send a new task with createTask; the same input usually fails the same way
invalid_assetUpload the file again and send a new task with createTask using the new uri; a retry would reuse the old file
invalid_request, unsupported_combinationDo not retry unchanged. Change the settings, then use createTask
content_rejectedDo 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 url works for about 20 minutes. Call getTask again to get a fresh one.
  • Generated files are deleted after about 14 days at most, sooner if your account or retentionSeconds says 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 url itself 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

image-to-video.mjs
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.

batch.mjs
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.

webhook-create.mjs
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

retry.mjs
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

On this page