Асинхронные задачи
Контракты createTask и recordInfo, смысл каждого из шести состояний и выбор между обратными вызовами и опросом.
Генерация асинхронна: отправка задачи и получение результата — два отдельных вызова.
Простыми словами:
- Вы отправляете задачу и сразу получаете
taskId. Пока ничего не готово; стоимость задачи резервируется на вашем балансе — это резерв. - Позже вы спрашиваете, завершена ли задача с этим
taskId. Это опрос; одного запроса каждые несколько секунд достаточно. Либо вы передаёте веб-адрес, и мы отправляем туда результат после завершения — это обратный вызов, или вебхук. - Когда
stateравенsucceeded, ссылка на файл находится вdata.output.assets[0].url. Откройте или скачайте его примерно в течение 20 минут; если ссылка истекла, запросите свежую. С успешной задачи списывается не больше резерва; неудачные и просроченные задачи обычно освобождают его, за исключением некоторых оплачиваемых отказов по содержимому, прямо указанных на странице модели. Окончательное начисление смотрите вsettledиcost.
Почему не один вызов? Изображения создаются за секунды, а видео обычно требует минут. Синхронный эндпоинт держал бы ваш процесс на открытом соединении; любой сбой оставил бы вас без результата и безопасного способа повторить запрос, поскольку вы не знали бы, выполняется ли предыдущая попытка. Разделение делает ID задачи надёжной точкой отсчёта: восстановите соединение и продолжайте запрашивать тот же ID.
1. Создайте задачу
POST /api/v1/jobs/createTaskЭтот вызов расходует деньги
Принятая задача сразу резервирует estimatedCost на балансе и не может быть отменена. Чтобы узнать цену без создания задачи, рассчитайте стоимость запроса. Лимиты расходов ключа ограничивают его траты; см. Оплата.
Заголовки
| Заголовок | Обязателен | Примечания |
|---|---|---|
Authorization | Да | Bearer sk-spicy-… |
Content-Type | Да | application/json |
Idempotency-Key | Нет | Настоятельно рекомендуем: делает повтор после сетевой ошибки безопасным. См. Идемпотентность |
X-Spicy-Retention | Нет | Более короткий срок хранения результатов и промпта этой задачи в секундах. См. Хранение и уничтожение данных |
Тело
Свойство
Тип
callBackUrl должен быть публичным HTTPS-адресом, доступным нашим серверам через интернет. Отклоняются обычные URL http://, явно заданные порты, отличные от 443 и 80, URL с именем пользователя или паролем, частные IP или адреса обратной петли, а также имена хостов, которые не разрешаются: тело обратного вызова содержит промпт и подписанные ссылки на результаты. Поэтому http://localhost не подходит для локальной разработки: используйте опрос или туннель, выдающий вашему компьютеру публичный HTTPS-адрес. Некорректное значение возвращает HTTP 400 с бизнес-полями code: 400, msg: "Invalid callback URL" и request_id. Ошибка не повторяет ваш URL, имя хоста, IP-адрес или сведения DNS. При подключении мы повторно проверяем адрес назначения; см. Вебхуки.
Пишите callBackUrl с заглавной B
Только input проверяется по полям. Неизвестное поле верхнего уровня, например callback_url или webhookUrl, сейчас игнорируется: задача принимается, но обратный вызов никогда не отправляется. Если вы ждёте обратный вызов, а он не приходит, сначала проверьте написание поля.
Ответ
HTTP/1.1 202 Accepted
Location: /api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3
Retry-After: 2Транспортный статус — 202 Accepted. Тело использует стандартную обёртку, поэтому успешный бизнес-code остаётся 200. Location указывает адрес опроса, а Retry-After: 2 — рекомендуемое ожидание в секундах до первого запроса.
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "queued",
"estimatedCost": "0.008",
"deadlineAt": "2026-08-28T09:42:04Z"
},
"request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}| Поле | Примечания |
|---|---|
taskId | ID задачи с префиксом job_. Он нужен во всех дальнейших действиях, поэтому сохраните его |
state | queued для новой задачи; текущее состояние исходной задачи при идемпотентном повторе с тем же ключом |
estimatedCost | Сумма резерва при принятии, например "0.008": доллары США в строке. Это также максимум, который может списать задача. Неиспользованные средства освобождаются; сверх резерва позднее ничего не списывается |
deadlineAt | Последний момент, до которого сервер выполняет задачу. Если она не завершится, состояние станет expired и плата не взимается. Используйте это время, чтобы определить длительность ожидания |
Денежные поля — строки без символа валюты
estimatedCost, cost и три поля баланса — обычные десятичные строки, например "0.008", без $ и разделителей тысяч. parseFloat / float() работают с ними напрямую.
Они являются строками, а не JSON-числами, потому что в большинстве языков JSON-число превращается в float64, а у 0.008 нет точного двоичного представления. После преобразования цены в число с плавающей точкой уже нельзя гарантировать точную начисленную сумму. Для финансового учёта используйте десятичный тип, например decimal.Decimal или BigDecimal.
Валюта всегда USD. См. Оплата.
Ошибки создания
С этими ошибками обычно сталкиваются при первой интеграции. Если createTask возвращает ошибку, ничего не резервируется и не списывается.
| HTTP | code | Обычная причина | Что делать |
|---|---|---|---|
| 400 | 400 | Поле input отсутствует в схеме модели, значение вне диапазона или callBackUrl некорректен | Сверьте с inputSchema модели; msg указывает проблему |
| 400 | 40004 | Каждое поле допустимо, но это сочетание сейчас нельзя выполнить | Измените сочетание, например разрешение или длительность |
| 401 | 401 | API-ключ отсутствует, неверен или отключён | Проверьте заголовок Authorization |
| 402 | 40201 | Баланса недостаточно для резерва estimatedCost | Пополните баланс в консоли, затем повторите с тем же Idempotency-Key |
| 402 | 40202 | Достигнут лимит расходов ключа, команды или аккаунта | Увеличьте лимит в консоли или дождитесь сброса дневного либо месячного лимита |
| 403 | 40301 | Этому ключу не разрешена модель | Используйте другой ключ или измените ограничения моделей ключа |
| 409 | 409 / 40901 | Конфликт идемпотентности или расчёт цены больше не соответствует запросу | См. Идемпотентность и Расчёт цены |
| 413 | 413 | Слишком большое тело запроса, обычно из-за встроенных медиа | Загрузите файл и передайте его URI spicy://; см. Медиафайлы |
| 429 | 429 | Слишком много запросов за короткий срок | Выждите Retry-After, затем повторите с тем же ключом |
| 503 | 50301 | Модель временно недоступна | Повторите позже с тем же ключом |
Полный список: Ошибки.
Перед началом: выберите модель и допустимые входные данные
Примерам ниже нужны настоящий ID модели и допустимый для неё input. Каталог даёт и то и другое: includeExamples=1 добавляет входные данные, уже прошедшие схему каждой модели. Нужен jq, небольшой инструмент командной строки для JSON; замените image на video или audio для других типов моделей.
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}'Каждая выведенная строка — готовое тело запроса с полями model и input. Выберите одну. Если в строке указано "input": null, у модели нет сохранённого примера: прочитайте её inputSchema через GET /api/v1/models/{model} и составьте входные данные самостоятельно. См. Каталог и схемы моделей.
Примеры
MODEL_ID_FROM_CATALOG ниже — только заглушка: замените всё тело запроса строкой, выбранной из актуального каталога. Не отправляйте заглушку буквально.
# 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"Вы должны получить показанное выше тело ответа 202 с code: 200 и taskId. Сохраните taskId: по нему вы получите результат. Для обратного вызова добавьте в JSON "callBackUrl":"https://…" со своим HTTPS-адресом.
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"])Ответ 202 не всегда означает queued
Первый принятый запрос создаёт задачу queued. Если тот же API-ключ повторяет тот же запрос с тем же Idempotency-Key, ответ 202 возвращает текущее состояние исходной задачи. Она уже может быть running или в конечном состоянии, но вторая задача и второй резерв не создаются.
2. Состояния
queuedrunningsucceededfailedexpiredПринятая задача необратима: ни для queued, ни для running нет действия отмены. API
по-прежнему читает исторические записи canceled, но новые действия пользователя или оператора не создают это состояние.
| Состояние | Смысл | Конечное | Расчёты |
|---|---|---|---|
queued | Создана, средства зарезервированы, ожидает начала выполнения | Нет | Резерв, расчёт не завершён |
running | Модель генерирует; ожидается результат | Нет | Резерв, расчёт не завершён |
succeeded | Генерация успешна | Да | Окончательная стоимость по фактическому использованию, не выше резерва |
failed | Генерация не удалась | Да | Обычно резерв полностью освобождается; некоторые результаты content_rejected, прямо указанные на странице модели, оплачиваются по принятому расчёту цены. Проверяйте settled и cost |
canceled | Только историческая совместимость; действие отмены недоступно | Да | Резерв полностью освобождён |
expired | Превышено максимальное время выполнения модели | Да | Резерв полностью освобождён |
Проверяйте расчёт стоимости, а не только состояние задачи
Неудачные или просроченные задачи обычно освобождают весь резерв. Некоторые отказы по содержимому оплачиваются по принятому расчёту только тогда, когда страница модели прямо раскрывает это исключение. Итоговое начисление смотрите в settled и cost. Оплачиваемый отказ всё равно имеет state: "failed" и errorCode: "content_rejected". Процесс восстановления автоматически завершает незаконченные расчёты или освобождение резерва.
Проверяйте конечное состояние условием state не входит в {queued, running}, а не перечислением конечных значений. Это корректно обрабатывает исторические записи canceled и не позволяет будущему конечному состоянию вызвать бесконечный опрос.
2.5 Пропустите цикл опроса с wait
Добавьте ?wait=<seconds> к createTask: мы удержим соединение до конечного состояния задачи, так что первому вызову вообще не понадобится цикл опроса.
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"}}'| Исход | Что вы получаете |
|---|---|
| Конечное состояние в пределах времени ожидания | 200 с записью задачи в том же формате, что возвращает recordInfo |
| Время ожидания исчерпано | Обычный 202, как без wait. Далее опрашивайте задачу |
| Вы разорвали соединение | Для задачи ничего не меняется. Она продолжает выполняться и оплачивается как обычно |
Конечное состояние не обязательно означает успех
200 здесь означает завершение задачи, а не успех. Проверяйте data.state: failed и expired приходят с тем же статусом, как и в recordInfo.
Максимум — 60 секунд. Большие значения ограничиваются до максимума, а не отклоняются; всё, что не является положительным целым числом, игнорируется, а не вызывает отказ. Опечатка в параметре оптимизации не должна ломать генерацию, которая иначе была бы успешной.
Предел связан со 100-секундным тайм-аутом пограничного сервера перед API. После него соединение разрывает CDN, а не мы, и вы получили бы 524 без ID задачи и возможности продолжить получение результата, хотя задача уже существует, а средства зарезервированы. 60 секунд оставляют время на сам запрос.
Используйте для изображений, а не для видео
Видеомодели обычно работают несколько минут. wait просто исчерпает время, задержка на открытом соединении увеличит ожидание, и вам всё равно придётся опрашивать задачу. Он полезен, когда модель обычно завершается за секунды, как большинство эндпоинтов изображений и редактирования, и не помогает в остальных случаях. Сделать его значением по умолчанию намеренно нельзя.
Для рабочих интеграций вебхуки остаются предпочтительнее: соединение вообще не удерживается, а вы узнаете о завершении задач, которые выполняются дольше любого выбранного времени ожидания.
3. Запросите задачу
GET /api/v1/jobs/recordInfo?taskId=job_…Единственный обязательный параметр запроса — taskId. Прочитать задачу можно только тем API-ключом, который её создал. Другой ключ, даже из того же аккаунта или новый после ротации, возвращает 404, как для несуществующей задачи. Так различия ошибок нельзя использовать для перебора чужих ID. То же правило относится к download-url и retry. Перед ротацией ключа завершите получение результатов задач, созданных старым ключом.
curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3" \
-H "Authorization: Bearer $SPICY_API_KEY"Запрос состояния бесплатен. Замените ID примера своим taskId.
Поля ответа
| Поле | Тип | Примечания |
|---|---|---|
taskId | string | ID задачи |
sourceTaskId | string | Только для задачи, созданной через повторную попытку: ID исходной задачи |
model | string | Идентификатор модели |
state | string | См. таблицу выше |
input | object | Проверенные параметры; встроенные изображения превращаются в URI файлов платформы. Поле отсутствует после истечения срока хранения входных данных |
output | object | Описание результата. Имеет смысл только при succeeded |
errorCode | string | Присутствует при state со значением failed или expired; для просроченной задачи — timeout. Одно из девяти значений, см. Коды неудачного завершения задач. Выбирайте обработку по нему, а не по errorMessage |
errorMessage | string | Понятная человеку причина неудачи, на английском, если не выбран другой язык. Только при неудачном завершении |
cost | string | Окончательная сумма либо резерв, если расчёт ещё не завершён; итоговое начисление не может превышать этот резерв |
settled | boolean | Завершён ли расчёт. Пока значение false, cost ещё может меняться |
createdAt | string | RFC 3339 |
deadlineAt | string | Срок выполнения, сохранённый при принятии; старые события вебхуков могут его не содержать |
completedAt | string | RFC 3339. Только для конечных состояний |
contentState | string | present, expired: удалено по сроку хранения, либо purged: уничтожено вами. См. Хранение и уничтожение данных |
contentRemovedBy | string | user или system; отсутствует, пока содержимое сохраняется |
purgedAt | string | RFC 3339. Только после уничтожения содержимого |
retention | object | outputsExpireAt: когда удаляются результаты, promptsExpireAt: когда удаляется промпт, и source: какая настройка определила срок |
Четыре разных срока
- Собственное время ожидания вы выбираете сами. Прекращение ожидания не отменяет удалённую задачу: сохраните
taskIdи позже запросите ту же задачу для продолжения. deadlineAt— срок выполнения на сервере. Идемпотентный повтор его не продлевает; явная повторная попытка создаёт новую задачу со своим сроком. Проверяйте конечное состояние иsettled, а не делайте вывод о возврате средств по локальному таймеру.output.assets[].expiresAt— истечение временной ссылки на результат, обычно через 20 минут. Повторный запрос обновляет ссылку без создания новой задачи.- Созданные медиа хранятся не дольше примерно 14 дней после завершения, а загруженные на платформу входные файлы — не дольше суток. Это пределы платформы: можно задать более короткие сроки в разделе Хранение и уничтожение данных или уничтожить содержимое одной задачи по запросу. Обновление ссылки не продлевает хранение самого медиафайла.
В JavaScript SDK client.run({ model, input }, { idempotencyKey, onAccepted, timeoutMs }) отправляет задачу один раз и опрашивает с растущим интервалом от 2 до максимум 10 секунд. Его timeoutMs — только локальное время ожидания, поэтому сохраняйте ID из onAccepted. См. SDK. Для фоновых задач предпочитайте вебхуки. Проверив полный обратный вызов с результатом, используйте результат напрямую без дополнительного запроса задачи.
Используйте URL результата напрямую
Готовые файлы содержат url и expiresAt вместе с key и метаданными. Скачивайте output.assets[].url напрямую, без API-ключа, например curl -L -o result.png "<url>". После истечения URL запросите задачу снова; /common/download-url остаётся необязательным для существующих интеграций. URL обычно действует 20 минут и не продлевает 14-дневное хранение результата.
{
"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"
}Успешный запрос не означает успешную задачу
Оба ответа содержат code: 200: запрос состояния выполнен успешно. Исход задачи указан в data.state. Бизнес-код описывает доставку ответа, а state — задачу. Не смешивайте их.
Тело вебхука использует ту же структуру: ту же обёртку code: 200 и те же поля data. Код разбора этого раздела обрабатывает обратный вызов без изменений: два способа получения, один разбор.
Структура output
У output две независимые части. Созданные файлы — изображения, видео, аудио — поступают в output.assets[]. Текстовые результаты находятся в output.text в виде обычной строки.
Большинство моделей заполняют только одну часть, но модель может заполнить обе. Это свойство эндпоинта, а не его modality: эндпоинт speech-to-text находится в категории audio каталога, поскольку читает аудио, а возвращает текст.
`output.assets` присутствует не всегда
У эндпоинта с текстовым результатом ключа assets вообще нет, а не просто пустой массив. Код, открывающий результат через task.output.assets[0].url, вызывает ошибку для таких задач, а не возвращает проверяемое значение. Сначала читайте output.text, если оно присутствует, а assets считайте необязательным:
const { text, assets = [] } = task.output ?? {};
if (text) console.log(text);
for (const asset of assets) console.log(asset.url);Для текстового результата нечего скачивать и нет истекающей ссылки, но текст остаётся частью результата задачи: он удаляется по тому же расписанию, что и остальные результаты, а contentState вместе с ним переходит в expired. Скопируйте всё нужное для хранения. Если результат приходит несколькими частями, например сегментами расшифровки, они объединяются в одну строку с переносами строк между частями.
Далее в этом разделе описаны assets[].
| Поле | Тип | Примечания |
|---|---|---|
key | string | Идентификатор совместимости для необязательного обновления ссылки скачивания. Пуст, пока pending |
url | string | Прямой подписанный GET URL. Отсутствует при ожидании или недоступности; не добавляйте API-ключ |
expiresAt | string | Время истечения этого URL в RFC 3339; запросите снова для обновления в пределах срока хранения |
mime | string | Тип содержимого, например image/png, video/mp4 |
width · height | integer | Размеры в пикселях. Отсутствуют для аудио и текста |
durationSeconds | number | Длительность в секундах. Только видео и аудио |
bytes | integer | Размер в байтах |
pending | boolean | См. ниже |
unavailable | boolean | См. ниже |
layer | object | Только для результатов разложения на слои: zIndex — целое число, boundingBox — необязательное [x1, y1, x2, y2], и необязательное name. См. ниже |
Отсутствующие данные не включаются в ответ, вместо значений null или 0. Разбирайте поля как необязательные.
Нужно обработать два флага:
pending: true: файл ещё копируется в наше хранилище, иkeyиurlпока пусты. Запросите снова через несколько секунд; вызовdownload-urlбезkeyсейчас возвращает409.unavailable: true: срок файла истёк либо он окончательно недоступен после неудачной передачи. Этот файл уже не вернётся; ожидание не поможет, нужна новая генерация. Если не удалось сохранить ни один файл успешной задачи, плата за неё не взимается.
Результаты разложения на слои содержат дополнительное поле layer. Первый файл — базовое изображение, то есть исходник с удалёнными выделенными элементами и zIndex 0; остальные — прозрачные PNG-слои. Сложите их в порядке возрастания zIndex, чтобы восстановить изображение. boundingBox имеет вид [x1, y1, x2, y2] в пикселях базового изображения: масштабируйте слой до этого прямоугольника, и он встанет на своё место. Модели, не сообщающие позиции, не включают поле; их слои всё равно пригодны как отдельные вырезанные элементы. name — собственное название от модели, которое присутствует только при его выдаче. В расчёт оплаты входит каждое возвращённое изображение, включая базовое.
4. Обратные вызовы в первую очередь, опрос как запасной вариант
Опрос — ваш код многократно запрашивает recordInfo до завершения задачи. Обратный вызов — мы один раз отправляем завершённую задачу на ваш сервер. Оба способа дают те же данные.
| Обратный вызов | Опрос | |
|---|---|---|
| Задержка | В момент завершения задачи | Интервал вашего опроса |
| Запросы | 1 на задачу, отправляем мы | N на задачу, отправляете вы |
| Расходует ваш лимит запросов | Нет | Да: см. Лимиты частоты запросов |
| Нужен публичный HTTPS-эндпоинт | Да | Нет |
Используйте обратные вызовы в рабочей среде. Трёхминутная видеозадача при опросе раз в секунду даёт 180 запросов почти без новой информации. Действующий предохранитель открытого API в 1000 запросов за 10 секунд не ограничивает обычную платную генерацию, но частый опрос создаёт лишний трафик, а бесконтрольный цикл исчерпывает собственную квоту чтения, пока recordInfo не начнёт отвечать 429.
Опрос разумен в двух случаях: локальная разработка и сервисы без публичного входящего подключения.
Увеличивайте интервал опроса
recordInfo использует квоту чтения, а createTask — записи, поэтому опрос не может лишить отправку задач её квоты. Но чрезмерный опрос заставит сам recordInfo возвращать 429, и вы перестанете видеть задачи, пока квота не пополнится.
Рабочий подход:
- Начните с 2–3 секунд. Меньший интервал бесполезен: даже самые быстрые модели изображений требуют нескольких секунд.
- Умножайте на 1,5, ограничивая интервал 10–15 секундами.
- Останавливайтесь по собственному сроку задачи, а не через фиксированное количество минут. Некоторые видеомодели могут работать до часа, поэтому фиксированный тайм-аут 10 минут прекращает ожидание ещё нормально выполняющихся задач. Используйте
deadlineAtиз ответаcreateTask, добавив несколько минут на фиксацию исхода сервером.
Прекращение ожидания не означает неудачу
Когда время ожидания закончилось, вы лишь перестали ждать. Задача продолжает выполняться, может завершиться успешно и быть оплачена. Не отправляйте её заново с новым Idempotency-Key: это создаст вторую платную задачу. Позже запросите тот же taskId или найдите его в истории задач.
// 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. Проверьте баланс
GET /api/v1/chat/credit возвращает available: средства для новых задач, held: резерв выполняющихся задач, и total. При использовании одобренного кредита available может быть отрицательным, а бонусные средства в funding.grants могут оплачивать только некоторые модели. Подробнее: Проверка баланса.
curl https://api.spicyapi.ai/api/v1/chat/credit \
-H "Authorization: Bearer $SPICY_API_KEY"История задач и восстановление
GET /api/v1/jobs перечисляет видимые задачи, созданные текущим API-ключом. Используйте его для поиска работы после перезапуска приложения или пропущенного обратного вызова. Задачи других ключей и скрытые записи исключены. Ответы содержат ID задачи, модель, состояние, стоимость, состояние расчёта, время и, если он был записан, requestId вызова создания задачи; для текстового вызова это то же значение, что заголовок X-Request-Id. Промпты, входные данные, результаты и ссылки на медиа в список не входят. Получите выбранную задачу через recordInfo, чтобы забрать результат.
Фильтруйте по state, точному model из каталога или псевдониму, а также датам UTC from / to: формат YYYY-MM-DD, начало включено, конец исключён. По умолчанию диапазон составляет семь дней до завтра по UTC; максимальный диапазон — 92 дня. Значение limit по умолчанию 20 и не может превышать 100.
Не передавайте ненужные фильтры: пустое значение, например model=, повторяющийся или неизвестный параметр возвращает 400. Запрос дольше пяти секунд возвращает 503; повторите с меньшим диапазоном дат.
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"Читайте data.items. Если data.hasMore равно true, передайте data.nextCursor как cursor следующего запроса, сохраняя все фильтры и явно заданные даты. Сортировка — по убыванию createdAt, затем ID задачи. Страницы отражают текущие состояния задач, а не замороженный снимок. Не создавайте курсоры самостоятельно. Получение истории никогда не создаёт и не повторяет задачу.
Та же функция доступна через client.listTasks(...), spicyapi tasks list и инструмент MCP только для чтения spicyapi_tasks_list. Храните USD cost как десятичную строку; если settled равно false, это всё ещё оценка зарезервированной суммы, а не окончательное начисление.
Если ответ создания потерян, сначала повторите неизменённый запрос с исходным Idempotency-Key. Пустая страница истории не даёт оснований снова создавать ту же платную работу с новым ключом.

