spicyapiDocumentación
SDK
Contenido principal

SDK de TypeScript

Guía paso a paso del SDK de TypeScript @spicyapi/sdk para instalarlo, conseguir tu primer resultado y consultar cada método, opción y error explicados con palabras sencillas. Los SDK de Python, Go, PHP y Java tienen cada uno su propia página.

@spicyapi/sdk es la biblioteca oficial de TypeScript y JavaScript para SpicyAPI. Esta página da por hecho que nunca la has usado. Te lleva desde una carpeta vacía hasta una imagen guardada en tu equipo y después describe cada función del paquete: para qué sirve, qué le pasas, qué te devuelve, si cuesta dinero y qué puede salir mal.

Qué es el SDK

Un SDK es una biblioteca de código ya hecha que añades a tu propio programa. Sin él, tu programa tiene que construir a mano cada solicitud HTTP: adjuntar la clave de API, convertir la respuesta de JSON en datos, preguntar una y otra vez si una tarea ha terminado, reintentar cuando se corta la red, subir archivos en tres pasos separados y comprobar la firma de cada webhook. El SDK hace todo eso por ti, así que una generación completa cabe en una sola llamada:

const task = await client.run({ model: modelId, input: { prompt: "A paper lantern at night" } });

Te conviene si escribes, o vas a escribir, un programa en Node.js (JavaScript o TypeScript) que se ejecuta en un servidor, en un script en tu propio equipo o en un proceso en segundo plano. Si usas TypeScript, además tienes autocompletado y comprobación de tipos en cada campo.

¿Es la herramienta adecuada?

Quieres…Usa
Llamar a SpicyAPI desde tu propio código de Node.js o TypeScriptEste SDK
Generar cosas desde una terminal, sin escribir códigoLa CLI
Que un asistente de IA como Claude Code o Cursor llame a SpicyAPI por tiEl servidor MCP y el Agent Skill
Usar un modelo de chat o de textoEl cliente oficial de OpenAI apuntando a SpicyAPI; consulta Texto y streaming
Usar Python, Go, PHP o JavaEl SDK de Python, el SDK de Go, el SDK de PHP o el SDK de Java oficiales
Usar cualquier otro lenguajeLlamadas HTTP directas o un cliente generado a partir de la referencia de la API

Lo que no hace

  • No funciona en un navegador ni en una aplicación móvil. Necesita Node.js, y tu clave de API no debe salir nunca de tu servidor: cualquiera puede sacar una clave de una página web o de una aplicación. Haz que tu sitio web llame a tu propio backend y que sea el backend quien llame a SpicyAPI.
  • No se ocupa de los modelos de chat. La generación de texto usa endpoints compatibles con OpenAI, y el paquete oficial openai ya lo hace bien. Consulta Texto y streaming.
  • No elige el modelo ni rellena los campos por ti. Cada modelo acepta sus propios campos. El SDK te da la lista de modelos y el esquema de cada uno (su lista de campos aceptados); tú decides qué enviar.
  • No puede cancelar una tarea. Cuando SpicyAPI acepta una tarea, esta se ejecuta hasta el final. Detener tu programa solo detiene tu espera.
  • No gestiona tu cuenta. Añadir saldo, crear claves, los límites de gasto y las claves de firma de webhooks se configuran en la consola.

Tu clave de API se queda en tu servidor

No pongas nunca SPICY_API_KEY en JavaScript del navegador, en una aplicación móvil, en un repositorio público, en una captura de pantalla ni en un mensaje de chat. Una clave filtrada puede gastar tu saldo hasta que la desactives. El SDK lee la clave de una variable de entorno para que nunca tenga que aparecer en tu código.

Palabras que se usan en esta página

PalabraQué significa aquí
ID de modeloEl nombre exacto de un modelo, con la forma publisher/model/task. Cópialo siempre del catálogo en vivo (listModels()), nunca de memoria
EntradaLos ajustes que envías con una tarea, como prompt o image_url. Cada modelo tiene su propia lista de campos permitidos, llamada esquema
TareaUn trabajo de generación. Lo creas, SpicyAPI trabaja en él en segundo plano y tú recoges el resultado. Cada tarea tiene un ID que empieza por job_
RetenciónCuando se acepta una tarea, su precio máximo se aparta de tu saldo. Las tareas fallidas o vencidas normalmente liberan toda la retención. Algunos rechazos de contenido se cobran al precio aceptado, solo si la página del modelo indica expresamente esta excepción. Consulta settled y cost para conocer el importe final.
Clave de idempotenciaUna etiqueta que inventas para un trabajo concreto, como order-8814-image-1. Si envías dos veces la misma solicitud con la misma etiqueta, SpicyAPI te devuelve la primera tarea en lugar de crear y cobrar una segunda
WebhookUna dirección web tuya a la que SpicyAPI llama cuando una tarea termina, para que no tengas que estar preguntando

Qué puedes hacer

Cada función de abajo es un método del cliente (client.listModels()), salvo que se indique lo contrario. «Cuesta dinero» significa que la llamada puede acabar en un cargo a tu saldo.

Qué quieres hacerMétodo¿Cuesta dinero?
Comprobar que SpicyAPI responde (sin clave)getStatusNo
Ver la lista de modelos en vivo, tus precios y los campos de cada modelolistModels, getModelNo
Saber el precio exacto de una solicitud antes de gastar nadaquoteTaskNo
Iniciar una generacióncreateTaskSí
Iniciar una generación y esperar el resultado en una sola llamadarunSí
Consultar una tarea una vezgetTaskNo
Esperar a que una tarea terminewaitForTaskNo
Volver a intentar una tarea fallidaretryTaskSí (una tarea nueva)
Listar las tareas creadas con tu clavelistTasksNo
Consultar tu saldogetBalanceNo
Ver lo que has gastado, por día y por modelogetUsageNo
Subir un archivo local de imagen, video o audiouploadFile, uploadBytes, uploadBase64No
Obtener un enlace de descarga nuevo para un resultadocreateDownloadUrlNo
Borrar los archivos guardados y el prompt de una tarea terminadapurgeTaskNo (y sin reembolso)
Comprobar que un webhook viene de verdad de SpicyAPIverifyWebhook (una función normal)No
Buscar en la documentación o leer el contrato de la API sin conexiónsearchDocumentation, readOpenApiContract (funciones normales)No

Antes de empezar

Necesitas unos diez minutos. Cada paso explica qué hace el comando, para que nada se ejecute a ciegas.

Instala Node.js 22.13 o posterior

Node.js es el programa que ejecuta JavaScript fuera de un navegador. Instala la versión LTS desde nodejs.org; sirve cualquier versión a partir de la 22.13. Después abre una terminal (Terminal en macOS, PowerShell en Windows) y compruébalo:

node -v

Muestra la versión instalada, por ejemplo v22.13.0 o v24.8.0. Si el número es menor que 22.13, o no se encuentra el comando, vuelve a instalar Node.js y abre una ventana de terminal nueva.

Crea una carpeta de proyecto e instala el SDK

Ejecuta estos comandos de uno en uno. Funcionan igual en macOS, Linux y Windows PowerShell.

mkdir spicy-demo
cd spicy-demo
npm init -y
npm pkg set type=module
npm install @spicyapi/sdk
ComandoQué hace
mkdir spicy-demoCrea una carpeta vacía para tu proyecto
cd spicy-demoMueve la terminal a esa carpeta; los comandos siguientes actúan sobre ella
npm init -yCrea package.json, el archivo que enumera los ajustes y las bibliotecas de tu proyecto
npm pkg set type=moduleLe indica a Node.js que tus archivos usan la sintaxis moderna import, que este SDK necesita
npm install @spicyapi/sdkDescarga el SDK en una carpeta node_modules y lo anota en package.json

El SDK solo se publica como módulo ES moderno: import { SpicyClient } from "@spicyapi/sdk" funciona, mientras que el antiguo require("@spicyapi/sdk") falla con ERR_PACKAGE_PATH_NOT_EXPORTED. Si te saltas npm pkg set type=module, pon a tus archivos la extensión .mjs.

Crea una clave de API

  1. Inicia sesión en spicyapi.ai. ¿Todavía no tienes cuenta? Regístrate en spicyapi.ai/register; si el alta de cuentas nuevas está en pausa, esa página ofrece una lista de espera.
  2. Abre la página Claves de API de la consola y elige Crear clave. Solo es obligatorio el nombre.
  3. Copia la clave en ese mismo momento. Empieza por sk-spicy- y solo se muestra una vez. Si la pierdes, crea otra.

Puedes ponerle a la clave un límite de gasto diario en la misma página; las claves nuevas no tienen ninguno a menos que lo añadas. Para ejecutar tareas de pago también necesitas saldo, que se añade en Facturación.

Guarda la clave en una variable de entorno

Una variable de entorno es un valor con nombre que tu terminal entrega a cada programa que inicia. El SDK busca una llamada SPICY_API_KEY, así que la clave nunca tiene que quedar escrita en tu código.

# Configúrala para esta ventana de terminal (pega tu propia clave)
export SPICY_API_KEY="sk-spicy-paste-your-key-here"

# Comprobación: debería mostrar sk-spicy-
echo "${SPICY_API_KEY:0:9}"

El valor desaparece al cerrar la ventana. Para conservarlo en tu propio equipo, añade la línea export a ~/.zshrc (macOS) o a ~/.bashrc (Linux) y abre una ventana nueva.

¿Prefieres un archivo .env? Crea en la carpeta del proyecto un archivo llamado .env con una sola línea, SPICY_API_KEY=sk-spicy-paste-your-key-here, e inicia tu programa con node --env-file=.env yourfile.mjs. El SDK no lee archivos .env por sí mismo; de eso se encarga esa opción. Añade .env a tu archivo .gitignore antes de ejecutar git add por primera vez, para que la clave no acabe nunca en un commit.

En un servidor real, configura SPICY_API_KEY en los ajustes de secretos o de variables de entorno de tu proveedor de alojamiento, o pasa la clave desde tu gestor de secretos con new SpicyClient({ apiKey }).

Ejecuta tu primer archivo

En la carpeta del proyecto, crea con cualquier editor de texto un archivo llamado check.mjs, pega esto y guárdalo:

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);

Ejecútalo:

node check.mjs
SpicyAPI is up
Checked at 2026-09-17T08:00:00.000Z

Esta comprobación no necesita clave y no cuesta nada, así que demuestra que Node.js y el SDK están bien instalados.

¿Usas TypeScript? Guarda el mismo código como check.ts. Las versiones recientes de Node.js 22 y 24 lo ejecutan directamente con node check.ts. Si la tuya se queja de la extensión .ts, ejecuta node --experimental-strip-types check.ts o npx tsx check.ts.

Tu primer resultado

Este programa hace el recorrido completo: comprueba el servicio, elige un modelo de imagen que tu clave puede usar, pide el precio exacto y se detiene. Ejecútalo por segunda vez con --yes y creará la imagen, esperará a que esté lista y la guardará junto al script.

La ejecución con --yes gasta dinero

Sin --yes, el programa solo lee: no se crea nada y no se cobra nada. Con --yes crea una tarea de imagen, que retiene el importe cotizado y lo cobra si la imagen sale bien.

first-result.mjs
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { SpicyApiError, SpicyClient } from "@spicyapi/sdk";

// Lee tu clave de la variable de entorno SPICY_API_KEY.
const client = new SpicyClient();

async function main() {
  // Paso 1: ¿responde SpicyAPI? Gratis y sin clave.
  const status = await client.getStatus();
  if (!status.operational) {
    throw new Error("SpicyAPI is not reachable right now. Try again in a few minutes.");
  }

  // Paso 2: ¿qué modelos de imagen puede llamar esta clave? Gratis.
  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})`);

  // Paso 3: construye la entrada. Parte de un ejemplo que el servidor ya ha
  // validado y pon tu propio prompt si el modelo acepta uno.
  const input = { ...model.examples?.[0]?.input };
  if ("prompt" in input) input.prompt = "A paper lantern glowing on a rainy street at night";

  // Paso 4: pide el precio exacto. Gratis: no se crea ni se retiene nada.
  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;
  }

  // Paso 5: crea la tarea y espera a que termine. Este es el paso que cuesta dinero.
  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}`),
    },
  );

  // Paso 6 — Una tarea fallida es una respuesta normal. Comprueba la liquidación.
  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;
  }

  // Paso 7: descarga el resultado ya. El enlace caduca al cabo de unos 20 minutos.
  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); // nunca envíes aquí tu clave de API
  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;
});

Ejecútalo dos veces:

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>

Qué hace cada paso

  1. getStatus() pregunta al endpoint de estado de SpicyAPI si el servicio está funcionando. Nunca lanza un error por un problema de red; en su lugar, devuelve operational: false.
  2. listModels() devuelve el catálogo de modelos en vivo con los precios de tu cuenta. Usa solo elementos que tengan enabled y available en true. includeExamples: true añade entradas de ejemplo que el servidor ya ha validado, que son el punto de partida más seguro. El valor que envías después es el campo model del elemento: el ID de modelo.
  3. La entrada. Cada modelo acepta campos distintos, y un campo que el modelo no conoce se rechaza con el error 400. Copiar un ejemplo validado y cambiar solo el prompt lo evita. Para ver todos los campos que acepta un modelo, llama a getModel() y lee su inputSchema.
  4. quoteTask() pone precio a esta solicitud exacta. No crea nada y no retiene nada. Los importes son dólares estadounidenses escritos como texto, como "0.04", para que no se cuele ningún redondeo.
  5. run() crea la tarea y después la consulta hasta que termina. Pasar quoteId y expectedCost significa «acéptala solo al precio que me mostraste»: si el precio ha cambiado, la llamada falla con 40901 en lugar de cobrar un importe distinto. idempotencyKey hace que reintentar tras un fallo de red sea seguro; en una aplicación real, guárdala antes de la llamada para que el programa pueda reutilizarla si se reinicia. onAccepted te entrega el ID de la tarea en cuanto la tarea existe: guárdalo, así podrás consultar la tarea más tarde aunque tu programa se detenga.
  6. El estado de la tarea. run() también termina con normalidad cuando la tarea falla. Las tareas fallidas o vencidas normalmente liberan toda la retención. Algunos rechazos de contenido se cobran al precio aceptado, solo si la página del modelo indica expresamente esta excepción. Consulta settled y cost para conocer el importe final.
  7. La descarga. Cada resultado trae una url temporal. Descárgala sin tu clave de API y guarda tu propia copia: el enlace dura unos 20 minutos (vuelve a llamar a getTask() para obtener uno nuevo) y SpicyAPI borra los archivos generados como máximo al cabo de unos 14 días. Consulta Conservación y destrucción.

El video funciona igual, pero puede tardar varios minutos; pasa a run() un timeoutMs más largo (la espera predeterminada es de 10 minutos). En los fragmentos de código siguientes, el ID de modelo aparece como MODEL_ID_FROM_CATALOG: es un marcador, no un modelo real. Sustitúyelo por el valor model de un elemento del catálogo en vivo (live catalog), que obtienes con listModels() en el SDK o con el endpoint autenticado GET /api/v1/models, y que tenga enabled y available en true.

Referencia de la API

Cada entrada explica para qué sirve el método, cómo llamarlo, qué devuelve, si puede costar dinero y qué errores esperar. Las clases de error, como SpicyApiError, se explican en Gestionar errores. Todos los importes son dólares estadounidenses escritos como texto ("0.04") y todas las horas son cadenas ISO 8601 en UTC.

Crear un cliente

new SpicyClient(options?: SpicyClientOptions)

La mayoría de los programas no pasan nada: new SpicyClient() lee la clave de SPICY_API_KEY y se comunica con el servicio de producción. Una opción no válida lanza un TypeError al instante, antes de enviar ninguna solicitud.

Propiedad

Tipo

Reintentos automáticos

El SDK reintenta una solicitud por su cuenta solo cuando hacerlo no puede cobrarte dos veces:

Llamada¿Se reintenta automáticamente?
Lecturas: getStatus, listModels, getModel, getTask, listTasks, getBalance, getUsage; también quoteTask, purgeTask, commitUploadedFileSí
createTask, run (su paso de creación), retryTaskSolo si pasas una idempotencyKey. Sin ella, un error de red te llega directamente, porque volver a enviar la solicitud podría crear una segunda tarea
createUploadUrl, la subida de los bytes propiamente dicha, createDownloadUrlNo

Se reintenta cuando no llega ninguna respuesta (error de red o tiempo de espera local agotado) o cuando la respuesta es HTTP 408, 429, 500, 502, 503 o 504. La pausa es la que pida la cabecera Retry-After del servidor; si no la hay, 250 ms, luego 500 ms, luego 1 s, y así sucesivamente, cada una con una variación de hasta el 25 % y con un tope de maxRetryDelayMs. Si fallan todos los intentos, recibes el último error.

Detener una llamada con signal

Todos los métodos aceptan la opción signal, un AbortSignal estándar. Usa AbortSignal.timeout(5_000) para fijar un plazo, o un AbortController para detenerla cuando quieras. La llamada lanza entonces el motivo de la interrupción. Detenerla solo termina tu solicitud o tu espera: una tarea que SpicyAPI ya ha aceptado sigue ejecutándose y se puede cobrar igualmente.

getStatus()

Comprueba si SpicyAPI responde. Útil como primera línea de un script o como comprobación de estado en tu propia monitorización.

getStatus(options?: { signal?: AbortSignal }): Promise<ServiceStatus>
Campo devueltoSignificado
operationaltrue cuando el servicio responde. Este es el campo que debes usar
healthEl resultado en bruto de la comprobación de estado: path, ok, el status HTTP, los primeros 512 caracteres de body y error cuando no llegó respuesta
readinessLa misma forma, para una segunda comprobación. En el servicio público responde 404, lo cual es normal y no significa que el servicio esté caído
checkedAtCuándo se hizo la comprobación

Cuesta dinero: No. Necesita clave: No. Errores: no lanza errores por problemas de red: recibes operational: false y el motivo en health.error. Solo lanza un error si lo interrumpes con signal. Hay un ejemplo en Ejecuta tu primer archivo.

listModels(options?)

Lista el catálogo de modelos en vivo, con los precios de tu cuenta. De aquí salen todos los ID de modelo.

listModels(options?: ListModelsOptions): Promise<ModelList>

Propiedad

Tipo

Devuelve { total, items }. Los campos más útiles de cada elemento:

CampoSignificado
modelEl ID de modelo. Envía exactamente este valor al crear una tarea
displayNameUn nombre legible
modality, tasksQué produce y qué trabajo hace
provider, familyQuién creó el modelo y a qué línea de productos pertenece
enabled, availableLos dos deben ser true antes de que uses el modelo
pricingUna entrada por cada tramo de precio: price, unit (per_image, per_second, per_request o per_1k_tokens) y variant (qué tramo es, a menudo una resolución). Durante un descuento, regularPrice muestra el precio sin descuento
startingPriceEl tramo más barato. Para una cifra exacta, usa quoteTask
quantityFieldEl campo de entrada que fija la cantidad facturada, como una duración. Si está vacío, cada llamada cuenta como una unidad
taskTimeoutSecondsCuánto puede durar una tarea antes de terminar como expired
maxOutputDurationSecondsLa duración máxima del resultado que admite el modelo, cuando el resultado tiene duración
inputSchema, examplesSolo aparecen si los pediste
updatedAt, versionCambian cada vez que cambian los datos del modelo; úsalos para saber cuándo actualizar un esquema que tengas guardado

Cuesta dinero: No. Necesita clave: Sí. Errores: SpicyApiError (por ejemplo, 401 por un problema con la clave o 429 por demasiadas solicitudes), 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);
}

Las consultas del catálogo utilizan la capacidad de lectura de la cuenta, de forma independiente de la creación de tareas. Guarda la lista y actualízala de vez en cuando, o después de un 400 o un 50301, en lugar de leerla antes de cada tarea.

getModel(modelId)

Devuelve un modelo con su esquema completo: la lista de campos de entrada que acepta, cuáles son obligatorios y qué valores se permiten.

getModel(modelId: string, options?: { signal?: AbortSignal }): Promise<ApiModel>

El resultado tiene todos los campos descritos en listModels e incluye siempre inputSchema y examples. El esquema es JSON Schema estándar: properties enumera cada campo con su type y, a menudo, enum (los valores permitidos), minimum / maximum y default; required enumera los campos que debes enviar.

Cuesta dinero: No. Necesita clave: Sí. Errores: SpicyApiError con 404 cuando ningún modelo tiene ese ID; TypeError si el ID está vacío.

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)

Da el precio exacto de una solicitud sin crear nada. Úsalo para mostrarle un precio a una persona o para rechazar trabajos que superen un presupuesto.

quoteTask(request: { model: string; input: object; callBackUrl?: string }, options?: { signal?: AbortSignal }): Promise<TaskQuote>

Pasa el mismo model, la misma input y, si lo usas, el mismo callBackUrl con los que piensas crear la tarea.

Campo devueltoSignificado
quoteIdPásalo a createTask o a run para fijar este precio
estimatedCostEl importe que se retendrá si creas la tarea ahora
maxChargeLo máximo que puede costar esta solicitud
currencySiempre USD
quantity, unitLa cantidad facturada, como "5" y per_second
expiresAtLa cotización es válida durante cinco minutos

Cuesta dinero: No. Necesita clave: Sí. Errores: SpicyApiError, por ejemplo 400 cuando la entrada no coincide con el esquema del modelo, 50301 cuando el modelo no está disponible, o 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?)

Inicia una generación y vuelve al instante con un ID de tarea. No espera al resultado: después usa waitForTask o un webhook, o bien usa run para hacer las dos cosas en una sola llamada.

createTask(request: CreateTaskInput, options?: CreateTaskOptions): Promise<CreateTaskResult>

La solicitud:

Propiedad

Tipo

Las opciones:

Propiedad

Tipo

Campo devueltoSignificado
taskIdEl ID de la tarea nueva. Guárdalo enseguida
statequeued para una tarea nueva. Si repetiste una clave de idempotencia, el estado actual de la tarea original
estimatedCostEl importe que queda retenido, que también es lo máximo que puede cobrar esta tarea
deadlineAtCuándo deja SpicyAPI de trabajar en la tarea si no ha terminado

Cuesta dinero: Sí. El estimatedCost se retiene de tu saldo. Si la tarea sale bien, se te cobra el uso real, nunca más de lo retenido, y el resto se libera. Las tareas fallidas o vencidas normalmente liberan toda la retención. Algunos rechazos de contenido se cobran al precio aceptado, solo si la página del modelo indica expresamente esta excepción. Consulta settled y cost para conocer el importe final.

Errores:

error.codeQué significaQué hacer
400La entrada no coincide con el esquema del modelo, o el callBackUrl no está permitidoCompárala con getModel().inputSchema y corrige la solicitud
401Falta la clave, tiene un formato incorrecto o está revocadaRevisa SPICY_API_KEY
40201Tu saldo no cubre la retenciónAñade saldo en Facturación, en la consola
40202Se alcanzó un límite de gasto diario, mensual o acumuladoSube el límite en la página Claves de API o espera a que se reinicie
40301Esta clave no tiene permiso para usar este modeloCambia la lista de modelos de la clave en la consola
409Esta clave de idempotencia ya se usó con una solicitud distintaUsa una clave nueva para cada trabajo nuevo
40901La cotización caducó, o cambió el precio o la solicitudVuelve a cotizar y confirma el precio nuevo
413El cuerpo de la solicitud es demasiado grandeSube los archivos multimedia en lugar de incrustarlos en la solicitud
429Demasiadas solicitudesEspera error.retryAfterSeconds y vuelve a intentarlo
50301El modelo no puede aceptar tareas ahora mismoInténtalo más tarde o elige otro modelo

Un SpicyTransportError o un SpicyTimeoutError significa que no llegó ninguna respuesta, así que no puedes saber si la tarea se creó. Vuelve a enviar la misma solicitud con la misma clave de idempotencia: o bien la creas, o bien recibes la tarea que ya existe. Un TypeError significa que una opción no era válida (por ejemplo, un retentionSeconds negativo) y que no se envió nada.

import { randomUUID } from "node:crypto";

const idempotencyKey = randomUUID(); // en una aplicación real, guárdala antes junto con tu pedido
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?)

La forma más sencilla de generar algo: llama a createTask, después a waitForTask y devuelve la tarea terminada.

run(request: CreateTaskInput, options?: RunTaskOptions): Promise<TaskRecord>

request es igual que en createTask. Las opciones son todas las que acepta createTask, y además:

Propiedad

Tipo

Devuelve la tarea terminada, con los campos descritos en getTask. Si la tarea sale bien, además espera a que los archivos del resultado terminen de copiarse, de modo que output.assets[].url esté listo para descargar.

Cuesta dinero: Sí, exactamente igual que createTask. Una tarea fallida se devuelve, no se lanza como error: comprueba task.state.

Errores: todo lo que puede lanzar createTask, y además:

  • SpicyWaitTimeoutError cuando se agota timeoutMs después de que se aceptara la tarea. La tarea sigue ejecutándose en SpicyAPI. Llama más tarde a waitForTask(error.taskId) para recogerla.
  • SpicyTimeoutError cuando el tiempo se agota antes de que SpicyAPI confirmara la tarea. Envía la misma solicitud con la misma clave de idempotencia para averiguar qué pasó.
  • Cualquier error que se lance dentro de tus propios onAccepted u onUpdate detiene la espera; la tarea en sí sigue ejecutándose.
  • TypeError si timeoutMs o intervalMs no son válidos. Se comprueban antes de crear nada.

Hay un ejemplo completo en Tu primer resultado.

getTask(taskId)

Consulta una tarea una vez: su estado, los enlaces a su resultado y lo que costó.

getTask(taskId: string, options?: { signal?: AbortSignal }): Promise<TaskRecord>
CampoSignificado
taskId, modelQué tarea es y con qué modelo se hizo
statequeued o running mientras trabaja; succeeded, failed o expired cuando ha terminado (canceled solo aparece en registros antiguos)
output.assetsLos archivos del resultado, descritos en la tabla siguiente. Solo con succeeded
output.textResultado de texto, en los modelos que devuelven texto
errorCode, errorMessagePor qué falló una tarea. Toma decisiones según errorCode; la redacción del mensaje puede cambiar
costMientras settled es false, el importe retenido; cuando pasa a true, el cargo definitivo
settledSi cost ya es definitivo
createdAt, completedAt, deadlineAtCuándo se creó, cuándo terminó y cuándo caducaría
inputLa entrada tal como se guardó. Se elimina cuando termina el plazo de conservación del prompt
sourceTaskIdPresente en una tarea creada con retryTask; apunta a la original
contentStatepresent; expired (eliminado por las reglas de conservación); o purged (lo borraste con purgeTask)
retentionCuándo se eliminarán los archivos (outputsExpireAt) y el prompt (promptsExpireAt)

Cada elemento de output.assets:

CampoSignificado
urlUn enlace de descarga temporal. Descárgalo sin tu clave de API
expiresAtCuándo deja de funcionar ese enlace, normalmente al cabo de unos 20 minutos. Vuelve a llamar a getTask para obtener uno nuevo
mimeEl tipo de archivo, como image/png o video/mp4
width, height, durationSeconds, bytesDatos de tamaño, cuando corresponden
pendingtrue mientras el archivo todavía se está copiando al almacenamiento. Vuelve a consultar en unos segundos
unavailabletrue cuando el archivo ya no existe. Esperar no servirá de nada; vuelve a generarlo
keyUn nombre de archivo interno, que solo necesita createDownloadUrl

Los campos para los que SpicyAPI no tiene valor se omiten en lugar de ponerse a null, así que trata como opcionales todos los campos anteriores.

Cuesta dinero: No. Errores: SpicyApiError con 404 cuando tu clave no puede ver ninguna tarea con ese ID.

const task = await client.getTask("job_01k3m8x9q2z4v7n5p6r8s0t1w3"); // el ID que guardaste

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?)

Consulta una tarea repetidamente hasta que termina y después la devuelve. Úsalo con una tarea que creaste antes, por ejemplo después de que tu programa se reiniciara.

waitForTask(taskId: string, options?: WaitForTaskOptions): Promise<TaskRecord>

Opciones: timeoutMs (600000 por defecto, hasta 86400000), intervalMs, onUpdate y signal, todas como se describen en run.

Termina cuando state es succeeded, failed, expired o canceled y, si la tarea salió bien, cuando ya no queda ningún archivo del resultado en pending. Cada consulta es una solicitud getTask y cuenta para el límite de solicitudes de tu cuenta; por eso el intervalo predeterminado se va alargando con el tiempo.

Cuesta dinero: No. Errores: SpicyWaitTimeoutError cuando se agota timeoutMs. Incluye taskId, timeoutMs y lastTask (el último estado visto). La tarea no se cancela; puedes simplemente volver a esperar. También SpicyApiError (por ejemplo, 404) y 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?)

Vuelve a ejecutar una tarea fallida como una tarea completamente nueva, con el mismo modelo y la misma entrada. La tarea original se queda exactamente como estaba, y la nueva apunta a ella mediante sourceTaskId.

retryTask(taskId: string, options?: { idempotencyKey?: string; signal?: AbortSignal }): Promise<RetryTaskResult>

Funciona con tareas failed y expired (y con las antiguas canceled). Una tarea que salió bien o que sigue en marcha se rechaza con 409. Se vuelven a comprobar el precio, tu saldo y los permisos de la clave, y la tarea nueva mantiene el ajuste de conservación de la original.

Como la entrada se reutiliza sin cambios, reintentar solo ayuda cuando el fallo fue temporal. Lee primero el errorCode de la tarea original: con rate_limited, upstream_unavailable, timeout y upstream_failed vale la pena un reintento tras una pausa; generation_failed e invalid_asset necesitan una solicitud modificada enviada con createTask; content_rejected, invalid_request y unsupported_combination no se deben reintentar sin cambios. Consulta Códigos de error frecuentes y Errores.

Devuelve taskId (la tarea nueva), sourceTaskId (la original), state, estimatedCost y deadlineAt.

Cuesta dinero: Sí. La tarea nueva tiene su propia retención y se cobra por separado si sale bien. Pasa una idempotencyKey para que, aunque tu código de reintento se ejecute dos veces, solo se cree un reintento.

Errores: 409 (la tarea no se puede reintentar), además de los errores de saldo, de límite y de disponibilidad que se enumeran en createTask. Si la entrada original usaba un archivo subido spicy://, ese archivo solo se puede usar durante un día; súbelo de nuevo y usa createTask en su lugar.

retryTask no sirve para errores de red

Si lo que falló con un error de red fue el propio createTask, no tienes ninguna tarea que reintentar. Vuelve a enviar createTask con la misma clave de idempotencia. Usa retryTask solo con una tarea cuyo state hayas leído como failed o expired.

Hay un ejemplo completo en Reintentar una tarea fallida.

listTasks(options?)

Lista las tareas creadas con esta clave de API, de la más reciente a la más antigua, página a página. Sirve para encontrar trabajos después de un reinicio o de un webhook perdido.

listTasks(options?: ListTasksOptions): Promise<TaskList>

Propiedad

Tipo

Devuelve items, hasMore y, cuando hay más páginas, nextCursor. Cada elemento tiene taskId, model, state, cost, settled, createdAt, deadlineAt y completedAt. No incluye entradas, resultados ni enlaces; llama a getTask para la tarea que necesites.

Cuesta dinero: No. Errores: SpicyApiError (por ejemplo, 400 por un intervalo de fechas no válido), SpicyTransportError.

let cursor;
do {
  const page = await client.listTasks({
    from: "2026-09-01", // mantén las mismas fechas en todas las páginas
    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()

Muestra cuánto dinero tiene tu cuenta.

getBalance(options?: { signal?: AbortSignal }): Promise<Balance>
CampoSignificado
availableDinero con el que se pueden pagar tareas nuevas. Puede ser negativo cuando tu cuenta usa una línea de crédito aprobada
heldDinero apartado para tareas que todavía están en marcha
totalavailable más held
fundingDesglose opcional: fondos prepagados, bonos promocionales (con los modelos a los que se aplican y su caducidad) y cualquier línea de crédito. Las respuestas más antiguas pueden omitirlo

Cuesta dinero: No. No hace falta que compruebes el saldo antes de cada tarea: SpicyAPI lo comprueba cuando creas una y responde 40201 si no alcanza. Todos los campos de funding se explican en Facturación.

const balance = await client.getBalance();
console.log(`Available: $${balance.available} (on hold: $${balance.held})`);

getUsage(options?)

Informa de lo que ha gastado esta clave de API, agrupado por día y por modelo.

getUsage(options?: { from?: string; to?: string; signal?: AbortSignal }): Promise<UsageReport>

from y to son fechas UTC (YYYY-MM-DD); from se incluye y to no. Omite las dos para obtener los últimos siete días, hoy incluido. El intervalo puede abarcar como máximo 92 días.

CampoSignificado
totalCallsNúmero de tareas del intervalo
totalSpendSolo los cargos definitivos; el dinero que sigue retenido no cuenta
daysUna entrada por cada día con llamadas: day, calls, succeeded, failed, spend
modelsUna entrada por modelo: model, calls, succeeded, failed, spend

Cuesta dinero: No. Este informe es más pesado que otras lecturas, así que tiene un límite adicional de 30 solicitudes por minuto; úsalo para informes, no dentro de un bucle. Errores: SpicyApiError (429 por encima de ese límite), 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);

Subir archivos

Cuando un modelo necesita una imagen, un video o un sonido como entrada, puedes poner directamente en el campo de entrada un enlace HTTPS público al archivo. Si el archivo está en tu equipo, súbelo primero. Recibirás una dirección spicy://; ponla en el campo de entrada en lugar del enlace. Subir archivos es gratis.

  • Imágenes (JPEG, PNG, WebP, GIF): hasta 10 MiB.
  • Video (MP4, WebM) y audio (MP3, WAV): hasta 90 MiB y 600 segundos de duración.
  • Un modelo puede fijar un límite más bajo en su esquema.
  • Un archivo subido se puede usar durante un día, y solo desde tu cuenta.

uploadFile(path, options?)

Sube un archivo desde el disco.

uploadFile(path: string, options?: { contentType?: string; signal?: AbortSignal }): Promise<UploadedFile>

El tipo de archivo se detecta por la extensión: .jpg, .jpeg, .png, .webp, .gif, .mp4, .webm, .mp3 o .wav. Con cualquier otro nombre, pasa contentType: image/jpeg, image/png, image/webp, image/gif, video/mp4, video/webm, audio/mpeg o audio/wav.

Campo devueltoSignificado
uriLa dirección spicy:// que pones en la entrada de una tarea
expiresAtCuándo deja de funcionar la dirección (al cabo de un día)
fileId, status, bytes, contentType, sha256Datos de lo que se guardó; status es ready
width, height, durationSecondsDimensiones o duración medidas, cuando corresponden

Cuesta dinero: No. Errores: TypeError cuando no se puede detectar el tipo (su mensaje enumera todas las extensiones que reconoce: contentType is required unless the file extension is one of: gif, jpeg, jpg, png, webp, mp4, webm, mp3, wav); un error ENOENT de Node.js cuando la ruta es incorrecta; SpicyUploadError (con status) cuando el almacenamiento rechazó los bytes o el archivo supera el tamaño permitido (413); SpicyApiError cuando SpicyAPI rechazó la subida; SpicyTimeoutError cuando el envío tardó más que uploadTimeoutMs.

const file = await client.uploadFile("./photo.jpg");
console.log(file.uri, "can be used until", file.expiresAt);

uploadBytes(data, options)

Sube bytes que ya tienes en memoria, como un archivo que un usuario envió a tu servidor. Sirve un Buffer de Node.js.

uploadBytes(data: Uint8Array, options: { contentType: string; signal?: AbortSignal }): Promise<UploadedFile>

contentType es obligatorio. El resultado y los errores son los mismos que en 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?)

Sube un archivo que tienes como texto Base64: o bien un data URI como data:image/png;base64,iVBOR…, o bien Base64 sin más junto con contentType.

uploadBase64(value: string, options?: { contentType?: string; signal?: AbortSignal }): Promise<UploadedFile>

La codificación y el tamaño se comprueban primero en tu máquina; los datos no válidos o demasiado grandes lanzan un TypeError sin subir nada. El resultado es el mismo que en uploadFile.

createUploadUrl y commitUploadedFile

Los tres métodos anteriores se construyen con estas dos llamadas de más bajo nivel. Solo las necesitas si los bytes se envían desde algún sitio distinto de tu proceso de Node.js.

createUploadUrl(contentType: string, bytes: number, options?: { signal?: AbortSignal }): Promise<UploadTicket>
commitUploadedFile(fileId: string, options?: { signal?: AbortSignal }): Promise<UploadedFile>
  1. createUploadUrl devuelve fileId, uploadUrl, method (PUT), headers, maxBytes y expiresAt.
  2. Envía el archivo con un PUT a uploadUrl, con exactamente las headers indicadas y sin cabecera Authorization. El tamaño declarado debe coincidir con el del archivo.
  3. commitUploadedFile(fileId) comprueba el archivo guardado y devuelve la uri spicy://. Un archivo que se subió pero nunca se confirmó no se puede usar.

El flujo completo, incluidas las subidas desde el navegador, se describe en Carga y descarga de medios.

createDownloadUrl(taskId, key?)

Crea un enlace de descarga temporal nuevo para un archivo del resultado. Rara vez lo necesitarás: getTask, waitForTask, run y los webhooks ya incluyen una url lista para cada resultado.

createDownloadUrl(taskId: string, key?: string, options?: { signal?: AbortSignal }): Promise<DownloadTicket>

Pasa la key del archivo para elegirlo, u omítela para obtener el primero. Devuelve key, url y expiresAt (unos 20 minutos).

Cuesta dinero: No. Errores: SpicyApiError con 409 mientras el archivo todavía se está copiando (espera unos segundos), o con 404 cuando la tarea no ha salido bien o esa key no pertenece a la tarea.

purgeTask(taskId)

Borra para siempre los archivos generados, el resultado (los datos guardados del resultado), el prompt y el resto del texto de entrada de una tarea terminada, antes de lo que lo haría el plazo de conservación. Úsalo cuando un resultado no deba conservarse.

purgeTask(taskId: string, options?: { signal?: AbortSignal }): Promise<PurgeTaskResult>
Campo devueltoSignificado
contentStatepurged cuando el contenido está borrado. present significa que esta vez no se eliminó nada; vuelve a intentarlo más tarde
purgedAtCuándo se borró el contenido por primera vez
contentRemovedByuser cuando lo borraste tú
billingRetainedSiempre true: se conserva el registro de lo que costó la tarea
mediaDeletionPendingtrue mientras los archivos guardados se siguen eliminando en segundo plano, algo que normalmente dura menos de un minuto

Cuesta dinero: No, y no es un reembolso. Se conservan el movimiento contable, el importe cobrado, el modelo, el estado, las marcas de tiempo y el ID de solicitud. El borrado no se puede deshacer. Volver a llamarlo con la misma tarea es seguro. Errores: SpicyApiError con 400 cuando la tarea sigue en queued o running, y con 404 cuando no existe. Una tarea aceptada no se puede cancelar y no existe ninguna API de cancelación, así que espera a que termine con waitForTask y después púrgala. Consulta Conservación y destrucción.

const result = await client.purgeTask("job_01k3m8x9q2z4v7n5p6r8s0t1w3");
console.log(result.contentState, "billing kept:", result.billingRetained);

Funciones de webhook

verifyWebhook y computeWebhookSignature son funciones normales, no métodos del cliente. Se describen con un ejemplo que funciona en Webhooks.

Utilidades sin conexión y tipos

Funcionan sin conexión a la red y sin clave.

ExportQué te da
searchDocumentation(query, limit)Busca en un pequeño índice integrado de este sitio de documentación. Devuelve hasta limit entradas (de 1 a 25; 10 por defecto), cada una con slug, title, summary, keywords y url, donde url tiene la forma https://docs.spicyapi.ai/docs/<slug>. Si no hay coincidencias, devuelve un array vacío; con una query vacía devuelve las primeras entradas
DOCUMENTATIONTodo ese índice integrado como array, con una entrada por página de documentación
readOpenApiContract()El contrato OpenAPI 3.1 incluido en esta versión del SDK, como texto YAML
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_BYTESLos valores predeterminados que se describen en esta página. Las respuestas de la API de más de MAX_API_RESPONSE_BYTES (4 MiB) se rechazan

También se exportan tipos de TypeScript para cada solicitud y cada respuesta, por ejemplo import type { TaskRecord, ApiModel, Balance } from "@spicyapi/sdk".

Ruta de importaciónContiene
@spicyapi/sdkTodo lo que aparece en esta página
@spicyapi/sdk/webhooksSolo verifyWebhook, computeWebhookSignature y SpicyWebhookVerificationError: una importación pequeña para un receptor de webhooks
@spicyapi/sdk/openapiSolo los tipos OpenAPI en bruto paths, components y operations

Webhooks

Un webhook permite que SpicyAPI te avise cuando una tarea termina, en lugar de que tu programa pregunte cada pocos segundos. Das a cada tarea un callBackUrl, una dirección en tu propio servidor, y SpicyAPI envía allí el registro de la tarea terminada.

Por qué debes verificar cada webhook

Tu dirección de webhook es una URL pública: cualquiera que la adivine puede enviarle un mensaje falso que diga «la tarea salió bien, aquí está el archivo». Si tu código se lo cree, podría entregar a un cliente el archivo equivocado o dar un pedido por terminado.

Cada entrega real va firmada con una clave de firma secreta que nadie más que tú y SpicyAPI conoce. verifyWebhook vuelve a calcular esa firma a partir de los bytes exactos que recibiste y compara las dos. También comprueba la hora de la entrega, para que un atacante no pueda capturar un mensaje auténtico y reenviártelo horas después. Actúa sobre un mensaje solo después de que supere la verificación.

Configúralo

Consigue tu clave de firma

Al registrarte se crea automáticamente una clave de firma, pero su valor no se muestra nunca, así que necesitas crear una que puedas copiar. En la consola, abre Webhooks y elige Generar (o Rotar si la tarjeta indica que ya hay una clave configurada). La clave nueva se muestra una sola vez: cópiala y guárdala en tu servidor como SPICY_WEBHOOK_SECRET. Si la pierdes, vuelve a rotarla.

Rotar no deja periodo de transición: la clave antigua deja de firmar al instante, e incluso las entregas que todavía se están reintentando se firman con la nueva. Actualiza todos los receptores en marcha justo después de rotar. Los detalles están en Webhooks.

Ejecuta un receptor en una dirección HTTPS pública

SpicyAPI solo puede llamar a una dirección accesible desde internet que empiece por https://. Las direcciones http:// sin cifrar, localhost y las de redes privadas se rechazan. Mientras desarrollas, expón tu servidor local a través de un servicio de túnel que le dé una dirección HTTPS pública, o usa el sondeo hasta que lo despliegues. Las reglas completas sobre la dirección de callback están en Webhooks.

Envía callBackUrl con tus tareas

Añade callBackUrl: "https://your-app.example.com/hooks/spicy" a la solicitud que pasas a createTask. Consulta Recibir resultados por webhook.

Un receptor mínimo

Este receptor solo usa el módulo http integrado en Node.js, así que no hay nada más que instalar. Guárdalo como receiver.mjs e inícialo con 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");

// ID de las entregas que ya has procesado. En producción, usa una tabla de base de datos
// para que la lista sobreviva a un reinicio.
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. Lee el cuerpo como bytes en bruto. No lo interpretes antes de verificarlo.
  const chunks = [];
  for await (const chunk of request) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks);

  // 2. Comprueba la firma y la marca de tiempo.
  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. Responde rápido. SpicyAPI espera como máximo 15 segundos y después reintenta.
  response.writeHead(204).end();

  // 4. La misma entrega puede llegar más de una vez. Procesa cada una solo una vez.
  if (!event.deliveryId || handled.has(event.deliveryId)) return;
  handled.add(event.deliveryId);

  if (!("data" in event.payload)) return; // en la versión 2, la tarea va dentro de data
  const task = event.payload.data; // los mismos campos que devuelve getTask()
  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"));

Lo que importa de este código:

  • Bytes en bruto. La firma cubre los bytes exactos que envió SpicyAPI. Si un framework interpreta primero el JSON y tú lo vuelves a convertir en texto, los bytes cambian y todas las comprobaciones fallan. En Express, usa express.raw({ type: "application/json" }) en esta ruta.
  • Responde rápido. Cualquier respuesta 2xx significa «recibido». Si no respondes en 15 segundos, o respondes con cualquier otro estado, SpicyAPI reintenta varias veces a lo largo de las horas siguientes. Haz el trabajo lento después de responder.
  • Llegan duplicados. Los reintentos y los cortes de red pueden entregar el mismo mensaje dos veces. event.deliveryId no cambia entre reintentos, así que recuerda los ID que ya has procesado.
  • El éxito está en state. Una entrega de una tarea fallida sigue siendo una entrega válida. Lee task.state, nunca el code de la envoltura.
  • En producción, guarda primero el evento verificado en tu base de datos, responde 2xx solo después de que se haya guardado bien e ignora los ID de tarea que tu aplicación no creó. La guía de Webhooks tiene una versión con base de datos.

Prueba tu receptor antes de ponerlo en producción

computeWebhookSignature genera una firma real, así que puedes enviarte una entrega de prueba firmada. Con receiver.mjs en marcha en una terminal y el mismo SPICY_WEBHOOK_SECRET configurado en otra, ejecuta:

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); // se espera 204

Ejecútalo dos veces: el receptor muestra la tarea una sola vez, porque la segunda entrega tiene el mismo request_id. Cambia un carácter de body después de firmar y el receptor responderá 401.

verifyWebhook(options)

verifyWebhook(options: VerifyWebhookOptions): VerifiedWebhook

Propiedad

Tipo

Devuelve payload (el cuerpo ya interpretado; en la versión 2, la tarea está en payload.data), payloadVersion, taskId, deliveryId (el request_id de la versión 2, idéntico en todos los reintentos) y timestamp. La función es síncrona: no hace falta await.

Si la verificación falla, lanza SpicyWebhookVerificationError, cuyo reason es uno de estos valores: invalid_signature, stale_timestamp, invalid_timestamp, invalid_json, invalid_payload, body_too_large o invalid_secret. Responde 401 y no actúes sobre el mensaje.

computeWebhookSignature(taskId, timestamp, rawBody, secret) devuelve la firma en Base64 de un cuerpo. Solo la necesitas para pruebas como la de arriba. El algoritmo de firma completo, para otros lenguajes, está en Webhooks.

Consejo para TypeScript: payload tiene como tipo cualquiera de los dos formatos. Acótalo con "data" in event.payload, como hace el receptor, o pasa el tipo esperado: verifyWebhook<components["schemas"]["TaskRecordEnvelope"]>(…) con import type { components } from "@spicyapi/sdk".

Gestionar errores

Las cosas pueden salir mal de dos formas distintas, y cada una se trata de manera diferente:

  1. Falla una llamada. El SDK lanza un error: tu clave era incorrecta, tu saldo no alcanzaba, se cortó la red. Captúralo con try / catch.
  2. Falla una tarea. La llamada funcionó, pero la generación no. No se lanza nada: la tarea devuelta tiene state: "failed" y un errorCode. Las tareas fallidas o vencidas normalmente liberan toda la retención. Algunos rechazos de contenido se cobran al precio aceptado, solo si la página del modelo indica expresamente esta excepción. Consulta settled y cost para conocer el importe final.

Las clases de error

ClaseCuándo apareceCampos útiles
SpicyApiErrorSpicyAPI respondió con un error, como una clave rechazada o un saldo insuficientecode (el código de negocio en el que debes basarte, como 40201), status (estado HTTP), message, requestId, retryAfterSeconds, rateLimitLimit, rateLimitRemaining
SpicyTransportErrorNo llegó ninguna respuesta utilizable: fallo de red, problema de DNS o una respuesta de más de 4 MiBmessage, cause (el error de fondo)
SpicyTimeoutErrorUna sola solicitud tardó más que timeoutMs (o uploadTimeoutMs). Es un tipo de SpicyTransportErrortimeoutMs
SpicyWaitTimeoutErrorA run o a waitForTask se les agotó el tiempo. La tarea sigue ejecutándosetaskId, timeoutMs, lastTask
SpicyUploadErrorEl almacenamiento rechazó un archivo subido, o el archivo era demasiado grandestatus
SpicyWebhookVerificationErrorUn webhook no superó la verificaciónreason
TypeErrorLa propia llamada no era válida: no hay clave configurada, una opción está fuera de rango, un ID está vacío. No se envió nadamessage

Decide qué hacer según code, nunca según message: los mensajes son para personas, su redacción puede cambiar y siguen el ajuste de idioma de los errores de tu cuenta. Incluye requestId siempre que contactes con soporte; nos permite encontrar la solicitud exacta.

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"; // un ID real de listModels()
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");

const idempotencyKey = randomUUID(); // guárdala: necesitas la misma para volver a intentarlo

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) {
    // Terminó tu espera, no la tarea. Consúltala más tarde con el mismo 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) {
    // Fallo de red o tiempo de espera local agotado: puede que la tarea exista o no.
    // Vuelve a ejecutar la misma solicitud con la MISMA idempotencyKey para averiguarlo sin riesgo.
    console.log(`Network problem (${error.message}). Retry with key ${idempotencyKey}.`);
  } else {
    throw error; // un TypeError aquí es un fallo de la propia llamada, como una clave que falta
  }
}

Códigos de error frecuentes

error.codeSignificado¿Vale la pena reintentar?
400Solicitud no válida, normalmente por un campo de entrada que el modelo no aceptaNo. Corrige la solicitud
401Clave ausente, con formato incorrecto, revocada o caducadaNo. Revisa la clave
40201Saldo insuficiente para la retenciónNo. Añade saldo primero
40202Se alcanzó un límite de gastoNo. Sube el límite o espera a que se reinicie el diario o el mensual
40301Esta clave no puede usar este modeloNo. Cambia la lista de modelos de la clave
404No existe ese modelo o esa tarea para esta claveNo
409Entra en conflicto con el estado actual, por ejemplo una clave de idempotencia reutilizada para otro trabajoDepende; consulta Errores
40901La cotización caducó o cambió el precioVuelve a cotizar y después confirma
429Demasiadas solicitudesSí, después de retryAfterSeconds. El SDK ya reintenta las lecturas por ti
500Algo se rompió en el lado de SpicyAPISí, tras una pausa
50301El modelo no está disponible ahora mismoSí, más tarde, o elige otro modelo

Cuando una tarea falla, task.errorCode es uno de estos valores: invalid_request, unsupported_combination, content_rejected, rate_limited, upstream_unavailable, generation_failed, timeout, invalid_asset o upstream_failed. retryTask vuelve a enviar la entrada original sin cambios, así que elige el siguiente paso según el código:

task.errorCodeQué hacer
rate_limited, upstream_unavailable, timeout, upstream_failedEspera y usa retryTask una vez. Si el timeout se repite, prueba con una duración más corta o una resolución más baja mediante createTask
generation_failedCambia el prompt o la imagen y envía una tarea nueva con createTask; la misma entrada suele fallar de la misma manera
invalid_assetVuelve a subir el archivo y envía una tarea nueva con createTask usando la uri nueva; un reintento reutilizaría el archivo antiguo
invalid_request, unsupported_combinationNo reintentes sin cambios. Cambia los ajustes y usa createTask
content_rejectedNo reintentes sin cambios. El modelo elegido rechazó la solicitud según su propia política; cambia el prompt o elige otro modelo

La tabla completa está en Errores.

Guarda tus resultados

  • La url de un resultado funciona durante unos 20 minutos. Vuelve a llamar a getTask para obtener una nueva.
  • Los archivos generados se borran como máximo al cabo de unos 14 días, o antes si así lo indican tu cuenta o retentionSeconds. Los archivos de entrada subidos duran un día, y los prompts guardados, 30 días.
  • En cuanto una tarea salga bien, descarga en tu propio almacenamiento lo que quieras conservar. No guardes en tu base de datos la url temporal. Detalles: Conservación y destrucción.

Recetas habituales

Las recetas usan MODEL_ID_FROM_CATALOG como marcador. Antes de ejecutar una, sustitúyelo por el valor model de un elemento adecuado del catálogo en vivo (listModels() o GET /api/v1/models) que tenga enabled y available en true. Si se te olvida, cada receta se detiene con un mensaje claro.

De imagen a video: sube primero una imagen local

image-to-video.mjs
import { randomUUID } from "node:crypto";
import { SpicyClient } from "@spicyapi/sdk";

const client = new SpicyClient();

// Sustitúyelo por un ID de modelo image-to-video del catálogo en vivo:
// 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. Sube la imagen local. Gratis. La dirección spicy:// sirve durante un día.
const file = await client.uploadFile("./photo.jpg");
console.log("Uploaded:", file.uri);

// 2. Muestra el esquema del modelo: qué campo recibe la imagen y qué campos son obligatorios.
const { inputSchema } = await client.getModel(MODEL_ID);
console.log(JSON.stringify(inputSchema, null, 2));

// 3. Crea el video y espera a que termine. Un video suele tardar varios minutos.
const task = await client.run(
  {
    model: MODEL_ID,
    input: {
      prompt: "Slow push-in, light rain, neon reflections on wet stone",
      image_url: file.uri, // usa el nombre de campo que muestra el esquema de arriba
    },
  },
  {
    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);

El esquema que se muestra es la referencia para los nombres de los campos y para saber cuáles son obligatorios; un modelo de video también puede exigir una duración o una resolución, y el precio suele depender de ellas. Si tu imagen ya está en internet en una dirección HTTPS pública, sáltate la subida y pon esa dirección directamente en el campo de la imagen. Si quieres ver el precio, ejecuta antes quoteTask con la misma solicitud.

Ejecutar un lote con concurrencia limitada

Iniciar cientos de tareas a la vez puede chocar con tus límites de gasto y con tu límite de solicitudes. Este script ejecuta una lista de prompts con un máximo de dos tareas en curso al mismo tiempo.

batch.mjs
import { SpicyApiError, SpicyClient } from "@spicyapi/sdk";

const client = new SpicyClient();

const MODEL_ID = "MODEL_ID_FROM_CATALOG"; // un ID real de listModels()
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");

// Cambia el nombre del lote cada vez que cambies los 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; // cuántas tareas pueden ejecutarse a la vez

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] } },
        {
          // Mismo nombre de lote + misma posición = misma clave. Si el script se cae y lo
          // vuelves a ejecutar en menos de 24 horas, recuperas las tareas que ya existen
          // en lugar de pagarlas dos veces.
          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);
      // Sin saldo (40201) o por encima de un límite de gasto (40202): deja de iniciar tareas nuevas.
      if (error instanceof SpicyApiError && (error.code === 40201 || error.code === 40202)) {
        nextIndex = prompts.length;
      }
    }
  }
}

await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
console.table(results);
  • Por qué las claves se construyen con el nombre del lote. Una clave de idempotencia ligada a «este lote, elemento 3» hace que, si vuelves a ejecutar el script después de un fallo, se reutilicen las tareas ya creadas. Una clave aleatoria en cada ejecución pagaría otra vez la misma imagen.
  • Por qué el nombre del lote debe cambiar con los prompts. Reutilizar una clave para una solicitud distinta se rechaza con 409.
  • Cómo elegir CONCURRENCY. Cada tarea en curso retiene parte de tu saldo, y cada consulta de estado cuenta para el límite de solicitudes de tu cuenta. Empieza con un valor bajo y súbelo cuando el lote funcione sin problemas.

Recibir resultados por webhook en lugar de sondear

En una aplicación web o en un proceso en segundo plano, quedarse esperando dentro de una solicitud es un desperdicio. Crea la tarea con un callBackUrl, guarda el ID de la tarea y deja que el receptor procese el resultado cuando llegue.

webhook-create.mjs
import { randomUUID } from "node:crypto";
import { SpicyClient } from "@spicyapi/sdk";

const client = new SpicyClient();
const MODEL_ID = "MODEL_ID_FROM_CATALOG"; // un ID real de listModels()
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");

// En una aplicación real, guarda esta clave con el pedido del cliente antes de llamar.
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.`);
// Guarda accepted.taskId ahora. Cuando la tarea termine, SpicyAPI llamará a tu receptor.

Ten una red de seguridad para las entregas que nunca llegan, por ejemplo porque tu servidor estuvo caído durante todos los reintentos: de vez en cuando, llama a getTask para las tareas guardadas que ya hayan superado con creces su deadlineAt sin recibir webhook, o recorre las páginas de listTasks. El historial de entregas de webhooks, con un botón para reenviarlas a mano, está en la página Webhooks de la consola.

Reintentar una tarea fallida

retry.mjs
import { SpicyClient } from "@spicyapi/sdk";

const client = new SpicyClient();

const taskId = process.argv[2]; // ejecútalo así: node retry.mjs job_...
if (!taskId) throw new Error("Usage: node retry.mjs <taskId>");

// Un reintento vuelve a enviar la entrada original sin cambios, así que solo compensa con fallos temporales.
// Con cualquier otro código, cambia la solicitud y usa createTask.
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 {
  // Una clave fija por intento: ejecutar este script dos veces crea un reintento, no dos.
  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);
}

Solo reintenta los fallos que pueden salir de otra manera la próxima vez (un servicio ocupado o no disponible temporalmente, un tiempo de espera agotado o un error desconocido), y solo una vez, con una clave de idempotencia fija. Con invalid_asset, vuelve a subir el archivo y crea una tarea nueva, porque el reintento reutilizaría la subida caducada. Con generation_failed, cambia el prompt o la imagen y crea una tarea nueva. Con content_rejected, invalid_request y unsupported_combination, cambia la solicitud en lugar de reintentarla. Si ese único reintento también falla, detente y examina el error; si timeout se repite, prueba con una duración más corta o una resolución más baja mediante createTask. Consulta Errores.

Solución de problemas y preguntas frecuentes

Código fuente

El paquete tiene licencia MIT y se desarrolla en abierto en github.com/spicyapi-ai/spicy-sdk. Ahí puedes leer el código detrás de cualquier método de esta página, avisar de un comportamiento que no coincida con lo descrito o proponer un cambio.

Siguientes pasos

En esta página