Tareas asíncronas
Conoce los contratos de createTask y recordInfo, los seis estados de tarea y cómo elegir entre callbacks y sondeo.
La generación es asíncrona: enviar y recuperar son dos llamadas distintas.
En términos sencillos:
- Envías el trabajo y recibes de inmediato un
taskId. Todavía no ha terminado; su precio se reserva de tu saldo mediante una retención. - Vuelves después y preguntas «¿ya terminó este
taskId?». Eso es sondeo; consultar cada pocos segundos basta. O nos das una dirección web y enviamos allí el resultado al terminar: eso es un callback, un webhook. - Cuando
stateessucceeded, el enlace del archivo está endata.output.assets[0].url. Ábrelo o descárgalo en unos 20 minutos; si venció, consulta de nuevo para obtener otro. Una tarea correcta no se cobra por encima de su retención; las fallidas y vencidas normalmente la liberan, con excepciones indicadas en la página del modelo para ciertos rechazos de contenido cobrables. Leesettledycostpara conocer el cargo final.
¿Por qué no una sola llamada? Las imágenes tardan segundos y los videos normalmente minutos. Un endpoint síncrono dejaría tu proceso esperando en una conexión; cualquier interrupción podría dejarte sin resultado ni una forma segura de reintentar, porque no sabrías si el intento anterior ya se estaba ejecutando. Al separar las llamadas, el ID de tarea sirve de referencia: reconecta y sigue consultando el mismo ID.
1. Crear una tarea
POST /api/v1/jobs/createTaskEsta llamada gasta dinero
Una tarea aceptada retiene inmediatamente estimatedCost de tu saldo y no puede cancelarse. Para ver el precio sin crear nada, cotiza la solicitud. Los límites de gasto de tu clave limitan lo que puede gastar; consulta Facturación.
Encabezados
| Encabezado | Obligatorio | Notas |
|---|---|---|
Authorization | Sí | Bearer sk-spicy-… |
Content-Type | Sí | application/json |
Idempotency-Key | No | Muy recomendable: permite reintentar con seguridad después de un error de red. Consulta Idempotencia |
X-Spicy-Retention | No | Conserva las salidas y el prompt de esta tarea durante un periodo más corto, en segundos. Consulta Retención y eliminación |
Cuerpo
Propiedad
Tipo
callBackUrl debe ser una dirección HTTPS pública accesible por nuestros servidores desde internet. Se rechazan las URL http://, un puerto explícito distinto de 443 u 80, las URL con usuario o contraseña, las IP privadas o de loopback y los nombres de host que no resuelven, porque el cuerpo del callback incluye tu prompt y enlaces firmados de resultados. Por eso http://localhost no funciona durante el desarrollo local: usa sondeo o expón tu máquina mediante un túnel con dirección HTTPS pública. Los valores no válidos devuelven HTTP 400 con el code: 400 de negocio, msg: "Invalid callback URL" y un request_id. El error no repite tu URL, host, IP ni detalles DNS. Comprobamos de nuevo el destino al conectar; consulta Webhooks.
Escribe callBackUrl, con B mayúscula
Solo input se valida campo por campo. Un campo superior desconocido, como callback_url o webhookUrl, se ignora actualmente: se acepta la tarea, pero nunca se envía un callback. Si esperas uno y no llega, comprueba primero la escritura de ese nombre.
Respuesta
HTTP/1.1 202 Accepted
Location: /api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3
Retry-After: 2El estado del transporte es 202 Accepted. El cuerpo sigue utilizando el formato estándar, de modo que el code de negocio de una respuesta correcta permanece en 200. Location indica la dirección para el sondeo y Retry-After: 2 sugiere esperar dos segundos antes de la primera consulta.
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "queued",
"estimatedCost": "0.008",
"deadlineAt": "2026-08-28T09:42:04Z"
},
"request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}| Campo | Notas |
|---|---|
taskId | ID de tarea con prefijo job_. Se necesita en todos los pasos posteriores; guárdalo |
state | queued para una tarea nueva; el estado actual de la original al repetir de forma idempotente con la misma clave |
estimatedCost | Importe retenido al aceptar, como "0.008", en dólares estadounidenses como cadena, y cargo máximo de la tarea. Lo no utilizado se libera; no se cobra después nada por encima de la retención |
deadlineAt | Último momento en que el servidor sigue trabajando en esta tarea. Si no termina antes, pasa a expired y no se cobra nada. Úsalo para decidir cuánto esperar |
Los importes son cadenas sin símbolo de moneda
estimatedCost, cost y los tres campos de saldo son cadenas decimales simples, como "0.008": sin $ ni separadores de miles. parseFloat / float() funcionan directamente con ellas.
Son cadenas y no números JSON porque un número JSON se convierte en float64 en la mayoría de los lenguajes, y 0.008 no tiene representación binaria exacta. Tras pasar por un float, ya no puedes afirmar el importe exacto cobrado; interprétalo con un tipo decimal (decimal.Decimal, BigDecimal) si lo usarás en contabilidad.
La moneda siempre es USD. Consulta Facturación.
Cuando falla la creación
Estos son los errores habituales de una primera integración. No se retiene ni se cobra nada cuando createTask devuelve un error.
| HTTP | code | Significado habitual | Qué hacer |
|---|---|---|---|
| 400 | 400 | Un campo de input no está en el esquema, un valor está fuera de rango o callBackUrl no es válido | Compara con el inputSchema del modelo; msg indica el problema |
| 400 | 40004 | Todos los campos son válidos, pero la combinación no se puede servir ahora | Cambia la combinación, por ejemplo resolución o duración |
| 401 | 401 | Falta la clave de API, es incorrecta o está deshabilitada | Comprueba el encabezado Authorization |
| 402 | 40201 | No hay saldo suficiente para retener estimatedCost | Añade fondos y vuelve a enviar con el mismo Idempotency-Key |
| 402 | 40202 | Se alcanzó un límite de gasto de clave, equipo o cuenta | Auméntalo en la consola o espera al reinicio del límite diario o mensual |
| 403 | 40301 | La clave no tiene permiso para llamar a este modelo | Usa otra clave o cambia las restricciones de modelos de la clave |
| 409 | 409 / 40901 | Conflicto de idempotencia o una cotización que ya no coincide | Consulta Idempotencia y Cotizaciones |
| 413 | 413 | El cuerpo es demasiado grande, normalmente por medios en línea | Sube el archivo y envía su URI spicy://; consulta Medios |
| 429 | 429 | Demasiadas solicitudes en poco tiempo | Espera según Retry-After y vuelve a enviar con la misma clave |
| 503 | 50301 | El modelo no está disponible temporalmente | Reintenta más tarde con la misma clave |
Lista completa: Errores.
Antes de empezar: elige un modelo y una entrada válida
Los ejemplos necesitan un ID real y un input admitido por ese modelo. El catálogo proporciona ambos: includeExamples=1 añade entradas que ya pasaron el esquema de cada modelo. Necesitas jq, una pequeña herramienta de JSON para la terminal; cambia image por video o audio para otros tipos de modelo.
curl -s "https://api.spicyapi.ai/api/v1/models?modality=image&includeExamples=1" \
-H "Authorization: Bearer $SPICY_API_KEY" \
| jq -c '.data.items[] | select(.enabled and .available) | {model, input: .examples[0].input}'Cada línea muestra un cuerpo listo para enviar con dos campos, model e input. Elige uno. Si una línea muestra "input": null, ese modelo no tiene un ejemplo guardado: consulta su inputSchema en GET /api/v1/models/{model} y escribe la entrada. Consulta Catálogo de modelos y esquemas.
Ejemplos
MODEL_ID_FROM_CATALOG es solo un marcador: sustituye todo el cuerpo por la línea elegida del catálogo actualizado. No envíes literalmente el marcador.
# Paste one line printed by the command above between the single quotes.
TASK='{"model":"MODEL_ID_FROM_CATALOG","input":{"prompt":"a folded paper lantern, hard side light"}}'
# One new key per new task. After a network error, rerun only the curl command
# so the same key is sent again and no second task is created.
IDEMPOTENCY_KEY="$(uuidgen)"
curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
--data "$TASK"Deberías ver el cuerpo 202 anterior con code: 200 y un taskId. Guarda el taskId: permite recuperar el resultado. Para recibir un callback, añade "callBackUrl":"https://…" con tu dirección HTTPS dentro del JSON.
const res = await fetch('https://api.spicyapi.ai/api/v1/jobs/createTask', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPICY_API_KEY}`,
'Content-Type': 'application/json',
// One key per logical request; reuse it across retries
'Idempotency-Key': 'order-8814-render-1',
},
body: JSON.stringify({
model: 'MODEL_ID_FROM_CATALOG',
input: { prompt: 'a folded paper lantern, hard side light' },
// Your own public HTTPS endpoint; delete this line to poll instead
callBackUrl: 'https://your-app.example.com/hooks/spicy',
}),
});
const body = await res.json();
if (res.status !== 202 || body.code !== 200) throw new Error(`${res.status} ${body.code} ${body.msg}`);
console.log(body.data.taskId, body.data.deadlineAt);import os, requests
res = requests.post(
"https://api.spicyapi.ai/api/v1/jobs/createTask",
headers={
"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
"Content-Type": "application/json",
# One key per logical request; reuse it across retries
"Idempotency-Key": "order-8814-render-1",
},
json={
"model": "MODEL_ID_FROM_CATALOG",
"input": {"prompt": "a folded paper lantern, hard side light"},
# Your own public HTTPS endpoint; delete this line to poll instead
"callBackUrl": "https://your-app.example.com/hooks/spicy",
},
)
body = res.json()
if res.status_code != 202 or body["code"] != 200:
raise RuntimeError(f'{res.status_code} {body["code"]} {body["msg"]}')
print(body["data"]["taskId"], body["data"]["deadlineAt"])Una respuesta 202 no siempre está en queued
La primera solicitud aceptada crea una tarea queued. Si la misma clave de API repite la misma solicitud con el mismo Idempotency-Key, ese 202 devuelve el estado actual de la original. Puede estar en running o en un estado final, pero no crea otra tarea ni otra retención.
2. Estados
queuedrunningsucceededfailedexpiredAceptar una tarea es irreversible: ni queued ni running tienen una acción de cancelación. La API todavía lee registros históricos canceled, pero ninguna acción nueva de usuario u operador crea ese estado.
| Estado | Significado | Final | Facturación |
|---|---|---|---|
queued | Creada, con fondos retenidos, esperando ejecución | No | Retenida, no liquidada |
running | El modelo genera; esperando resultado | No | Retenida, no liquidada |
succeeded | Generación correcta | Sí | Liquidada según el uso real, sin superar la retención |
failed | Generación fallida | Sí | Normalmente se libera todo; ciertos resultados content_rejected indicados expresamente en la página del modelo se cobran al precio aceptado. Consulta settled y cost |
canceled | Solo compatibilidad histórica; no hay acción de cancelación disponible | Sí | Liberación completa |
expired | Superó el tiempo máximo del modelo | Sí | Liberación completa |
Consulta la liquidación, además del estado de la tarea
Las tareas fallidas o vencidas normalmente liberan toda la retención. Algunos rechazos de contenido se cobran al precio aceptado únicamente si la página del modelo informa expresamente de esa excepción. Consulta settled y cost para conocer el cargo final. Un rechazo cobrado mantiene state: "failed" y errorCode: "content_rejected". Un proceso de recuperación completa automáticamente cualquier liquidación o liberación pendiente.
Comprueba el estado final como state no pertenece a {queued, running}, en lugar de enumerar valores finales. Así se manejan correctamente los registros históricos canceled y un estado final futuro no provoca un sondeo interminable.
2.5 Evita el bucle de sondeo con wait
Añade ?wait=<seconds> a createTask para mantener la conexión abierta hasta que la tarea alcance un estado final; así la primera llamada no necesita un bucle de sondeo.
curl -X POST "https://api.spicyapi.ai/api/v1/jobs/createTask?wait=30" \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"MODEL_ID_FROM_CATALOG","input":{"prompt":"a lighthouse at dusk"}}'| Resultado | Qué recibes |
|---|---|
| Estado final dentro del tiempo permitido | 200 con el registro, igual al formato de recordInfo |
| Se agota el tiempo permitido | El 202 habitual, igual que al omitir wait. Continúa con sondeo |
| Te desconectas | La tarea no cambia. Sigue ejecutándose y se factura normalmente |
Un estado final no significa necesariamente éxito
Aquí 200 significa que terminó la tarea, no que tuvo éxito. Comprueba data.state: failed y expired llegan con el mismo estado HTTP, igual que desde recordInfo.
El máximo es de 60 segundos. Los valores mayores se limitan en lugar de rechazarse y todo lo que no sea un entero positivo se ignora: un error de escritura en un parámetro de optimización no debería hacer fallar una generación válida.
El máximo existe por el tiempo de espera de 100 segundos de la red de borde delante de la API. Después, el CDN corta la conexión en lugar de nosotros y recibirías un 524 sin ID de tarea ni estado recuperable, aunque la tarea exista y los fondos ya estén retenidos. Los 60 segundos dejan margen para la propia solicitud.
Úsalo para imágenes, no para video
Los modelos de video suelen tardar minutos. wait agotará su tiempo, añadirás latencia por mantener la conexión y terminarás usando sondeo igualmente. Conviene cuando el modelo suele terminar en segundos, como la mayoría de endpoints de imagen y edición; en los demás casos es un desperdicio. Por diseño no se puede establecer como predeterminado.
En producción, los webhooks siguen siendo la mejor opción: no mantienen una conexión abierta y notifican tareas que duran más que cualquier espera que hayas elegido.
3. Consultar una tarea
GET /api/v1/jobs/recordInfo?taskId=job_…Solo hay un parámetro de consulta obligatorio: taskId. Una tarea solo se puede leer con la clave de API que la creó. Otra clave, incluso de tu cuenta o una sustituta después de rotarla, devuelve 404, igual que una tarea inexistente, para impedir enumerar ID ajenos mediante diferencias de error. La misma regla se aplica a download-url y retry. Antes de rotar una clave, termina de recuperar los resultados de las tareas creadas con la anterior.
curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3" \
-H "Authorization: Bearer $SPICY_API_KEY"Consultar es gratis. Sustituye el ID de ejemplo por tu taskId.
Campos de respuesta
| Campo | Tipo | Notas |
|---|---|---|
taskId | string | ID de tarea |
sourceTaskId | string | Solo en una tarea creada por reintento: indica la tarea de origen |
model | string | Identificador del modelo |
state | string | Véase la tabla anterior |
input | object | Parámetros validados; las imágenes en línea se convierten en URI de archivos de la plataforma. Se omite al vencer la retención de entrada |
output | object | Descripción del resultado. Solo tiene sentido con succeeded |
errorCode | string | Aparece cuando state es failed o expired; una tarea vencida tiene timeout. Uno de nueve valores; consulta Códigos de fallo de tareas. Basa las condiciones en él, nunca en errorMessage |
errorMessage | string | Motivo del fallo para personas, en inglés salvo que se elija otro idioma. Solo aparece si falla |
cost | string | Importe liquidado o retenido si aún no se liquida; el cargo final no puede superar la retención |
settled | boolean | Si la facturación es definitiva. Mientras sea false, cost todavía puede cambiar |
createdAt | string | RFC 3339 |
deadlineAt | string | Plazo de ejecución guardado al aceptar; los eventos antiguos de webhook pueden omitirlo |
completedAt | string | RFC 3339. Solo estados finales |
contentState | string | present, expired, eliminado por retención, o purged, eliminado por ti. Consulta Retención y eliminación |
contentRemovedBy | string | user o system; se omite mientras existe el contenido |
purgedAt | string | RFC 3339. Solo después de que elimines el contenido |
retention | object | outputsExpireAt, cuándo se eliminan las salidas; promptsExpireAt, cuándo se borra el prompt; y source, qué ajuste decidió |
Cuatro plazos distintos
- Tu tiempo de espera lo eliges tú. Dejar de esperar no cancela la tarea remota: guarda el
taskIdy consulta después la misma tarea para continuar. deadlineAtes el plazo de ejecución del servidor. Repetir de forma idempotente no lo amplía; un reintento explícito crea otra tarea con su propio plazo. Consulta el estado final ysettleden lugar de deducir una devolución por un temporizador local.output.assets[].expiresAtvence un enlace temporal de resultado, normalmente después de 20 minutos. Consultar otra vez renueva el enlace sin crear una tarea.- Los medios generados se conservan como máximo unos 14 días después de terminar; las entradas subidas a la plataforma, como máximo un día. Son máximos de plataforma: puedes acortarlos en Retención y eliminación o eliminar el contenido de una tarea a petición. Renovar un enlace no amplía la retención del archivo.
Si usas el SDK de JavaScript, client.run({ model, input }, { idempotencyKey, onAccepted, timeoutMs }) envía una vez y consulta con intervalos crecientes de 2 a un máximo de 10 segundos; su timeoutMs solo es la espera local, así que guarda el ID desde onAccepted. Consulta SDK. Prefiere webhooks para trabajos en segundo plano. Tras verificar un callback completo de resultado, usa directamente su resultado sin consultar otra vez la tarea.
Usa directamente la URL del resultado
Los archivos listos incluyen url y expiresAt, además de key y metadatos multimedia. Recupera output.assets[].url directamente sin credenciales de API, por ejemplo curl -L -o result.png "<url>". Consulta de nuevo cuando venza la URL; /common/download-url sigue siendo opcional para integraciones existentes. Las URL normalmente duran 20 minutos y no pueden ampliar los 14 días de retención.
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "succeeded",
"input": { "prompt": "a folded paper lantern, hard side light" },
"output": {
"assets": [
{
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
"url": "https://example.r2.cloudflarestorage.com/results/result.png?X-Amz-Signature=SIGNATURE_FROM_RESPONSE",
"expiresAt": "2026-08-28T09:32:11Z",
"mime": "image/png",
"width": 1024,
"height": 1024,
"bytes": 1483920
}
]
},
"cost": "0.008",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"deadlineAt": "2026-08-28T09:42:04Z",
"completedAt": "2026-08-28T09:12:11Z",
"contentState": "present"
},
"request_id": "req_01k3m8xa1b2c3d4e5f6g7h8j9k0m"
}{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "failed",
"errorCode": "upstream_failed",
"errorMessage": "Generation failed; the charge has been refunded",
"cost": "0",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"deadlineAt": "2026-08-28T09:42:04Z",
"completedAt": "2026-08-28T09:12:39Z",
"contentState": "present"
},
"request_id": "req_01k3m8xb2c3d4e5f6g7h8j9k0m1n"
}Una consulta correcta no implica una tarea correcta
Ambas respuestas tienen code: 200: la consulta funcionó. El resultado de la tarea está en data.state. El código de negocio describe la entrega; state describe la tarea. No los confundas.
El cuerpo de un webhook usa esta misma estructura: el mismo formato code: 200 y los mismos campos data. El código que interpreta esta sección interpreta un callback sin cambios: dos vías, un solo analizador.
Formato de output
output tiene dos partes independientes. Los archivos generados, como imágenes, video y audio, llegan en output.assets[]. Los resultados de texto llegan en output.text, como una cadena simple.
La mayoría de modelos rellena exactamente una parte y un modelo puede rellenar ambas. Depende del endpoint, no de su modality: un endpoint speech-to-text está en audio dentro del catálogo porque lee audio, pero responde con texto.
`output.assets` no siempre existe
Un endpoint con resultado de texto no tiene ninguna clave assets, ni siquiera una lista vacía. Acceder con task.output.assets[0].url provoca un error en esas tareas en lugar de devolver algo comprobable. Lee primero output.text si aparece y trata assets como opcional:
const { text, assets = [] } = task.output ?? {};
if (text) console.log(text);
for (const asset of assets) console.log(asset.url);Un resultado de texto no tiene nada que descargar ni un enlace que venza, pero sigue formando parte de la salida: se elimina con el mismo calendario que cualquier resultado y contentState pasa a expired. Copia lo que quieras conservar. Un resultado dividido en varias partes, como segmentos de transcripción, se une en una cadena con saltos de línea entre las partes.
El resto de la sección describe assets[].
| Campo | Tipo | Notas |
|---|---|---|
key | string | Identificador de compatibilidad para renovar enlaces opcionalmente. Vacío mientras está pending |
url | string | URL GET directa y firmada. Se omite si está pendiente o no disponible; no adjuntes una clave de API |
expiresAt | string | Vencimiento RFC 3339 de esta URL; consulta de nuevo para renovarla dentro de la retención |
mime | string | Tipo de contenido, como image/png o video/mp4 |
width · height | integer | Dimensiones en píxeles. No aparecen en audio ni texto |
durationSeconds | number | Duración en segundos. Solo video y audio |
bytes | integer | Tamaño en bytes |
pending | boolean | Véase más abajo |
unavailable | boolean | Véase más abajo |
layer | object | Solo en resultados de descomposición en capas: zIndex, entero; boundingBox ([x1, y1, x2, y2], opcional); y name, opcional. Véase más abajo |
Los campos de los que no tenemos datos se omiten, no son null ni 0. Trátalos como opcionales.
Debes manejar dos indicadores:
pending: true: el archivo sigue copiándose a nuestro almacenamiento ykeyyurlestán vacíos. Consulta de nuevo en unos segundos; llamar ahora adownload-urlsinkeydevuelve409.unavailable: true: el archivo venció o quedó permanentemente no disponible tras fallar la transferencia. No volverá; esperar no ayuda, así que genera de nuevo. Si no se pudo almacenar ninguno de los archivos de una tarea correcta, esa tarea no se cobra.
Los resultados de descomposición añaden layer. El primer archivo es la imagen base, la fuente sin los elementos separados y con zIndex 0; los demás son capas PNG transparentes. Apílalas en orden ascendente de zIndex para reconstruir la imagen. boundingBox es [x1, y1, x2, y2] en píxeles de la imagen base: escala la capa para llenar ese rectángulo y volverá a su posición. Los modelos que no informan posiciones lo omiten y las capas funcionan como recortes independientes. name es la etiqueta del modelo y solo aparece si la proporciona. Se factura cada imagen devuelta, incluida la base.
4. Callbacks primero, sondeo como respaldo
El sondeo consulta recordInfo repetidamente hasta que termina la tarea. Un callback envía la tarea terminada a tu servidor una vez. Ambos proporcionan los mismos datos.
| Callback | Sondeo | |
|---|---|---|
| Latencia | Al terminar la tarea | Tu intervalo de sondeo |
| Solicitudes | 1 por tarea, realizada por nosotros | N por tarea, realizadas por ti |
| Consume tu límite de solicitudes | No | Sí; consulta Límites de solicitudes |
| Necesita un endpoint HTTPS público | Sí | No |
Usa callbacks en producción. Consultar cada segundo una tarea de video de tres minutos son 180 solicitudes con casi ninguna información nueva. La protección actual de 1,000 solicitudes por 10 segundos de la API abierta no limita la generación normal de pago, pero el sondeo intenso crea tráfico innecesario. Un bucle fuera de control agota su propia capacidad de lectura hasta que recordInfo devuelve 429.
El sondeo tiene sentido en dos casos: desarrollo local y servicios sin acceso público de entrada.
Aumenta los intervalos del sondeo
recordInfo usa el contador de lectura y createTask el de escritura, por lo que el sondeo no agota la capacidad de envío. Sin embargo, si consultas demasiado, el propio recordInfo empieza a devolver 429 y pierdes visibilidad hasta que se reponga su capacidad.
Un patrón útil:
- Empieza en 2–3 segundos. Un intervalo menor se desperdicia; incluso los modelos de imagen más rápidos tardan unos segundos.
- Multiplica por 1.5, con un máximo de 10–15 segundos.
- Detente según el plazo de la tarea, no tras un número fijo de minutos. Algunos modelos de video pueden tardar hasta una hora; un tiempo fijo de 10 minutos abandona tareas que siguen trabajando normalmente. Usa el
deadlineAtde la respuesta decreateTask, más unos minutos para que el servidor registre el resultado.
Dejar de esperar no significa que la tarea falló
Cuando termina tu espera, solo has dejado de esperar. La tarea sigue ejecutándose y puede terminar correctamente y cobrarse. No la envíes otra vez con un Idempotency-Key nuevo: crearías y pagarías otra tarea. Consulta después el mismo taskId o búscalo en el historial.
// created is the data object returned by createTask.
async function waitForTask(created) {
// Stop a few minutes after the server's own deadline.
const giveUpAt = Date.parse(created.deadlineAt) + 5 * 60 * 1000;
let wait = 2000;
while (Date.now() < giveUpAt) {
await new Promise((r) => setTimeout(r, wait));
wait = Math.min(wait * 1.5, 15000);
const res = await fetch(
`https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(created.taskId)}`,
{ headers: { Authorization: `Bearer ${process.env.SPICY_API_KEY}` } },
);
const body = await res.json();
// Being rate-limited is not a task failure — honour Retry-After
if (body.code === 429) {
wait = Number(res.headers.get('retry-after') ?? 1) * 1000;
continue;
}
if (body.code !== 200) throw new Error(`${body.code} ${body.msg}`);
// Terminal means "not one of the two in-flight states"
const { state } = body.data;
if (state !== 'queued' && state !== 'running') return body.data;
}
// Not a failure: query this taskId again later instead of resubmitting.
throw new Error(`stopped waiting for ${created.taskId}`);
}
// created = body.data from the createTask example above
const task = await waitForTask(created);
if (task.state === 'succeeded') console.log(task.output.assets[0].url);
else console.log(task.state, task.errorCode);import os, time, requests
from datetime import datetime
BASE = "https://api.spicyapi.ai/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}"}
def wait_for_task(created: dict) -> dict:
"""created is the data object returned by createTask."""
# Stop a few minutes after the server's own deadline.
deadline = datetime.fromisoformat(created["deadlineAt"].replace("Z", "+00:00"))
give_up_at = deadline.timestamp() + 5 * 60
wait = 2.0
while time.time() < give_up_at:
time.sleep(wait)
wait = min(wait * 1.5, 15.0)
res = requests.get(f"{BASE}/jobs/recordInfo", headers=AUTH, params={"taskId": created["taskId"]})
body = res.json()
# Being rate-limited is not a task failure — honour Retry-After
if body["code"] == 429:
wait = float(res.headers.get("retry-after", 1))
continue
if body["code"] != 200:
raise RuntimeError(f'{body["code"]} {body["msg"]}')
# Terminal means "not one of the two in-flight states"
if body["data"]["state"] not in ("queued", "running"):
return body["data"]
# Not a failure: query this taskId again later instead of resubmitting.
raise TimeoutError(f'stopped waiting for {created["taskId"]}')
# created = body["data"] from the createTask example above
task = wait_for_task(created)
if task["state"] == "succeeded":
print(task["output"]["assets"][0]["url"])
else:
print(task["state"], task.get("errorCode"))5. Consultar tu saldo
GET /api/v1/chat/credit devuelve available, lo que puede financiar nuevas tareas, held, lo reservado para tareas en ejecución, y total. available puede ser negativo al utilizar crédito aprobado, y los fondos promocionales de funding.grants pueden cubrir solo determinados modelos. Más información: Consultar tu saldo.
curl https://api.spicyapi.ai/api/v1/chat/credit \
-H "Authorization: Bearer $SPICY_API_KEY"Historial de tareas y recuperación
GET /api/v1/jobs lista tareas visibles creadas por la clave de API actual. Úsalo para recuperar trabajos después de reiniciar una aplicación o perder un callback. Se excluyen las tareas de otras claves y los registros ocultos. Las respuestas incluyen ID de tarea, modelo, estado, costo, estado de liquidación, fechas y, si se registró, el requestId de la llamada que creó la tarea; para una llamada de texto, es el mismo valor del encabezado X-Request-Id. No incluyen prompts, entrada, salida ni enlaces multimedia. Consulta una tarea seleccionada con recordInfo para obtener el resultado.
Filtra por state, un model exacto del catálogo o alias, y fechas UTC from / to (YYYY-MM-DD, inicio incluido y fin excluido). El valor predeterminado son siete días hasta mañana UTC; el máximo es de 92 días. limit es 20 de forma predeterminada y no puede superar 100.
Omite los filtros que no uses: un valor vacío como model=, un parámetro repetido o uno desconocido devuelve 400. Una consulta de más de cinco segundos devuelve 503; reintenta con un intervalo de fechas más corto.
curl --fail-with-body \
'https://api.spicyapi.ai/api/v1/jobs?from=2026-09-01&to=2026-09-07&state=failed&limit=20' \
-H "Authorization: Bearer $SPICY_API_KEY"Lee data.items. Si data.hasMore es true, envía data.nextCursor como cursor de la siguiente solicitud y conserva todos los filtros y fechas explícitas. El orden es descendente por createdAt y después por ID de tarea. Las páginas reflejan el estado actual y no son una instantánea fija. No construyas cursores por tu cuenta. Consultar el historial nunca crea ni reintenta una tarea.
La misma función está en client.listTasks(...), spicyapi tasks list y la herramienta MCP de solo lectura spicyapi_tasks_list. Conserva el cost en USD como cadena decimal; mientras settled sea false, sigue siendo la estimación retenida, no un cargo final.
Si se perdió la respuesta de creación, primero repite la solicitud sin cambios con su Idempotency-Key original. Una página de historial vacía no autoriza a crear de nuevo el mismo trabajo de pago con otra clave.

