spicyapiDocumentación
Desarrollar con la API
Contenido principal

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:

  1. 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.
  2. 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.
  3. Cuando state es succeeded, el enlace del archivo está en data.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. Lee settled y cost para 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/createTask

Esta 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

EncabezadoObligatorioNotas
AuthorizationSíBearer sk-spicy-…
Content-TypeSíapplication/json
Idempotency-KeyNoMuy recomendable: permite reintentar con seguridad después de un error de red. Consulta Idempotencia
X-Spicy-RetentionNoConserva 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: 2

El 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"
}
CampoNotas
taskIdID de tarea con prefijo job_. Se necesita en todos los pasos posteriores; guárdalo
statequeued para una tarea nueva; el estado actual de la original al repetir de forma idempotente con la misma clave
estimatedCostImporte 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.

HTTPcodeSignificado habitualQué hacer
400400Un campo de input no está en el esquema, un valor está fuera de rango o callBackUrl no es válidoCompara con el inputSchema del modelo; msg indica el problema
40040004Todos los campos son válidos, pero la combinación no se puede servir ahoraCambia la combinación, por ejemplo resolución o duración
401401Falta la clave de API, es incorrecta o está deshabilitadaComprueba el encabezado Authorization
40240201No hay saldo suficiente para retener estimatedCostAñade fondos y vuelve a enviar con el mismo Idempotency-Key
40240202Se alcanzó un límite de gasto de clave, equipo o cuentaAuméntalo en la consola o espera al reinicio del límite diario o mensual
40340301La clave no tiene permiso para llamar a este modeloUsa otra clave o cambia las restricciones de modelos de la clave
409409 / 40901Conflicto de idempotencia o una cotización que ya no coincideConsulta Idempotencia y Cotizaciones
413413El cuerpo es demasiado grande, normalmente por medios en líneaSube el archivo y envía su URI spicy://; consulta Medios
429429Demasiadas solicitudes en poco tiempoEspera según Retry-After y vuelve a enviar con la misma clave
50350301El modelo no está disponible temporalmenteReintenta 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.

curl
# 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.

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

queued
running
succeededfailedexpired
Una tarea ocupa un único estado a la vez. Solo se cobra succeeded; cualquier otro estado final devuelve íntegro el importe retenido.

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

EstadoSignificadoFinalFacturación
queuedCreada, con fondos retenidos, esperando ejecuciónNoRetenida, no liquidada
runningEl modelo genera; esperando resultadoNoRetenida, no liquidada
succeededGeneración correctaSíLiquidada según el uso real, sin superar la retención
failedGeneración fallidaSí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
canceledSolo compatibilidad histórica; no hay acción de cancelación disponibleSíLiberación completa
expiredSuperó el tiempo máximo del modeloSí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
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"}}'
ResultadoQué recibes
Estado final dentro del tiempo permitido200 con el registro, igual al formato de recordInfo
Se agota el tiempo permitidoEl 202 habitual, igual que al omitir wait. Continúa con sondeo
Te desconectasLa 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
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

CampoTipoNotas
taskIdstringID de tarea
sourceTaskIdstringSolo en una tarea creada por reintento: indica la tarea de origen
modelstringIdentificador del modelo
statestringVéase la tabla anterior
inputobjectPará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
outputobjectDescripción del resultado. Solo tiene sentido con succeeded
errorCodestringAparece 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
errorMessagestringMotivo del fallo para personas, en inglés salvo que se elija otro idioma. Solo aparece si falla
coststringImporte liquidado o retenido si aún no se liquida; el cargo final no puede superar la retención
settledbooleanSi la facturación es definitiva. Mientras sea false, cost todavía puede cambiar
createdAtstringRFC 3339
deadlineAtstringPlazo de ejecución guardado al aceptar; los eventos antiguos de webhook pueden omitirlo
completedAtstringRFC 3339. Solo estados finales
contentStatestringpresent, expired, eliminado por retención, o purged, eliminado por ti. Consulta Retención y eliminación
contentRemovedBystringuser o system; se omite mientras existe el contenido
purgedAtstringRFC 3339. Solo después de que elimines el contenido
retentionobjectoutputsExpireAt, 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 taskId y consulta después la misma tarea para continuar.
  • deadlineAt es 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 y settled en lugar de deducir una devolución por un temporizador local.
  • output.assets[].expiresAt vence 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.

succeeded
{
  "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"
}
failed
{
  "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[].

CampoTipoNotas
keystringIdentificador de compatibilidad para renovar enlaces opcionalmente. Vacío mientras está pending
urlstringURL GET directa y firmada. Se omite si está pendiente o no disponible; no adjuntes una clave de API
expiresAtstringVencimiento RFC 3339 de esta URL; consulta de nuevo para renovarla dentro de la retención
mimestringTipo de contenido, como image/png o video/mp4
width · heightintegerDimensiones en píxeles. No aparecen en audio ni texto
durationSecondsnumberDuración en segundos. Solo video y audio
bytesintegerTamaño en bytes
pendingbooleanVéase más abajo
unavailablebooleanVéase más abajo
layerobjectSolo 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 y key y url están vacíos. Consulta de nuevo en unos segundos; llamar ahora a download-url sin key devuelve 409.
  • 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.

CallbackSondeo
LatenciaAl terminar la tareaTu intervalo de sondeo
Solicitudes1 por tarea, realizada por nosotrosN por tarea, realizadas por ti
Consume tu límite de solicitudesNoSí; consulta Límites de solicitudes
Necesita un endpoint HTTPS públicoSí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 deadlineAt de la respuesta de createTask, 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.

JavaScript
// 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);
Python
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
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.

En esta página