spicyapi문서
API로 개발하기
본문

비동기 작업

createTask·recordInfo 계약, 여섯 상태의 의미와 콜백·폴링 선택 방법입니다.

생성은 비동기입니다. 제출과 결과 수집은 별도 호출입니다.

쉽게 설명하면:

  1. 작업을 보내면 즉시 taskId를 받습니다. 아직 완료된 것이 아니며 잔액에서 가격만큼 선점합니다.
  2. 나중에 ‘이 taskId가 끝났나요?’라고 조회하는 것이 폴링입니다. 수초 간격이면 충분합니다. 또는 웹 주소를 전달하여 완료 후 결과를 받는 것이 콜백(webhook)입니다.
  3. state가 succeeded이면 data.output.assets[0].url에 파일 링크가 있습니다. 약 20분 안에 열거나 다운로드하고 만료되면 다시 조회해 새 링크를 받습니다. 성공 과금은 선점액을 넘지 않습니다. 실패·만료는 보통 선점을 해제하나 모델 페이지에 명시한 일부 콘텐츠 거절은 과금 예외가 있습니다. 최종 금액은 settled와 cost로 확인합니다.

왜 한 번에 처리하지 않을까요? 이미지는 수초, 영상은 흔히 수분이 걸립니다. 동기 호출은 프로세스를 연결에 묶으며 중간 장애가 나면 결과도 안전한 재시도 방법도 없이 이전 작업이 실행 중인지 알 수 없습니다. 두 호출을 나누면 작업 ID를 기준으로 재연결하여 같은 작업을 계속 조회할 수 있습니다.

1. 작업 생성

POST /api/v1/jobs/createTask

비용이 발생하는 호출입니다

승인된 작업은 즉시 잔액에서 estimatedCost를 선점하며 취소할 수 없습니다. 생성 없이 가격부터 보려면 요청 견적을 사용하세요. 키 지출 한도로 지출을 제한합니다. 과금 참조.

헤더

헤더필수설명
Authorization예Bearer sk-spicy-…
Content-Type예application/json
Idempotency-Key아니요강력 권장. 네트워크 오류 후 안전한 재시도. 멱등성 참조
X-Spicy-Retention아니요출력·프롬프트 보관 기간 단축, 초 단위. 보관과 폐기 참조

본문

속성

유형

callBackUrl은 서버가 인터넷으로 접근할 수 있는 공개 HTTPS 주소여야 합니다. 일반 http://, 명시 포트 443·80 외 값, 사용자 이름·비밀번호 포함 URL, 사설·루프백 IP, DNS로 해석되지 않는 호스트는 거절합니다. 콜백이 프롬프트와 서명된 결과 링크를 포함하기 때문입니다. 로컬 http://localhost는 작동하지 않으므로 폴링이나 공개 HTTPS 터널을 사용하세요. 잘못된 값은 HTTP 400, 비즈니스 code: 400, msg: "Invalid callback URL", request_id를 반환합니다. 오류는 URL, 호스트, IP나 DNS 정보를 되돌려 주지 않습니다. 연결 시 목적지를 다시 확인합니다. Webhook 참조.

대문자 B가 있는 callBackUrl을 사용하세요

필드별 검증은 input만 적용합니다. callback_url, webhookUrl 같은 알 수 없는 최상위 필드는 현재 무시되어 작업은 승인되지만 콜백이 오지 않습니다. 콜백을 기대했는데 없다면 표기부터 확인하세요.

응답

HTTP/1.1 202 Accepted
Location: /api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3
Retry-After: 2

HTTP는 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"
}
필드설명
taskIdjob_ 접두사 작업 ID. 이후 모든 단계에 필요하므로 저장
state새 작업은 queued, 같은 키의 멱등 반복은 원래 작업의 현재 상태
estimatedCost승인 시 선점액(예: USD 문자열 "0.008")이자 최대 과금액. 미사용액 해제, 이후 선점액 초과 징수 없음
deadlineAt서버가 작업을 수행하는 최종 기한. 미완료면 expired이며 과금 없음. 대기 시간 기준으로 사용

금액은 통화 기호 없는 문자열입니다

estimatedCost, cost와 잔액 세 필드는 "0.008" 같은 일반 십진 문자열입니다. $나 천 단위 구분자가 없으며 parseFloat / float()로 직접 읽을 수 있습니다.

대부분 언어에서 JSON 숫자는 float64가 되고 0.008은 정확한 이진 표현이 없기 때문에 문자열을 사용합니다. 부동소수점을 거치면 실제 과금액의 정확성을 보장하기 어려우므로 회계에는 십진 타입(decimal.Decimal, BigDecimal)으로 파싱하세요.

통화는 항상 USD입니다. 과금 참조.

생성 실패

첫 통합에서 흔히 만나는 오류입니다. createTask가 오류를 반환하면 선점·과금은 없습니다.

HTTPcode일반적인 의미대응
400400input 필드가 스키마에 없거나 범위 밖 또는 잘못된 callBackUrlinputSchema 비교. msg에서 원인 확인
40040004각 필드는 유효하지만 현재 조합 실행 불가해상도·길이 등 조합 변경
401401키 누락·오류·비활성Authorization 확인
40240201estimatedCost 선점에 잔액 부족콘솔에서 충전 후 같은 Idempotency-Key로 재전송
40240202키·팀·계정 지출 한도 도달상향 또는 일·월 한도 초기화 대기
40340301키에 이 모델 권한 없음다른 키 또는 모델 제한 변경
409409 / 40901멱등 충돌 또는 견적 불일치멱등성·견적 참조
413413본문 크기 초과, 주로 인라인 미디어업로드 후 spicy:// 전달. 미디어 참조
429429짧은 시간에 요청 과다Retry-After 후 같은 키 재전송
50350301모델 일시 사용 불가나중에 같은 키 재시도

전체 목록은 오류에 있습니다.

시작 전: 모델과 유효 입력 선택

아래 예시는 실제 모델 ID와 해당 모델이 받는 input이 필요합니다. 카탈로그의 includeExamples=1로 스키마 검증된 입력까지 얻으세요. 작은 JSON 명령줄 도구 jq가 필요합니다. 다른 미디어는 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이면 저장 예시가 없으므로 GET /api/v1/models/{model}에서 inputSchema를 읽고 직접 작성합니다. 모델 카탈로그와 스키마 참조.

예시

아래 MODEL_ID_FROM_CATALOG는 자리표시자입니다. 전체 본문을 최신 카탈로그에서 선택한 줄로 바꾸고 그대로 보내지 마세요.

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"

위의 202 본문, code: 200과 taskId를 확인하세요. taskId를 저장해야 결과를 수집합니다. 콜백은 JSON에 자체 HTTPS 주소의 "callBackUrl":"https://…"를 추가하세요.

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"])

202 응답이 항상 queued인 것은 아닙니다

첫 승인은 queued를 만듭니다. 같은 API 키가 같은 요청과 Idempotency-Key를 반복하면 202는 원래 작업의 현재 상태를 반환합니다. 이미 running이나 종료 상태일 수 있지만 두 번째 작업·선점은 없습니다.

2. 상태

queued
running
succeededfailedexpired
작업은 언제나 하나의 상태에만 머뭅니다. 요금이 청구되는 것은 succeeded뿐이고, 나머지 종료 상태에서는 잡아 둔 금액이 전액 돌아옵니다.

승인 작업은 되돌릴 수 없습니다. 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로 첫 폴링 생략

createTask에 ?wait=<seconds>를 붙이면 종료까지 연결을 유지하여 첫 호출의 폴링 루프를 생략할 수 있습니다.

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"}}'
결과응답
대기 시간 안에 종료200과 작업 기록. recordInfo와 같은 형식
대기 시간 소진wait 생략과 동일한 일반 202. 이후 폴링
연결 해제작업에는 변화 없음. 계속 실행·일반 과금

종료가 반드시 성공은 아닙니다

여기서 200은 종료를 뜻합니다. data.state를 확인하세요. recordInfo처럼 failed, expired도 같은 상태 코드입니다.

상한은 60초입니다. 큰 값은 거절 대신 상한으로 제한하고 양의 정수가 아닌 값은 거절 대신 무시합니다. 최적화 매개변수의 오타로 정상 생성까지 실패하지 않도록 합니다.

상한은 API 앞단의 100초 엣지 시간 초과 때문입니다. 이를 넘으면 플랫폼이 아니라 CDN이 연결을 끊어 ID·재개 상태 없는 524를 받지만 작업은 존재하고 금액도 선점되어 있습니다. 60초는 요청 자체에 여유를 둡니다.

이미지에 활용하세요. 영상에는 적합하지 않습니다

영상은 흔히 수분이 걸려 wait가 시간 초과되고 연결 지연을 감수한 뒤 결국 폴링해야 합니다. 대부분 이미지·편집처럼 수초에 끝나는 모델에는 유용하지만 그 외에는 이점이 없습니다. 의도적으로 기본값 설정은 제공하지 않습니다.

운영 환경은 여전히 webhook이 낫습니다. 연결을 유지하지 않고 어떤 대기 시간보다 오래 걸린 작업도 알 수 있습니다.

3. 작업 조회

GET /api/v1/jobs/recordInfo?taskId=job_…

필수 쿼리는 taskId 하나입니다. 생성한 API 키만 조회할 수 있습니다. 같은 계정의 다른 키나 교체 키도 없는 작업과 같은 404를 받아 오류 차이로 타인 ID를 탐색하지 못합니다. download-url과 retry도 같습니다. 교체 전에 이전 키의 결과 수집을 마치세요.

curl
curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3" \
  -H "Authorization: Bearer $SPICY_API_KEY"

조회는 무료입니다. 예시 ID를 자신의 taskId로 바꾸세요.

응답 필드

필드유형설명
taskId문자열작업 ID
sourceTaskId문자열재시도 작업에만 존재. 원본 작업
model문자열모델 식별자
state문자열위 상태표 참조
input객체검증된 매개변수. 인라인 이미지는 플랫폼 URI로 변환. 입력 보관 만료 후 생략
output객체결과 설명. succeeded에서만 유효
errorCode문자열failed·expired에서 존재(만료는 timeout). 9가지 작업 실패 코드. errorMessage 대신 이 코드로 분기
errorMessage문자열실패 시만 표시. 다른 언어 선택 없으면 영어
cost문자열정산액 또는 미정산 선점액. 최종 과금은 선점액을 넘지 않음
settled불리언과금 확정 여부. false이면 cost가 아직 바뀔 수 있음
createdAt문자열RFC 3339
deadlineAt문자열승인 시 저장된 실행 기한. 오래된 webhook 이벤트는 생략 가능
completedAt문자열RFC 3339. 종료 상태만
contentState문자열present, 보관 만료 제거 expired, 직접 폐기 purged. 보관과 폐기 참조
contentRemovedBy문자열user 또는 system. 콘텐츠 존재 시 생략
purgedAt문자열RFC 3339. 직접 폐기 후만
retention객체outputsExpireAt(출력 삭제), promptsExpireAt(프롬프트 삭제), source(결정한 설정)

서로 다른 네 기한

  • 자체 대기 시간은 직접 정합니다. 대기를 멈춰도 원격 작업은 취소되지 않습니다. taskId를 보관하고 나중에 같은 작업을 조회하세요.
  • deadlineAt은 서버 실행 기한입니다. 멱등 반복은 연장하지 않고 명시적 재시도는 자체 기한의 새 작업을 만듭니다. 로컬 타이머로 환불을 추측하지 말고 종료 상태와 settled를 읽으세요.
  • output.assets[].expiresAt은 임시 결과 링크 만료로 보통 20분입니다. 다시 조회하면 새 작업 없이 링크를 갱신합니다.
  • 생성 미디어는 완료 후 최대 약 14일, 플랫폼 업로드 입력은 최대 하루입니다. 보관과 폐기에서 단축하거나 작업 콘텐츠를 즉시 폐기할 수 있습니다. 링크 갱신은 미디어 보관 기간을 늘리지 않습니다.

JavaScript SDK의 client.run({ model, input }, { idempotencyKey, onAccepted, timeoutMs })은 한 번 제출하고 2초부터 최대 10초로 간격을 늘려 조회합니다. timeoutMs는 로컬 대기 시간뿐이므로 onAccepted에서 ID를 저장하세요. SDK 참조. 백그라운드 작업은 webhook을 권장합니다. 완전한 결과 콜백을 검증한 뒤 추가 조회 없이 결과를 사용하세요.

결과 URL을 직접 사용하세요

준비된 결과는 key·미디어 정보와 함께 url, expiresAt을 담습니다. API 인증 없이 output.assets[].url을 직접 받으세요(예: curl -L -o result.png "<url>"). 만료 후 다시 조회하고 기존 통합의 /common/download-url은 선택 사항입니다. 링크는 보통 20분이며 14일 보관을 연장하지 않습니다.

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"
}

조회 성공과 작업 성공은 다릅니다

두 응답 모두 code: 200이며 조회 성공을 뜻합니다. 작업 결과는 data.state입니다. 비즈니스 코드는 전달 성공 여부를 설명하고 state는 작업 결과를 설명합니다. state와 비즈니스 코드를 혼동하지 마세요.

Webhook도 같은 구조인 code: 200·data를 사용합니다. 이 응답을 파싱하는 코드로 콜백도 그대로 파싱할 수 있습니다. 두 수신 방법에 파서 하나입니다.

출력 형식

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문자열선택적 링크 갱신의 호환 식별자. pending 동안 비어 있음
url문자열직접 서명 GET URL. 대기·사용 불가 시 생략. API 키를 붙이지 않음
expiresAt문자열URL의 RFC 3339 만료. 보관 중 재조회하여 갱신
mime문자열콘텐츠 유형. 예: image/png, video/mp4
width · height정수픽셀 크기. 오디오·텍스트에는 없음
durationSeconds숫자초 단위 길이. 영상·오디오만
bytes정수바이트 크기
pending불리언아래 참조
unavailable불리언아래 참조
layer객체레이어 분해만 존재. 정수 zIndex, 선택적 boundingBox([x1, y1, x2, y2]), 선택적 name. 아래 참조

없는 필드는 생략되며 null·0이 아닙니다. 선택 필드로 파싱하세요.

다음 두 플래그를 처리해야 합니다.

  • pending: true — 저장소 복사 중이며 key, url은 아직 비었습니다. 수초 후 재조회하세요. 지금 key 없이 download-url을 호출하면 409입니다.
  • unavailable: true — 만료 또는 영구 전송 실패입니다. 기다려도 복구되지 않으므로 다시 생성하세요. 성공 작업의 파일을 하나도 저장하지 못하면 과금하지 않습니다.

레이어 분해는 layer를 추가합니다. 첫 결과는 분리 요소를 제거한 기본 이미지(zIndex 0)이며 나머지는 투명 PNG입니다. zIndex 오름차순으로 쌓으면 재구성됩니다. boundingBox는 기본 이미지 픽셀의 [x1, y1, x2, y2]이며 상자에 맞게 레이어를 조정하면 원위치에 놓입니다. 위치를 보고하지 않는 모델은 생략하지만 독립 컷아웃으로 쓸 수 있습니다. name은 모델이 제공한 경우만 있는 자체 라벨입니다. 기본 이미지를 포함한 모든 반환 이미지가 과금 수량입니다.

4. 콜백 우선, 폴링은 보조

폴링은 완료까지 recordInfo를 반복 조회하는 방식입니다. 콜백은 완료 작업을 서버로 한 번 보내는 방식입니다. 데이터는 같습니다.

콜백폴링
지연완료 즉시폴링 간격
요청작업당 1회, 플랫폼 발송작업당 N회, 이용자 호출
호출 허용량 사용아니요예. 호출 제한 참조
공개 HTTPS 필요예아니요

운영은 콜백을 사용하세요. 3분 영상에 1초마다 폴링하면 새 정보가 거의 없는 180회 요청입니다. 현재 10초당 1,000회의 공개 API 안전장치는 일반 유료 생성을 제약하지 않지만 빠른 폴링은 불필요한 트래픽을 만들고 통제되지 않는 루프는 읽기 허용량을 소진하여 recordInfo가 429를 반환합니다.

폴링은 로컬 개발과 공개 수신 주소 없는 서비스에 적합합니다.

폴링 간격 늘리기

recordInfo는 읽기, createTask는 쓰기 버킷을 사용하므로 폴링이 제출을 막지는 않습니다. 다만 과도한 폴링은 recordInfo 자체에 429를 유발하여 버킷이 보충될 때까지 작업 상태를 볼 수 없습니다.

권장 방식입니다.

  • 2–3초부터 시작. 더 짧으면 낭비입니다. 가장 빠른 이미지도 수초 걸립니다.
  • 1.5배씩 늘리고 10–15초로 제한합니다.
  • 고정 분 수가 아니라 작업 자체 기한에 종료합니다. 일부 영상은 최대 한 시간이라 고정 10분 대기는 정상 실행 중 포기할 수 있습니다. createTask의 deadlineAt에 서버 기록 여유 수분을 더하세요.

대기 포기는 작업 실패가 아닙니다

대기를 마치면 기다리기를 멈춘 것뿐입니다. 작업은 계속 실행하고 성공·과금될 수 있습니다. 새 Idempotency-Key로 제출하지 마세요. 두 번째 작업에 비용을 지불합니다. 나중에 같은 taskId를 조회하거나 작업 내역에서 찾으세요.

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. 잔액 확인

GET /api/v1/chat/credit는 available(새 작업 자금), held(진행 작업 선점)와 total을 반환합니다. 승인된 신용을 사용 중이면 available이 음수일 수 있고 funding.grants의 프로모션 잔액은 일부 모델에만 사용될 수 있습니다. 잔액 확인 참조.

curl
curl https://api.spicyapi.ai/api/v1/chat/credit \
  -H "Authorization: Bearer $SPICY_API_KEY"

작업 내역과 복구

GET /api/v1/jobs는 현재 키가 생성한 표시 가능한 작업을 나열합니다. 앱 재시작·콜백 누락 후 작업을 찾는 데 쓰세요. 다른 키 작업과 숨긴 기록은 제외합니다. 응답은 ID, 모델, 상태, 비용, 정산 상태, 시각과 기록된 경우 생성 호출의 requestId(텍스트 호출의 X-Request-Id 헤더)를 담으며 프롬프트·입력·출력·미디어 링크는 없습니다. 선택 작업은 recordInfo로 결과를 조회합니다.

state, 정확한 카탈로그 model이나 별칭, UTC from / to 날짜(YYYY-MM-DD, 시작 포함·끝 제외)로 필터합니다. 기본은 UTC 내일 전까지 7일, 최대 92일입니다. limit 기본 20, 최대 100입니다.

쓰지 않는 필터는 생략하세요. model= 같은 빈 값, 반복·알 수 없는 매개변수는 400입니다. 조회가 5초를 넘으면 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로 같은 요청을 반복하세요. 빈 내역 페이지가 새 키로 같은 유료 작업을 다시 생성해도 된다는 뜻은 아닙니다.

이 페이지에서