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 TypeScript | Este SDK |
| Generar cosas desde una terminal, sin escribir código | La CLI |
| Que un asistente de IA como Claude Code o Cursor llame a SpicyAPI por ti | El servidor MCP y el Agent Skill |
| Usar un modelo de chat o de texto | El cliente oficial de OpenAI apuntando a SpicyAPI; consulta Texto y streaming |
| Usar Python, Go, PHP o Java | El SDK de Python, el SDK de Go, el SDK de PHP o el SDK de Java oficiales |
| Usar cualquier otro lenguaje | Llamadas 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
openaiya 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
| Palabra | Qué significa aquí |
|---|---|
| ID de modelo | El nombre exacto de un modelo, con la forma publisher/model/task. Cópialo siempre del catálogo en vivo (listModels()), nunca de memoria |
| Entrada | Los ajustes que envías con una tarea, como prompt o image_url. Cada modelo tiene su propia lista de campos permitidos, llamada esquema |
| Tarea | Un 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ón | Cuando 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 idempotencia | Una 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 |
| Webhook | Una 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 hacer | Método | ¿Cuesta dinero? |
|---|---|---|
| Comprobar que SpicyAPI responde (sin clave) | getStatus | No |
| Ver la lista de modelos en vivo, tus precios y los campos de cada modelo | listModels, getModel | No |
| Saber el precio exacto de una solicitud antes de gastar nada | quoteTask | No |
| Iniciar una generación | createTask | Sí |
| Iniciar una generación y esperar el resultado en una sola llamada | run | Sí |
| Consultar una tarea una vez | getTask | No |
| Esperar a que una tarea termine | waitForTask | No |
| Volver a intentar una tarea fallida | retryTask | Sí (una tarea nueva) |
| Listar las tareas creadas con tu clave | listTasks | No |
| Consultar tu saldo | getBalance | No |
| Ver lo que has gastado, por día y por modelo | getUsage | No |
| Subir un archivo local de imagen, video o audio | uploadFile, uploadBytes, uploadBase64 | No |
| Obtener un enlace de descarga nuevo para un resultado | createDownloadUrl | No |
| Borrar los archivos guardados y el prompt de una tarea terminada | purgeTask | No (y sin reembolso) |
| Comprobar que un webhook viene de verdad de SpicyAPI | verifyWebhook (una función normal) | No |
| Buscar en la documentación o leer el contrato de la API sin conexión | searchDocumentation, 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 -vMuestra 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| Comando | Qué hace |
|---|---|
mkdir spicy-demo | Crea una carpeta vacía para tu proyecto |
cd spicy-demo | Mueve la terminal a esa carpeta; los comandos siguientes actúan sobre ella |
npm init -y | Crea package.json, el archivo que enumera los ajustes y las bibliotecas de tu proyecto |
npm pkg set type=module | Le indica a Node.js que tus archivos usan la sintaxis moderna import, que este SDK necesita |
npm install @spicyapi/sdk | Descarga 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
- 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.
- Abre la página Claves de API de la consola y elige Crear clave. Solo es obligatorio el nombre.
- 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:
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.mjsSpicyAPI is up
Checked at 2026-09-17T08:00:00.000ZEsta 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.
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 --yesModel: <display name> (<model ID>)
Price: $<amount> USD
Nothing was charged. Run again with --yes to generate the image.
Model: <display name> (<model ID>)
Price: $<amount> USD
Task accepted: job_…
State: running
State: succeeded
Saved result.png. Charged: $<amount>Qué hace cada paso
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, devuelveoperational: false.listModels()devuelve el catálogo de modelos en vivo con los precios de tu cuenta. Usa solo elementos que tenganenabledyavailableentrue.includeExamples: trueañ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 campomodeldel elemento: el ID de modelo.- 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 agetModel()y lee suinputSchema. 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.run()crea la tarea y después la consulta hasta que termina. PasarquoteIdyexpectedCostsignifica «acéptala solo al precio que me mostraste»: si el precio ha cambiado, la llamada falla con40901en lugar de cobrar un importe distinto.idempotencyKeyhace 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.onAcceptedte 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.- 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. Consultasettledycostpara conocer el importe final. - La descarga. Cada resultado trae una
urltemporal. Descárgala sin tu clave de API y guarda tu propia copia: el enlace dura unos 20 minutos (vuelve a llamar agetTask()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, commitUploadedFile | Sí |
createTask, run (su paso de creación), retryTask | Solo 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, createDownloadUrl | No |
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 devuelto | Significado |
|---|---|
operational | true cuando el servicio responde. Este es el campo que debes usar |
health | El 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 |
readiness | La 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 |
checkedAt | Cuá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:
| Campo | Significado |
|---|---|
model | El ID de modelo. Envía exactamente este valor al crear una tarea |
displayName | Un nombre legible |
modality, tasks | Qué produce y qué trabajo hace |
provider, family | Quién creó el modelo y a qué línea de productos pertenece |
enabled, available | Los dos deben ser true antes de que uses el modelo |
pricing | Una 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 |
startingPrice | El tramo más barato. Para una cifra exacta, usa quoteTask |
quantityField | El campo de entrada que fija la cantidad facturada, como una duración. Si está vacío, cada llamada cuenta como una unidad |
taskTimeoutSeconds | Cuánto puede durar una tarea antes de terminar como expired |
maxOutputDurationSeconds | La duración máxima del resultado que admite el modelo, cuando el resultado tiene duración |
inputSchema, examples | Solo aparecen si los pediste |
updatedAt, version | Cambian 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 devuelto | Significado |
|---|---|
quoteId | Pásalo a createTask o a run para fijar este precio |
estimatedCost | El importe que se retendrá si creas la tarea ahora |
maxCharge | Lo máximo que puede costar esta solicitud |
currency | Siempre USD |
quantity, unit | La cantidad facturada, como "5" y per_second |
expiresAt | La 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 devuelto | Significado |
|---|---|
taskId | El ID de la tarea nueva. Guárdalo enseguida |
state | queued para una tarea nueva. Si repetiste una clave de idempotencia, el estado actual de la tarea original |
estimatedCost | El importe que queda retenido, que también es lo máximo que puede cobrar esta tarea |
deadlineAt | Cuá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.code | Qué significa | Qué hacer |
|---|---|---|
400 | La entrada no coincide con el esquema del modelo, o el callBackUrl no está permitido | Compárala con getModel().inputSchema y corrige la solicitud |
401 | Falta la clave, tiene un formato incorrecto o está revocada | Revisa SPICY_API_KEY |
40201 | Tu saldo no cubre la retención | Añade saldo en Facturación, en la consola |
40202 | Se alcanzó un límite de gasto diario, mensual o acumulado | Sube el límite en la página Claves de API o espera a que se reinicie |
40301 | Esta clave no tiene permiso para usar este modelo | Cambia la lista de modelos de la clave en la consola |
409 | Esta clave de idempotencia ya se usó con una solicitud distinta | Usa una clave nueva para cada trabajo nuevo |
40901 | La cotización caducó, o cambió el precio o la solicitud | Vuelve a cotizar y confirma el precio nuevo |
413 | El cuerpo de la solicitud es demasiado grande | Sube los archivos multimedia en lugar de incrustarlos en la solicitud |
429 | Demasiadas solicitudes | Espera error.retryAfterSeconds y vuelve a intentarlo |
50301 | El modelo no puede aceptar tareas ahora mismo | Inté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:
SpicyWaitTimeoutErrorcuando se agotatimeoutMsdespués de que se aceptara la tarea. La tarea sigue ejecutándose en SpicyAPI. Llama más tarde awaitForTask(error.taskId)para recogerla.SpicyTimeoutErrorcuando 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
onAccepteduonUpdatedetiene la espera; la tarea en sí sigue ejecutándose. TypeErrorsitimeoutMsointervalMsno 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>| Campo | Significado |
|---|---|
taskId, model | Qué tarea es y con qué modelo se hizo |
state | queued o running mientras trabaja; succeeded, failed o expired cuando ha terminado (canceled solo aparece en registros antiguos) |
output.assets | Los archivos del resultado, descritos en la tabla siguiente. Solo con succeeded |
output.text | Resultado de texto, en los modelos que devuelven texto |
errorCode, errorMessage | Por qué falló una tarea. Toma decisiones según errorCode; la redacción del mensaje puede cambiar |
cost | Mientras settled es false, el importe retenido; cuando pasa a true, el cargo definitivo |
settled | Si cost ya es definitivo |
createdAt, completedAt, deadlineAt | Cuándo se creó, cuándo terminó y cuándo caducaría |
input | La entrada tal como se guardó. Se elimina cuando termina el plazo de conservación del prompt |
sourceTaskId | Presente en una tarea creada con retryTask; apunta a la original |
contentState | present; expired (eliminado por las reglas de conservación); o purged (lo borraste con purgeTask) |
retention | Cuándo se eliminarán los archivos (outputsExpireAt) y el prompt (promptsExpireAt) |
Cada elemento de output.assets:
| Campo | Significado |
|---|---|
url | Un enlace de descarga temporal. Descárgalo sin tu clave de API |
expiresAt | Cuándo deja de funcionar ese enlace, normalmente al cabo de unos 20 minutos. Vuelve a llamar a getTask para obtener uno nuevo |
mime | El tipo de archivo, como image/png o video/mp4 |
width, height, durationSeconds, bytes | Datos de tamaño, cuando corresponden |
pending | true mientras el archivo todavía se está copiando al almacenamiento. Vuelve a consultar en unos segundos |
unavailable | true cuando el archivo ya no existe. Esperar no servirá de nada; vuelve a generarlo |
key | Un 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>| Campo | Significado |
|---|---|
available | Dinero con el que se pueden pagar tareas nuevas. Puede ser negativo cuando tu cuenta usa una línea de crédito aprobada |
held | Dinero apartado para tareas que todavía están en marcha |
total | available más held |
funding | Desglose 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.
| Campo | Significado |
|---|---|
totalCalls | Número de tareas del intervalo |
totalSpend | Solo los cargos definitivos; el dinero que sigue retenido no cuenta |
days | Una entrada por cada día con llamadas: day, calls, succeeded, failed, spend |
models | Una 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 devuelto | Significado |
|---|---|
uri | La dirección spicy:// que pones en la entrada de una tarea |
expiresAt | Cuándo deja de funcionar la dirección (al cabo de un día) |
fileId, status, bytes, contentType, sha256 | Datos de lo que se guardó; status es ready |
width, height, durationSeconds | Dimensiones 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>createUploadUrldevuelvefileId,uploadUrl,method(PUT),headers,maxBytesyexpiresAt.- Envía el archivo con un
PUTauploadUrl, con exactamente lasheadersindicadas y sin cabeceraAuthorization. El tamaño declarado debe coincidir con el del archivo. commitUploadedFile(fileId)comprueba el archivo guardado y devuelve laurispicy://. 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 devuelto | Significado |
|---|---|
contentState | purged cuando el contenido está borrado. present significa que esta vez no se eliminó nada; vuelve a intentarlo más tarde |
purgedAt | Cuándo se borró el contenido por primera vez |
contentRemovedBy | user cuando lo borraste tú |
billingRetained | Siempre true: se conserva el registro de lo que costó la tarea |
mediaDeletionPending | true 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.
| Export | Qué 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 |
DOCUMENTATION | Todo 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_BYTES | Los 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ón | Contiene |
|---|---|
@spicyapi/sdk | Todo lo que aparece en esta página |
@spicyapi/sdk/webhooks | Solo verifyWebhook, computeWebhookSignature y SpicyWebhookVerificationError: una importación pequeña para un receptor de webhooks |
@spicyapi/sdk/openapi | Solo 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.
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
2xxsignifica «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.deliveryIdno 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. Leetask.state, nunca elcodede la envoltura. - En producción, guarda primero el evento verificado en tu base de datos, responde
2xxsolo 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:
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 204Ejecú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): VerifiedWebhookPropiedad
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:
- 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. - Falla una tarea. La llamada funcionó, pero la generación no. No se lanza nada: la tarea devuelta tiene
state: "failed"y unerrorCode. 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. Consultasettledycostpara conocer el importe final.
Las clases de error
| Clase | Cuándo aparece | Campos útiles |
|---|---|---|
SpicyApiError | SpicyAPI respondió con un error, como una clave rechazada o un saldo insuficiente | code (el código de negocio en el que debes basarte, como 40201), status (estado HTTP), message, requestId, retryAfterSeconds, rateLimitLimit, rateLimitRemaining |
SpicyTransportError | No llegó ninguna respuesta utilizable: fallo de red, problema de DNS o una respuesta de más de 4 MiB | message, cause (el error de fondo) |
SpicyTimeoutError | Una sola solicitud tardó más que timeoutMs (o uploadTimeoutMs). Es un tipo de SpicyTransportError | timeoutMs |
SpicyWaitTimeoutError | A run o a waitForTask se les agotó el tiempo. La tarea sigue ejecutándose | taskId, timeoutMs, lastTask |
SpicyUploadError | El almacenamiento rechazó un archivo subido, o el archivo era demasiado grande | status |
SpicyWebhookVerificationError | Un webhook no superó la verificación | reason |
TypeError | La 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ó nada | message |
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.
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.code | Significado | ¿Vale la pena reintentar? |
|---|---|---|
400 | Solicitud no válida, normalmente por un campo de entrada que el modelo no acepta | No. Corrige la solicitud |
401 | Clave ausente, con formato incorrecto, revocada o caducada | No. Revisa la clave |
40201 | Saldo insuficiente para la retención | No. Añade saldo primero |
40202 | Se alcanzó un límite de gasto | No. Sube el límite o espera a que se reinicie el diario o el mensual |
40301 | Esta clave no puede usar este modelo | No. Cambia la lista de modelos de la clave |
404 | No existe ese modelo o esa tarea para esta clave | No |
409 | Entra en conflicto con el estado actual, por ejemplo una clave de idempotencia reutilizada para otro trabajo | Depende; consulta Errores |
40901 | La cotización caducó o cambió el precio | Vuelve a cotizar y después confirma |
429 | Demasiadas solicitudes | Sí, después de retryAfterSeconds. El SDK ya reintenta las lecturas por ti |
500 | Algo se rompió en el lado de SpicyAPI | Sí, tras una pausa |
50301 | El modelo no está disponible ahora mismo | Sí, 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.errorCode | Qué hacer |
|---|---|
rate_limited, upstream_unavailable, timeout, upstream_failed | Espera 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_failed | Cambia el prompt o la imagen y envía una tarea nueva con createTask; la misma entrada suele fallar de la misma manera |
invalid_asset | Vuelve 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_combination | No reintentes sin cambios. Cambia los ajustes y usa createTask |
content_rejected | No 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
urlde un resultado funciona durante unos 20 minutos. Vuelve a llamar agetTaskpara 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
urltemporal. 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
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.
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.
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
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
Catálogo de modelos y esquemas
Cómo leer el inputSchema de un modelo y elegir entradas válidas
Tareas asíncronas
Estados de las tareas, sondeo y qué significa cada campo
Webhooks
Formato de las entregas, calendario de reintentos y verificación en otros lenguajes
Carga y descarga de medios
Subidas, límites de Base64 y descarga de resultados
Errores
Todos los códigos de error y cuáles vale la pena reintentar
Facturación
Retenciones, liquidación, límites de gasto y tu saldo
Idempotencia
Reintenta sin riesgo y sin pagar dos veces
Herramienta de línea de comandos
Las mismas funciones desde una terminal, sin escribir código
Servidor MCP
Deja que un asistente de programación con IA use SpicyAPI por ti
SDK de Python
El mismo flujo desde Python, sin ninguna dependencia en tiempo de ejecución
SDK de Go
El mismo flujo desde Go, solo con la biblioteca estándar
SDK de PHP
El mismo flujo desde PHP, sin ninguna dependencia de bibliotecas
SDK de Java
El mismo flujo desde Java, con una taxonomía de errores sellada
Integración en producción
Una lista de comprobación para lanzar una integración capaz de recuperarse de los fallos, con el modelo adecuado, claves y saldo protegidos, costos confirmados, seguimiento duradero de las tareas, callbacks fiables y las pruebas que hay que hacer antes de salir a producción.
SDK de Python
El SDK oficial de Python para SpicyAPI (spicyapi) — instálalo, cotiza y lanza tu primera generación, sube un archivo local, verifica webhooks y consulta todos los códigos de error.

