TypeScript SDK
TypeScript SDK @spicyapi/sdk를 설치하고 첫 결과를 받는 데까지 단계별로 안내하고, 모든 메서드·옵션·오류를 쉬운 말로 찾아볼 수 있게 정리했습니다. Python, Go, PHP, Java SDK는 각자의 페이지에 정리돼 있습니다.
@spicyapi/sdk는 SpicyAPI의 공식 TypeScript·JavaScript 라이브러리입니다. 이 페이지는 SDK를 한 번도 써 본 적이 없다고 가정합니다. 빈 폴더에서 시작해 이미지 파일을 저장하기까지 함께 따라간 다음, 패키지가 제공하는 함수를 하나도 빠짐없이 설명합니다. 각 함수가 무엇에 쓰이는지, 무엇을 넘기는지, 무엇이 돌아오는지, 비용이 드는지, 어떤 문제가 생길 수 있는지까지 다룹니다.
SDK란
SDK는 내 프로그램에 추가해 쓰는 완성된 코드 라이브러리입니다. SDK가 없으면 프로그램이 HTTP 요청을 하나하나 직접 만들어야 합니다. API 키를 붙이고, JSON으로 온 응답을 데이터로 바꾸고, 작업이 끝났는지 몇 번이고 다시 묻고, 네트워크가 끊기면 재시도하고, 파일은 세 단계로 나눠 업로드하고, 웹훅이 올 때마다 서명을 확인해야 합니다. SDK가 이 모든 일을 대신 해 주므로 생성 한 번이 호출 한 번으로 끝납니다.
const task = await client.run({ model: modelId, input: { prompt: "A paper lantern at night" } });Node.js(JavaScript 또는 TypeScript)로 프로그램을 작성하고 있거나 작성하려는 참이라면 SDK가 잘 맞습니다. 그 프로그램이 서버에서 돌든, 내 컴퓨터의 스크립트로 돌든, 백그라운드 처리로 돌든 상관없습니다. TypeScript를 쓰면 모든 필드에 자동 완성과 타입 검사도 적용됩니다.
나에게 맞는 도구인가요?
| 하고 싶은 일 | 쓸 도구 |
|---|---|
| 내 Node.js나 TypeScript 코드에서 SpicyAPI 호출하기 | 이 SDK |
| 코드를 짜지 않고 터미널에서 생성하기 | CLI |
| Claude Code나 Cursor 같은 AI 어시스턴트가 대신 SpicyAPI를 호출하게 하기 | MCP 서버와 Agent Skill |
| 채팅·텍스트 모델 쓰기 | SpicyAPI를 가리키도록 설정한 공식 OpenAI 클라이언트. 텍스트와 스트리밍을 보세요 |
| Python, Go, PHP, Java 쓰기 | 공식 Python SDK, Go SDK, PHP SDK, Java SDK |
| 그 밖의 언어 쓰기 | 일반 HTTP 호출, 또는 API 레퍼런스로 생성한 클라이언트 |
SDK가 하지 않는 일
- 브라우저나 모바일 앱에서는 실행되지 않습니다. Node.js가 필요하고, API 키는 절대 내 서버 밖으로 나가면 안 됩니다. 웹 페이지나 앱에 들어간 키는 누구나 꺼내 볼 수 있습니다. 웹사이트는 내 백엔드를 호출하고, 백엔드가 SpicyAPI를 호출하게 하세요.
- 채팅 모델은 다루지 않습니다. 텍스트 생성은 OpenAI 호환 엔드포인트를 쓰며, 공식
openai패키지가 이미 그 일을 잘 해냅니다. 텍스트와 스트리밍을 보세요. - 모델을 골라 주거나 필드를 채워 주지 않습니다. 모델마다 받는 필드가 다릅니다. SDK는 모델 목록과 모델별 스키마(받을 수 있는 필드의 목록)를 건네줄 뿐이고, 무엇을 보낼지는 직접 정합니다.
- 작업을 취소할 수 없습니다. SpicyAPI가 작업을 접수하면 끝까지 실행됩니다. 내 프로그램을 멈춰도 멈추는 것은 내 쪽의 기다림뿐입니다.
- 계정을 관리하지 않습니다. 충전, 키 만들기, 지출 한도, 웹훅 서명 키는 콘솔에서 설정합니다.
API 키는 서버에만 두세요
SPICY_API_KEY를 브라우저 JavaScript, 모바일 앱, 공개 저장소, 스크린샷, 채팅 메시지에 절대 넣지 마세요. 유출된 키는 비활성화하기 전까지 내 잔액을 쓸 수 있습니다. SDK는 키를 환경 변수에서 읽으므로 코드에 키를 적을 일이 없습니다.
이 페이지에서 쓰는 용어
| 용어 | 이 페이지에서의 뜻 |
|---|---|
| 모델 ID | 모델의 정확한 이름으로, publisher/model/task 형태입니다. 기억에 의존하지 말고 언제나 실시간 카탈로그(listModels())에서 복사하세요 |
| 입력(input) | 작업과 함께 보내는 설정값으로, prompt나 image_url 같은 것입니다. 모델마다 허용하는 필드 목록이 따로 있으며, 이 목록을 스키마라고 합니다 |
| 작업(task) | 생성 일 하나입니다. 작업을 만들면 SpicyAPI가 백그라운드에서 처리하고, 나는 나중에 결과를 받아 갑니다. 작업마다 job_으로 시작하는 ID가 있습니다 |
| 예약(hold) | 작업이 접수되면 그 작업의 최대 가격만큼이 잔액에서 따로 떼어집니다. 실패하거나 만료된 작업은 보통 예약 금액이 전액 해제됩니다. 다만 모델 페이지에 명시된 일부 콘텐츠 거부는 접수 시 견적 금액으로 과금됩니다. 최종 청구액은 settled와 cost로 확인하세요. |
| 멱등 키 | 처리할 일 하나에 내가 지어 붙이는 이름표로, 예를 들면 order-8814-image-1입니다. 같은 요청을 같은 이름표로 두 번 보내면 SpicyAPI는 두 번째 작업을 만들어 과금하는 대신 첫 번째 작업을 돌려줍니다 |
| 웹훅 | 작업이 끝나면 SpicyAPI가 호출해 주는 내 웹 주소입니다. 덕분에 계속 물어보지 않아도 됩니다 |
할 수 있는 일
따로 적혀 있지 않으면 아래 함수는 모두 클라이언트의 메서드입니다(client.listModels()). "비용 발생"은 그 호출 때문에 잔액에서 돈이 청구될 수 있다는 뜻입니다.
| 하고 싶은 일 | 메서드 | 비용 발생? |
|---|---|---|
| SpicyAPI에 연결되는지 확인하기(키 불필요) | getStatus | 아니요 |
| 실시간 모델 목록, 내 가격, 모델별 필드 보기 | listModels, getModel | 아니요 |
| 돈을 쓰기 전에 요청의 정확한 가격 받기 | quoteTask | 아니요 |
| 생성 시작하기 | createTask | 예 |
| 생성을 시작하고 결과까지 한 번에 기다리기 | run | 예 |
| 작업을 한 번 조회하기 | getTask | 아니요 |
| 작업이 끝날 때까지 기다리기 | waitForTask | 아니요 |
| 실패한 작업 다시 시도하기 | retryTask | 예 (새 작업) |
| 내 키로 만든 작업 목록 보기 | listTasks | 아니요 |
| 잔액 확인하기 | getBalance | 아니요 |
| 날짜별·모델별 사용액 보기 | getUsage | 아니요 |
| 내 컴퓨터의 이미지·영상·오디오 파일 업로드하기 | uploadFile, uploadBytes, uploadBase64 | 아니요 |
| 결과의 새 다운로드 링크 받기 | createDownloadUrl | 아니요 |
| 끝난 작업의 저장 파일과 프롬프트 삭제하기 | purgeTask | 아니요 (환불도 없음) |
| 웹훅이 정말 SpicyAPI에서 왔는지 확인하기 | verifyWebhook (일반 함수) | 아니요 |
| 오프라인에서 문서를 검색하거나 API 계약 읽기 | searchDocumentation, readOpenApiContract (일반 함수) | 아니요 |
시작하기 전에
10분 정도면 됩니다. 단계마다 명령이 무슨 일을 하는지 적어 두었으니, 무엇인지 모른 채 실행하는 명령은 없습니다.
Node.js 22.13 이상 설치하기
Node.js는 브라우저 밖에서 JavaScript를 실행해 주는 프로그램입니다. nodejs.org에서 LTS 버전을 설치하세요. 22.13 이상이면 어느 버전이든 됩니다. 그런 다음 터미널(macOS는 터미널 앱, Windows는 PowerShell)을 열고 확인합니다.
node -v설치된 버전이 출력됩니다. 예를 들면 v22.13.0이나 v24.8.0입니다. 숫자가 22.13보다 낮거나 명령을 찾을 수 없다고 나오면 Node.js를 다시 설치하고 터미널 창을 새로 여세요.
프로젝트 폴더를 만들고 SDK 설치하기
아래 명령을 한 줄씩 실행하세요. macOS, Linux, Windows PowerShell에서 똑같이 동작합니다.
mkdir spicy-demo
cd spicy-demo
npm init -y
npm pkg set type=module
npm install @spicyapi/sdk| 명령 | 하는 일 |
|---|---|
mkdir spicy-demo | 프로젝트용 빈 폴더를 만듭니다 |
cd spicy-demo | 터미널을 그 폴더로 옮깁니다. 이후의 명령은 이 폴더를 대상으로 실행됩니다 |
npm init -y | 프로젝트의 설정과 라이브러리 목록을 담는 파일인 package.json을 만듭니다 |
npm pkg set type=module | 내 파일이 최신 import 문법을 쓴다고 Node.js에 알려 줍니다. 이 SDK는 이 설정이 필요합니다 |
npm install @spicyapi/sdk | SDK를 node_modules 폴더에 내려받고 package.json에 기록합니다 |
SDK는 최신 ES 모듈 형식으로만 배포됩니다. import { SpicyClient } from "@spicyapi/sdk"는 동작하지만, 예전 방식인 require("@spicyapi/sdk")는 ERR_PACKAGE_PATH_NOT_EXPORTED 오류로 실패합니다. npm pkg set type=module을 건너뛰었다면 대신 파일 확장자를 .mjs로 지으세요.
API 키 만들기
- spicyapi.ai에 로그인합니다. 아직 계정이 없다면 spicyapi.ai/register에서 가입하세요. 신규 가입이 잠시 중단된 동안에는 그 페이지에서 대기자 명단에 등록할 수 있습니다.
- 콘솔의 API 키 페이지를 열고 키 만들기를 누릅니다. 이름만 입력하면 됩니다.
- 키를 바로 복사하세요. 키는
sk-spicy-로 시작하며 한 번만 표시됩니다. 잃어버렸다면 새로 만드세요.
같은 페이지에서 일 지출 한도를 걸 수 있는데, 새 키에는 추가하기 전까지 한도가 없습니다. 유료 작업을 실행하려면 잔액도 있어야 하며, 잔액은 결제에서 충전합니다.
키를 환경 변수에 넣기
환경 변수는 터미널이 자신이 실행하는 모든 프로그램에 넘겨주는, 이름이 붙은 값입니다. SDK는 SPICY_API_KEY라는 환경 변수를 찾으므로 코드에 키를 적을 필요가 없습니다.
# 이 터미널 창에만 설정합니다 (내 키를 붙여 넣으세요)
export SPICY_API_KEY="sk-spicy-paste-your-key-here"
# 확인: sk-spicy- 가 출력되어야 합니다
echo "${SPICY_API_KEY:0:9}"창을 닫으면 값이 사라집니다. 내 컴퓨터에 계속 남겨 두려면 export 줄을 ~/.zshrc(macOS)나 ~/.bashrc(Linux)에 추가하고 새 창을 여세요.
.env 파일이 더 편한가요? 프로젝트 폴더에 .env라는 파일을 만들어 SPICY_API_KEY=sk-spicy-paste-your-key-here 한 줄을 넣고, node --env-file=.env yourfile.mjs로 프로그램을 시작하세요. .env 파일을 읽는 것은 SDK가 아니라 이 옵션입니다. git add를 한 번이라도 실행하기 전에 .env를 .gitignore 파일에 추가해서 키가 커밋되는 일이 없게 하세요.
실제 서버에서는 호스팅 서비스의 시크릿 설정이나 환경 변수 설정에 SPICY_API_KEY를 넣거나, 시크릿 관리 도구에서 가져온 키를 new SpicyClient({ apiKey })로 넘기세요.
첫 파일 실행하기
프로젝트 폴더에서 아무 텍스트 편집기로 check.mjs라는 파일을 만들고, 아래 내용을 붙여 넣어 저장하세요.
import { SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
const status = await client.getStatus();
console.log(status.operational ? "SpicyAPI is up" : "SpicyAPI is not reachable right now");
console.log("Checked at", status.checkedAt);실행합니다.
node check.mjsSpicyAPI is up
Checked at 2026-09-17T08:00:00.000Z이 확인은 키가 필요 없고 비용도 들지 않습니다. 이렇게 출력되면 Node.js와 SDK가 제대로 설치된 것입니다.
TypeScript를 쓰나요? 같은 코드를 check.ts로 저장하세요. 최근의 Node.js 22와 24 릴리스는 node check.ts로 바로 실행합니다. .ts 확장자 때문에 오류가 난다면 node --experimental-strip-types check.ts나 npx tsx check.ts로 실행하세요.
첫 결과 받기
아래 프로그램은 전 과정을 처음부터 끝까지 수행합니다. 서비스 상태를 확인하고, 내 키로 쓸 수 있는 이미지 모델을 고르고, 정확한 가격을 받은 뒤 멈춥니다. --yes를 붙여 한 번 더 실행하면 이미지를 만들고, 완성될 때까지 기다린 다음 스크립트와 같은 폴더에 저장합니다.
--yes를 붙여 실행하면 비용이 듭니다
--yes 없이 실행하면 읽기만 합니다. 아무것도 만들지 않고 아무것도 과금되지 않습니다. --yes를 붙이면 이미지 작업을 하나 만들며, 견적 금액이 예약되고 이미지가 성공하면 그 금액이 과금됩니다.
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { SpicyApiError, SpicyClient } from "@spicyapi/sdk";
// SPICY_API_KEY 환경 변수에서 키를 읽습니다.
const client = new SpicyClient();
async function main() {
// 1단계 — SpicyAPI에 연결되나요? 무료이며 키가 필요 없습니다.
const status = await client.getStatus();
if (!status.operational) {
throw new Error("SpicyAPI is not reachable right now. Try again in a few minutes.");
}
// 2단계 — 이 키로 호출할 수 있는 이미지 모델은 무엇인가요? 무료입니다.
const catalog = await client.listModels({ modality: "image", includeExamples: true });
const model = catalog.items.find(
(item) => item.enabled && item.available && item.examples?.length,
);
if (!model) throw new Error("No image model is available to this key right now.");
console.log(`Model: ${model.displayName} (${model.model})`);
// 3단계 — 입력을 만듭니다. 서버가 이미 검증한 예시에서 출발하고,
// 모델이 프롬프트를 받는다면 내 프롬프트로 바꿉니다.
const input = { ...model.examples?.[0]?.input };
if ("prompt" in input) input.prompt = "A paper lantern glowing on a rainy street at night";
// 4단계 — 정확한 가격을 받습니다. 무료이며, 아무것도 만들거나 예약하지 않습니다.
const request = { model: model.model, input };
const quote = await client.quoteTask(request);
console.log(`Price: $${quote.estimatedCost} USD`);
if (!process.argv.includes("--yes")) {
console.log("Nothing was charged. Run again with --yes to generate the image.");
return;
}
// 5단계 — 작업을 만들고 기다립니다. 비용이 드는 단계는 여기입니다.
const task = await client.run(
{ ...request, quoteId: quote.quoteId, expectedCost: quote.estimatedCost },
{
idempotencyKey: randomUUID(),
onAccepted: (accepted) => console.log(`Task accepted: ${accepted.taskId}`),
onUpdate: (update) => console.log(`State: ${update.state}`),
},
);
// 6단계 — 실패한 작업도 정상 응답으로 반환됩니다. 정산 결과를 확인합니다.
if (task.state !== "succeeded") {
console.log(`The task ended as ${task.state}: ${task.errorCode} ${task.errorMessage ?? ""}`);
console.log("Check settled and cost for the final charge.");
return;
}
// 7단계 — 결과를 지금 내려받습니다. 링크는 약 20분 뒤에 만료됩니다.
const asset = task.output?.assets?.find((item) => item.url);
if (!asset?.url) throw new Error("The task succeeded but its file is unavailable.");
const download = await fetch(asset.url); // 여기에는 절대 API 키를 보내지 마세요
if (!download.ok) throw new Error(`Download failed with HTTP ${download.status}`);
const extension = asset.mime?.split("/")[1] ?? "png";
await writeFile(`result.${extension}`, Buffer.from(await download.arrayBuffer()));
console.log(`Saved result.${extension}. Charged: $${task.cost}${task.settled ? "" : " (not final yet)"}`);
}
main().catch((error) => {
if (error instanceof SpicyApiError) {
console.error(`SpicyAPI error ${error.code}: ${error.message} (request_id ${error.requestId})`);
} else {
console.error(error);
}
process.exitCode = 1;
});두 번 실행합니다.
node first-result.mjs
node first-result.mjs --yesModel: <display name> (<model ID>)
Price: $<amount> USD
Nothing was charged. Run again with --yes to generate the image.
Model: <display name> (<model ID>)
Price: $<amount> USD
Task accepted: job_…
State: running
State: succeeded
Saved result.png. Charged: $<amount>각 단계가 하는 일
getStatus(): SpicyAPI의 상태 확인 엔드포인트에 서비스가 살아 있는지 묻습니다. 네트워크 문제가 있어도 예외를 던지지 않고, 대신operational: false로 알려 줍니다.listModels(): 내 계정의 가격이 적용된 실시간 모델 카탈로그를 돌려줍니다.enabled와available이 모두true인 항목만 쓰세요.includeExamples: true를 주면 서버가 이미 검증한 입력 예시가 함께 오며, 이것이 가장 안전한 출발점입니다. 나중에 보낼 값은 항목의model필드, 즉 모델 ID입니다.- 입력. 모델마다 받는 필드가 다르며, 모델이 모르는 필드를 보내면
400오류로 거부됩니다. 검증된 예시를 복사해 프롬프트만 바꾸면 이 문제를 피할 수 있습니다. 모델이 받는 필드를 모두 보려면getModel()을 호출해inputSchema를 읽으세요. quoteTask(): 바로 이 요청의 가격을 계산합니다. 아무것도 만들지 않고 아무것도 예약하지 않습니다. 금액은"0.04"처럼 텍스트로 적은 미국 달러이므로 반올림 오차가 끼어들 틈이 없습니다.run(): 작업을 만든 다음 끝날 때까지 상태를 확인합니다.quoteId와expectedCost를 넘기는 것은 "안내받은 가격일 때만 접수하라"는 뜻입니다. 그사이 가격이 바뀌었다면 다른 금액을 과금하는 대신40901로 실패합니다.idempotencyKey는 네트워크 재시도를 안전하게 만들어 줍니다. 실제 앱에서는 호출하기 전에 이 키를 저장해 두어, 프로그램이 다시 시작돼도 같은 키를 쓸 수 있게 하세요.onAccepted는 작업이 생기는 순간 작업 ID를 넘겨줍니다. 이 ID를 저장해 두면 프로그램이 멈추더라도 나중에 작업을 확인할 수 있습니다.- 작업 상태.
run()은 작업이 실패해도 정상적으로 반환됩니다.state를 확인하세요. 실패하거나 만료된 작업은 보통 예약 금액이 전액 해제됩니다. 다만 모델 페이지에 명시된 일부 콘텐츠 거부는 접수 시 견적 금액으로 과금됩니다. 최종 청구액은settled와cost로 확인하세요. - 다운로드. 결과마다 임시
url이 딸려 옵니다. API 키 없이 내려받아 사본을 직접 보관하세요. 링크는 약 20분 동안 유효하고(새 링크가 필요하면getTask()를 다시 호출하세요), SpicyAPI는 생성된 파일을 늦어도 약 14일 뒤에 삭제합니다. 데이터 보관과 파기를 보세요.
영상도 똑같이 동작하지만 몇 분이 걸릴 수 있으므로 run()에 더 긴 timeoutMs를 넘기세요(기본 대기 시간은 10분). 아래 코드 조각에서 쓰는 모델 ID는 MODEL_ID_FROM_CATALOG로 적혀 있습니다. 실제 모델이 아니라 자리표시자입니다. 실시간 카탈로그(SDK의 listModels(), 또는 인증이 필요한 GET /api/v1/models 엔드포인트)에서 enabled와 available이 모두 true인 항목을 골라, 그 model 값으로 바꾸세요.
API 레퍼런스
아래 항목마다 메서드가 무엇에 쓰이는지, 어떻게 호출하는지, 무엇이 돌아오는지, 비용이 들 수 있는지, 어떤 오류를 예상해야 하는지 적었습니다. SpicyApiError 같은 오류 클래스는 오류 처리에서 설명합니다. 금액은 모두 텍스트로 적은 미국 달러("0.04")이고, 시각은 모두 UTC 기준의 ISO 8601 문자열입니다.
클라이언트 만들기
new SpicyClient(options?: SpicyClientOptions)대부분의 프로그램은 아무것도 넘기지 않습니다. new SpicyClient()는 SPICY_API_KEY에서 키를 읽고 운영 서비스에 연결합니다. 옵션이 잘못됐으면 요청을 하나도 보내기 전에 곧바로 TypeError를 던집니다.
속성
유형
자동 재시도
SDK는 두 번 과금될 위험이 없을 때만 요청을 스스로 재시도합니다.
| 호출 | 자동으로 재시도하나요? |
|---|---|
조회: getStatus, listModels, getModel, getTask, listTasks, getBalance, getUsage, 그리고 quoteTask, purgeTask, commitUploadedFile | 예 |
createTask, run(작업을 만드는 단계), retryTask | idempotencyKey를 넘긴 경우에만 재시도합니다. 키가 없으면 다시 보냈다가 작업이 두 개 생길 수 있으므로 네트워크 오류를 그대로 던집니다 |
createUploadUrl, 바이트 업로드 자체, createDownloadUrl | 아니요 |
재시도는 응답이 아예 없을 때(네트워크 오류 또는 로컬 타임아웃)나 응답이 HTTP 408, 429, 500, 502, 503, 504일 때 일어납니다. 쉬는 시간은 서버의 Retry-After 헤더가 요구하는 만큼이고, 그 헤더가 없으면 250 ms, 500 ms, 1 s 하는 식으로 늘어납니다. 각 값은 최대 25%까지 달라지며 maxRetryDelayMs를 넘지 않습니다. 모든 시도가 실패하면 마지막 오류를 받습니다.
signal로 호출 멈추기
모든 메서드는 표준 AbortSignal인 signal 옵션을 받습니다. 마감 시간을 두려면 AbortSignal.timeout(5_000)을, 원할 때 멈추려면 AbortController를 쓰세요. 멈춘 호출은 중단 사유(abort reason)를 던집니다. 멈추는 것은 내 요청이나 기다림뿐입니다. SpicyAPI가 이미 접수한 작업은 계속 실행되며 과금될 수도 있습니다.
getStatus()
SpicyAPI에 연결되는지 확인합니다. 스크립트의 첫 줄에 두거나, 내 모니터링에서 상태 확인용으로 쓰기 좋습니다.
getStatus(options?: { signal?: AbortSignal }): Promise<ServiceStatus>| 반환 필드 | 의미 |
|---|---|
operational | 서비스가 응답하고 있으면 true입니다. 이 필드를 보면 됩니다 |
health | 상태 확인의 원래 결과입니다. path, ok, HTTP status, body의 앞 512자, 그리고 응답이 오지 않았을 때의 error가 들어 있습니다 |
readiness | 두 번째 확인의 결과로, 형태는 같습니다. 공개 서비스에서는 404로 응답하는데, 정상이며 서비스가 멈췄다는 뜻이 아닙니다 |
checkedAt | 확인한 시각 |
비용 발생: 아니요. 키 필요: 아니요. 오류: 네트워크 문제로는 예외를 던지지 않습니다. 대신 operational: false가 되고 health.error에 이유가 담깁니다. 예외를 던지는 것은 signal로 중단했을 때뿐입니다. 예시는 첫 파일 실행하기를 보세요.
listModels(options?)
내 계정의 가격이 적용된 실시간 모델 카탈로그를 나열합니다. 모든 모델 ID는 여기서 나옵니다.
listModels(options?: ListModelsOptions): Promise<ModelList>속성
유형
{ total, items }를 돌려줍니다. 각 항목에서 가장 쓸모 있는 필드는 다음과 같습니다.
| 필드 | 의미 |
|---|---|
model | 모델 ID입니다. 작업을 만들 때 정확히 이 값을 보내세요 |
displayName | 읽기 쉬운 이름 |
modality, tasks | 무엇을 만들고 어떤 일을 하는지 |
provider, family | 누가 만든 모델이고 어느 제품 라인에 속하는지 |
enabled, available | 모델을 쓰기 전에 둘 다 true여야 합니다 |
pricing | 가격 등급마다 한 항목씩 있으며, price, unit(per_image, per_second, per_request, per_1k_tokens 중 하나), variant(어느 등급인지, 흔히 해상도)가 들어 있습니다. 할인 기간에는 regularPrice에 할인 전 가격이 표시됩니다 |
startingPrice | 가장 싼 등급입니다. 정확한 금액은 quoteTask로 받으세요 |
quantityField | 과금 수량을 정하는 입력 필드로, 예를 들면 길이입니다. 비어 있으면 호출 한 번이 1단위입니다 |
taskTimeoutSeconds | 작업이 expired로 끝나기 전까지 실행될 수 있는 시간 |
maxOutputDurationSeconds | 길이가 있는 결과물을 만드는 모델에서 허용하는 가장 긴 길이 |
inputSchema, examples | 요청했을 때만 들어 있습니다 |
updatedAt, version | 모델 정보가 바뀔 때마다 바뀝니다. 저장해 둔 스키마를 언제 새로 받아야 할지 판단하는 데 쓰세요 |
비용 발생: 아니요. 키 필요: 예. 오류: SpicyApiError(예: 키 문제면 401, 요청이 너무 많으면 429), SpicyTransportError.
const { items } = await client.listModels({ modality: "video", task: "image-to-video" });
for (const item of items) {
if (!item.enabled || !item.available) continue;
const from = item.startingPrice ? `from $${item.startingPrice.price} ${item.startingPrice.unit}` : "";
console.log(item.model, "|", item.displayName, "|", from);
}카탈로그 조회는 작업 생성과 독립된 계정 읽기 허용량을 사용합니다. 작업을 만들 때마다 목록을 읽지 말고, 목록을 저장해 두었다가 가끔, 또는 400이나 50301을 받은 뒤에 새로 받으세요.
getModel(modelId)
모델 하나를 전체 스키마와 함께 돌려줍니다. 스키마란 모델이 받는 입력 필드의 목록으로, 어떤 필드가 필수이고 어떤 값이 허용되는지가 적혀 있습니다.
getModel(modelId: string, options?: { signal?: AbortSignal }): Promise<ApiModel>결과에는 listModels에서 설명한 필드가 모두 있으며, inputSchema와 examples는 항상 들어 있습니다. 스키마는 표준 JSON Schema입니다. properties는 각 필드를 type과 함께 나열하고, 흔히 enum(허용 값), minimum / maximum, default도 적혀 있습니다. required는 반드시 보내야 하는 필드의 목록입니다.
비용 발생: 아니요. 키 필요: 예. 오류: 그 ID의 모델이 없으면 404인 SpicyApiError, ID가 비어 있으면 TypeError.
const model = await client.getModel("MODEL_ID_FROM_CATALOG");
console.log("Required fields:", model.inputSchema?.required);
console.log(JSON.stringify(model.inputSchema?.properties, null, 2));
console.log("A request that is known to work:", model.examples?.[0]?.input);quoteTask(request)
요청의 정확한 가격을 아무것도 만들지 않고 알려 줍니다. 사람에게 가격을 보여 주거나, 예산을 넘는 일을 거절할 때 쓰세요.
quoteTask(request: { model: string; input: object; callBackUrl?: string }, options?: { signal?: AbortSignal }): Promise<TaskQuote>작업을 만들 때 쓸 것과 똑같은 model, input, 그리고 필요하면 callBackUrl을 넘기세요.
| 반환 필드 | 의미 |
|---|---|
quoteId | createTask나 run에 넘기면 이 가격으로 고정됩니다 |
estimatedCost | 지금 작업을 만들면 예약될 금액 |
maxCharge | 이 요청에 청구될 수 있는 최대 금액 |
currency | 항상 USD |
quantity, unit | 과금 수량과 단위. 예: "5"와 per_second |
expiresAt | 견적은 5분 동안 유효합니다 |
비용 발생: 아니요. 키 필요: 예. 오류: SpicyApiError. 예를 들어 입력이 모델의 스키마와 맞지 않으면 400, 모델을 쓸 수 없으면 50301, 그리고 429.
const request = { model: "MODEL_ID_FROM_CATALOG", input: { prompt: "A paper lantern at night" } };
const quote = await client.quoteTask(request);
console.log(`This will cost $${quote.estimatedCost}. Quote valid until ${quote.expiresAt}.`);createTask(request, options?)
생성을 시작하고, 작업 ID와 함께 곧바로 반환합니다. 결과를 기다리지 않으므로 이어서 waitForTask나 웹훅으로 결과를 받거나, 두 가지를 한 번에 해 주는 run을 쓰세요.
createTask(request: CreateTaskInput, options?: CreateTaskOptions): Promise<CreateTaskResult>요청:
속성
유형
옵션:
속성
유형
| 반환 필드 | 의미 |
|---|---|
taskId | 새 작업의 ID. 곧바로 저장하세요 |
state | 새 작업이면 queued. 멱등 키를 반복해 보냈다면 원래 작업의 현재 상태 |
estimatedCost | 지금 예약된 금액으로, 이 작업에 청구될 수 있는 최대 금액이기도 합니다 |
deadlineAt | 작업이 끝나지 않았을 때 SpicyAPI가 처리를 멈추는 시각 |
비용 발생: 예. estimatedCost만큼이 잔액에서 예약됩니다. 작업이 성공하면 실제 사용량만큼 과금되며 예약 금액을 넘지 않고, 나머지는 해제됩니다. 실패하거나 만료된 작업은 보통 예약 금액이 전액 해제됩니다. 다만 모델 페이지에 명시된 일부 콘텐츠 거부는 접수 시 견적 금액으로 과금됩니다. 최종 청구액은 settled와 cost로 확인하세요.
오류:
error.code | 의미 | 대응 |
|---|---|---|
400 | 입력이 모델의 스키마와 맞지 않거나, 허용되지 않는 callBackUrl입니다 | getModel().inputSchema와 비교해 요청을 고치세요 |
401 | 키가 없거나, 형식이 잘못됐거나, 폐기됐습니다 | SPICY_API_KEY를 확인하세요 |
40201 | 잔액이 예약 금액에 못 미칩니다 | 콘솔의 결제에서 충전하세요 |
40202 | 일·월·누적 지출 한도 중 하나에 도달했습니다 | API 키 페이지에서 한도를 올리거나, 한도가 초기화될 때까지 기다리세요 |
40301 | 이 키로는 이 모델을 쓸 수 없습니다 | 콘솔에서 키의 허용 모델 목록을 바꾸세요 |
409 | 이 멱등 키가 이미 다른 요청에 쓰였습니다 | 새 일에는 새 키를 쓰세요 |
40901 | 견적이 만료됐거나, 가격 또는 요청이 바뀌었습니다 | 견적을 다시 받고 새 가격을 확인하세요 |
413 | 요청 본문이 너무 큽니다 | 미디어 파일을 요청에 직접 넣지 말고 업로드하세요 |
429 | 요청이 너무 많습니다 | error.retryAfterSeconds만큼 기다린 뒤 다시 시도하세요 |
50301 | 지금은 이 모델이 작업을 받을 수 없습니다 | 나중에 다시 시도하거나 다른 모델을 고르세요 |
SpicyTransportError나 SpicyTimeoutError는 응답이 오지 않았다는 뜻이므로, 작업이 만들어졌는지 알 수 없습니다. 같은 요청을 같은 멱등 키로 다시 보내세요. 그러면 작업이 새로 만들어지거나, 이미 있는 작업을 돌려받습니다. TypeError는 옵션이 잘못됐다는 뜻이며(예: 음수인 retentionSeconds), 아무것도 전송되지 않았습니다.
import { randomUUID } from "node:crypto";
const idempotencyKey = randomUUID(); // 실제 앱에서는 먼저 주문 정보와 함께 이 키를 저장하세요
const accepted = await client.createTask(
{ model: "MODEL_ID_FROM_CATALOG", input: { prompt: "A paper lantern at night" } },
{ idempotencyKey },
);
console.log(accepted.taskId, accepted.state, "on hold: $" + accepted.estimatedCost);run(request, options?)
가장 쉽게 생성하는 방법입니다. createTask를 호출한 다음 waitForTask를 호출하고, 끝난 작업을 돌려줍니다.
run(request: CreateTaskInput, options?: RunTaskOptions): Promise<TaskRecord>request는 createTask와 같습니다. 옵션은 createTask가 받는 것 전부에 다음이 더해집니다.
속성
유형
끝난 작업을 돌려주며, 그 필드는 getTask에서 설명합니다. 작업이 성공했다면 결과 파일 복사가 끝날 때까지도 기다리므로, output.assets[].url을 바로 내려받을 수 있습니다.
비용 발생: 예. createTask와 똑같습니다. 실패한 작업은 예외로 던지지 않고 그대로 반환합니다. task.state를 확인하세요.
오류: createTask가 던질 수 있는 모든 오류에 더해 다음이 있습니다.
SpicyWaitTimeoutError: 작업이 접수된 뒤timeoutMs가 다 지났을 때. 작업은 SpicyAPI에서 계속 실행됩니다. 나중에waitForTask(error.taskId)를 호출해 결과를 받으세요.SpicyTimeoutError: SpicyAPI가 작업 접수를 확인해 주기 전에 시간이 다 됐을 때. 같은 요청을 같은 멱등 키로 보내서 확인하세요.- 내
onAccepted나onUpdate안에서 던진 오류는 기다리기를 멈춥니다. 작업 자체는 계속 실행됩니다. timeoutMs나intervalMs가 잘못됐으면TypeError. 이 값들은 무엇이든 만들기 전에 검사합니다.
전체 예시는 첫 결과 받기를 보세요.
getTask(taskId)
작업을 한 번 읽습니다. 상태, 결과 링크, 비용을 알 수 있습니다.
getTask(taskId: string, options?: { signal?: AbortSignal }): Promise<TaskRecord>| 필드 | 의미 |
|---|---|
taskId, model | 어떤 작업인지, 어떤 모델로 만들었는지 |
state | 처리 중에는 queued 또는 running, 끝나면 succeeded, failed, expired 중 하나(canceled는 예전 기록에만 보입니다) |
output.assets | 결과 파일로, 다음 표에서 설명합니다. succeeded일 때만 있습니다 |
output.text | 텍스트를 돌려주는 모델의 텍스트 결과 |
errorCode, errorMessage | 작업이 실패한 이유. 판단은 errorCode로 하세요. 메시지 문구는 바뀔 수 있습니다 |
cost | settled가 false인 동안에는 예약 금액, true가 되면 최종 청구 금액 |
settled | cost가 최종 금액인지 여부 |
createdAt, completedAt, deadlineAt | 만든 시각, 끝난 시각, 만료될 시각 |
input | 저장된 입력. 프롬프트 보관 기간이 끝나면 삭제됩니다 |
sourceTaskId | retryTask로 만든 작업에 붙으며, 원래 작업을 가리킵니다 |
contentState | present, expired(보관 규칙에 따라 삭제됨), purged(purgeTask로 직접 삭제함) 중 하나 |
retention | 파일(outputsExpireAt)과 프롬프트(promptsExpireAt)가 삭제될 시각 |
output.assets의 각 항목:
| 필드 | 의미 |
|---|---|
url | 임시 다운로드 링크. API 키 없이 내려받으세요 |
expiresAt | 그 링크가 만료되는 시각으로, 보통 약 20분 뒤입니다. 새 링크가 필요하면 getTask를 다시 호출하세요 |
mime | 파일 형식. 예: image/png, video/mp4 |
width, height, durationSeconds, bytes | 해당하는 경우의 크기 정보 |
pending | 파일을 아직 저장소로 복사하는 중이면 true. 몇 초 뒤에 다시 확인하세요 |
unavailable | 파일이 영구히 사라졌으면 true. 기다려도 소용없으니 다시 생성하세요 |
key | 내부 파일 이름. createDownloadUrl에서만 필요합니다 |
SpicyAPI에 값이 없는 필드는 null로 채우지 않고 아예 빠지므로, 위의 필드는 모두 없을 수도 있다고 보고 다루세요.
비용 발생: 아니요. 오류: 내 키로 볼 수 있는 작업 중에 그 ID가 없으면 404인 SpicyApiError.
const task = await client.getTask("job_01k3m8x9q2z4v7n5p6r8s0t1w3"); // 저장해 둔 ID
if (task.state === "succeeded") {
for (const asset of task.output?.assets ?? []) console.log(asset.mime, asset.url);
} else if (task.state === "queued" || task.state === "running") {
console.log("Still working. On hold: $" + task.cost);
} else {
console.log(`Finished as ${task.state}: ${task.errorCode}`);
}waitForTask(taskId, options?)
작업이 끝날 때까지 반복해서 확인한 뒤 돌려줍니다. 프로그램이 다시 시작된 뒤처럼, 앞서 만든 작업을 기다릴 때 쓰세요.
waitForTask(taskId: string, options?: WaitForTaskOptions): Promise<TaskRecord>옵션은 timeoutMs(기본값 600000, 최대 86400000), intervalMs, onUpdate, signal이며, 모두 run에서 설명한 것과 같습니다.
state가 succeeded, failed, expired, canceled 중 하나가 되면 반환하며, 성공한 작업이라면 pending인 결과 파일이 하나도 남지 않았을 때 반환합니다. 확인 한 번이 getTask 요청 한 번이고 계정의 레이트 제한에 포함됩니다. 그래서 기본 간격은 시간이 갈수록 느려집니다.
비용 발생: 아니요. 오류: timeoutMs가 다 지나면 SpicyWaitTimeoutError. 이 오류에는 taskId, timeoutMs, lastTask(마지막으로 본 상태)가 들어 있습니다. 작업은 취소되지 않았으므로 그냥 다시 기다리면 됩니다. 그 밖에 SpicyApiError(예: 404)와 SpicyTransportError.
const task = await client.waitForTask("job_01k3m8x9q2z4v7n5p6r8s0t1w3", {
timeoutMs: 30 * 60_000,
onUpdate: (update) => console.log(new Date().toISOString(), update.state),
});
console.log(task.state, task.output?.assets?.[0]?.url);retryTask(taskId, options?)
실패한 작업을 같은 모델과 같은 입력으로, 완전히 새로운 작업으로 다시 실행합니다. 원래 작업은 그대로 남고, 새 작업은 sourceTaskId로 원래 작업을 가리킵니다.
retryTask(taskId: string, options?: { idempotencyKey?: string; signal?: AbortSignal }): Promise<RetryTaskResult>failed와 expired 작업(그리고 예전의 canceled 작업)에 쓸 수 있습니다. 성공했거나 아직 실행 중인 작업은 409로 거부됩니다. 가격, 잔액, 키 권한을 다시 확인하며, 새 작업은 원래 작업의 보관 설정을 그대로 이어받습니다.
입력을 바꾸지 않고 그대로 다시 쓰므로, 재시도는 일시적인 실패일 때만 도움이 됩니다. 먼저 원래 작업의 errorCode를 읽으세요. rate_limited, upstream_unavailable, timeout, upstream_failed는 잠시 기다린 뒤 한 번 재시도해 볼 만합니다. generation_failed와 invalid_asset은 요청을 바꿔 createTask로 보내야 합니다. content_rejected, invalid_request, unsupported_combination은 그대로 재시도하면 안 됩니다. 자주 보는 오류 코드와 오류를 보세요.
taskId(새 작업), sourceTaskId(원래 작업), state, estimatedCost, deadlineAt을 돌려줍니다.
비용 발생: 예. 새 작업은 따로 예약되고, 성공하면 따로 과금됩니다. idempotencyKey를 넘겨서, 재시도 코드가 두 번 실행되더라도 재시도 작업은 하나만 생기게 하세요.
오류: 409(재시도할 수 없는 작업), 그리고 createTask에 나열한 잔액·한도·사용 가능 여부 관련 오류. 원래 입력에 업로드한 spicy:// 파일을 썼다면 그 파일은 하루 동안만 쓸 수 있습니다. 파일을 다시 업로드하고 대신 createTask를 쓰세요.
retryTask는 네트워크 오류용이 아닙니다
createTask 자체가 네트워크 오류로 실패했다면 재시도할 작업이 없습니다. 같은 멱등 키로 createTask를 다시 보내세요. retryTask는 state를 읽어서 failed나 expired라고 확인한 작업에만 쓰세요.
전체 예시는 실패한 작업 다시 시도하기에 있습니다.
listTasks(options?)
이 API 키로 만든 작업을 최신순으로, 한 페이지씩 나열합니다. 프로그램을 다시 시작했거나 웹훅을 놓쳤을 때 작업을 찾는 데 유용합니다.
listTasks(options?: ListTasksOptions): Promise<TaskList>속성
유형
items, hasMore, 그리고 페이지가 더 있으면 nextCursor를 돌려줍니다. 각 항목에는 taskId, model, state, cost, settled, createdAt, deadlineAt, completedAt이 있습니다. 입력, 결과, 링크는 들어 있지 않으므로 필요한 작업은 getTask로 조회하세요.
비용 발생: 아니요. 오류: SpicyApiError(예: 날짜 범위가 잘못되면 400), SpicyTransportError.
let cursor;
do {
const page = await client.listTasks({
from: "2026-09-01", // 모든 페이지에서 같은 날짜를 유지하세요
to: "2026-09-08",
state: "failed",
limit: 100,
cursor,
});
for (const item of page.items) console.log(item.taskId, item.model, item.createdAt);
cursor = page.hasMore ? page.nextCursor : undefined;
} while (cursor);getBalance()
계정에 돈이 얼마나 있는지 보여 줍니다.
getBalance(options?: { signal?: AbortSignal }): Promise<Balance>| 필드 | 의미 |
|---|---|
available | 새 작업에 쓸 수 있는 돈. 계정이 승인된 후불 한도를 쓰고 있다면 음수일 수 있습니다 |
held | 아직 실행 중인 작업을 위해 따로 떼어 둔 돈 |
total | available과 held의 합 |
funding | 선택적으로 들어 있는 세부 내역: 선불 충전금, 프로모션 지원금(적용되는 모델과 만료일 포함), 후불 한도. 예전 응답에는 없을 수 있습니다 |
비용 발생: 아니요. 작업마다 미리 잔액을 확인할 필요는 없습니다. 작업을 만들 때 SpicyAPI가 잔액을 확인하고, 부족하면 40201로 응답합니다. funding의 모든 필드는 과금을 보세요.
const balance = await client.getBalance();
console.log(`Available: $${balance.available} (on hold: $${balance.held})`);getUsage(options?)
이 API 키가 쓴 금액을 날짜별·모델별로 묶어 보여 줍니다.
getUsage(options?: { from?: string; to?: string; signal?: AbortSignal }): Promise<UsageReport>from과 to는 UTC 날짜(YYYY-MM-DD)이며, from은 포함하고 to는 포함하지 않습니다. 둘 다 생략하면 오늘을 포함한 최근 7일입니다. 범위는 최대 92일입니다.
| 필드 | 의미 |
|---|---|
totalCalls | 기간 안의 작업 수 |
totalSpend | 최종 청구 금액만 합산합니다. 아직 예약 중인 금액은 포함하지 않습니다 |
days | 호출이 있었던 날마다 한 항목: day, calls, succeeded, failed, spend |
models | 모델마다 한 항목: model, calls, succeeded, failed, spend |
비용 발생: 아니요. 이 보고서는 다른 조회보다 무거워서 분당 30건이라는 한도가 추가로 걸립니다. 반복문 안에서 부르지 말고 보고서용으로 쓰세요. 오류: SpicyApiError(이 한도를 넘으면 429), SpicyTransportError.
const usage = await client.getUsage({ from: "2026-09-01", to: "2026-09-08" });
console.log(`${usage.totalCalls} tasks, $${usage.totalSpend} spent`);
for (const row of usage.models) console.log(row.model, row.calls, "$" + row.spend);파일 업로드
모델이 입력으로 이미지, 영상, 소리를 필요로 할 때는 파일의 공개 HTTPS 링크를 입력 필드에 바로 넣을 수 있습니다. 내 컴퓨터에 있는 파일이라면 먼저 업로드하세요. 업로드하면 spicy:// 주소를 돌려받으며, 링크 대신 이 주소를 입력 필드에 넣습니다. 업로드는 무료입니다.
- 이미지(JPEG, PNG, WebP, GIF): 최대 10 MiB.
- 영상(MP4, WebM)과 오디오(MP3, WAV): 최대 90 MiB, 길이 600초까지.
- 모델이 스키마에서 이보다 낮은 한도를 정할 수 있습니다.
- 업로드한 파일은 하루 동안, 내 계정에서만 쓸 수 있습니다.
uploadFile(path, options?)
디스크에 있는 파일을 업로드합니다.
uploadFile(path: string, options?: { contentType?: string; signal?: AbortSignal }): Promise<UploadedFile>파일 형식은 확장자로 알아냅니다. 인식하는 확장자는 .jpg, .jpeg, .png, .webp, .gif, .mp4, .webm, .mp3, .wav입니다. 그 밖의 파일 이름이라면 contentType에 image/jpeg, image/png, image/webp, image/gif, video/mp4, video/webm, audio/mpeg, audio/wav 중 하나를 넘기세요.
| 반환 필드 | 의미 |
|---|---|
uri | 작업의 입력에 넣을 spicy:// 주소 |
expiresAt | 주소가 만료되는 시각(하루 뒤) |
fileId, status, bytes, contentType, sha256 | 저장된 파일의 세부 정보. status는 ready입니다 |
width, height, durationSeconds | 해당하는 경우 측정된 크기나 길이 |
비용 발생: 아니요. 오류: 형식을 알아낼 수 없으면 TypeError이며, 메시지에 인식하는 확장자가 모두 나열됩니다(contentType is required unless the file extension is one of: gif, jpeg, jpg, png, webp, mp4, webm, mp3, wav). 경로가 틀리면 Node.js의 ENOENT 오류. 저장소가 바이트를 거부했거나 파일이 허용 크기보다 크면(413) status가 담긴 SpicyUploadError. SpicyAPI가 업로드를 거부하면 SpicyApiError. 전송이 uploadTimeoutMs보다 오래 걸리면 SpicyTimeoutError.
const file = await client.uploadFile("./photo.jpg");
console.log(file.uri, "can be used until", file.expiresAt);uploadBytes(data, options)
사용자가 내 서버로 보낸 파일처럼 이미 메모리에 있는 바이트를 업로드합니다. Node.js의 Buffer도 넘길 수 있습니다.
uploadBytes(data: Uint8Array, options: { contentType: string; signal?: AbortSignal }): Promise<UploadedFile>contentType은 필수입니다. 결과와 오류는 uploadFile과 같습니다.
const response = await fetch("https://example.com/reference.png");
const bytes = new Uint8Array(await response.arrayBuffer());
const file = await client.uploadBytes(bytes, { contentType: "image/png" });uploadBase64(value, options?)
Base64 텍스트로 가지고 있는 파일을 업로드합니다. data:image/png;base64,iVBOR… 같은 data URI를 넘기거나, 일반 Base64를 contentType과 함께 넘깁니다.
uploadBase64(value: string, options?: { contentType?: string; signal?: AbortSignal }): Promise<UploadedFile>인코딩과 크기는 먼저 내 컴퓨터에서 검사합니다. 잘못됐거나 너무 큰 데이터는 아무것도 업로드하지 않고 TypeError를 던집니다. 결과는 uploadFile과 같습니다.
createUploadUrl과 commitUploadedFile
위의 세 도우미 함수는 이 두 저수준 호출로 만들어져 있습니다. 바이트를 내 Node.js 프로세스가 아닌 다른 곳에서 보내야 할 때만 필요합니다.
createUploadUrl(contentType: string, bytes: number, options?: { signal?: AbortSignal }): Promise<UploadTicket>
commitUploadedFile(fileId: string, options?: { signal?: AbortSignal }): Promise<UploadedFile>createUploadUrl은fileId,uploadUrl,method(PUT),headers,maxBytes,expiresAt을 돌려줍니다.- 파일을
uploadUrl로PUT요청을 보내 전송합니다. 나열된headers를 정확히 그대로 붙이고,Authorization헤더는 붙이지 않습니다. 선언한 크기와 파일 크기가 일치해야 합니다. commitUploadedFile(fileId)가 저장된 파일을 확인하고spicy://uri를 돌려줍니다. 업로드만 하고 확정하지 않은 파일은 쓸 수 없습니다.
브라우저 업로드를 포함한 전체 흐름은 미디어에 설명되어 있습니다.
createDownloadUrl(taskId, key?)
결과 파일 하나에 대한 새 임시 다운로드 링크를 만듭니다. 쓸 일은 거의 없습니다. getTask, waitForTask, run, 웹훅이 이미 모든 결과에 바로 쓸 수 있는 url을 넣어 주기 때문입니다.
createDownloadUrl(taskId: string, key?: string, options?: { signal?: AbortSignal }): Promise<DownloadTicket>특정 파일을 고르려면 에셋의 key를 넘기고, 생략하면 첫 번째 파일이 선택됩니다. key, url, expiresAt(약 20분)을 돌려줍니다.
비용 발생: 아니요. 오류: 파일이 아직 복사 중이면 409인 SpicyApiError(몇 초 기다리세요), 작업이 성공하지 않았거나 그 key가 작업에 속하지 않으면 404.
purgeTask(taskId)
끝난 작업의 생성 미디어, 결과 페이로드(저장된 결과 데이터), 프롬프트와 그 밖의 입력 텍스트를 보관 기간이 끝나기 전에 영구히 삭제합니다. 결과를 남겨 두면 안 될 때 쓰세요.
purgeTask(taskId: string, options?: { signal?: AbortSignal }): Promise<PurgeTaskResult>| 반환 필드 | 의미 |
|---|---|
contentState | 내용이 삭제되면 purged. present는 이번에 아무것도 지워지지 않았다는 뜻이니 나중에 다시 시도하세요 |
purgedAt | 내용이 처음 삭제된 시각 |
contentRemovedBy | 직접 삭제했다면 user |
billingRetained | 항상 true. 작업 비용 기록은 남습니다 |
mediaDeletionPending | 저장된 파일을 아직 백그라운드에서 지우는 중이면 true. 보통 1분 안에 끝납니다 |
비용 발생: 아니요. 그리고 환불도 아닙니다. 과금 원장 항목, 청구 금액, 모델, 상태, 타임스탬프, 요청 ID는 기록으로 남습니다. 삭제는 되돌릴 수 없습니다. 같은 작업에 다시 호출해도 안전합니다. 오류: 작업이 아직 queued나 running이면 400인 SpicyApiError, 작업이 없으면 404. 접수된 작업은 취소할 수 없고 취소 API도 없으므로, waitForTask로 끝날 때까지 기다린 뒤 파기하세요. 데이터 보관과 파기를 보세요.
const result = await client.purgeTask("job_01k3m8x9q2z4v7n5p6r8s0t1w3");
console.log(result.contentState, "billing kept:", result.billingRetained);웹훅 함수
verifyWebhook과 computeWebhookSignature는 클라이언트 메서드가 아니라 일반 함수입니다. 동작하는 예시와 함께 웹훅에서 설명합니다.
오프라인 도우미와 타입
아래 항목은 네트워크 연결이나 키 없이 동작합니다.
| 내보내는 이름 | 제공하는 것 |
|---|---|
searchDocumentation(query, limit) | 이 문서 사이트의 작은 내장 색인을 검색합니다. 최대 limit개(1~25, 기본값 10)의 항목을 돌려주며, 각 항목에는 slug, title, summary, keywords, url이 있고 url은 https://docs.spicyapi.ai/docs/<slug> 형태입니다. 일치하는 것이 없으면 빈 배열을, query가 비어 있으면 앞쪽 항목들을 돌려줍니다 |
DOCUMENTATION | 그 내장 색인 전체를 담은 배열로, 문서 페이지마다 한 항목입니다 |
readOpenApiContract() | 이 SDK 버전에 함께 들어 있는 OpenAPI 3.1 계약을 YAML 텍스트로 돌려줍니다 |
DEFAULT_API_BASE_URL, DEFAULT_SERVICE_BASE_URL, DEFAULT_TIMEOUT_MS, DEFAULT_UPLOAD_TIMEOUT_MS, DEFAULT_WAIT_TIMEOUT_MS, DEFAULT_WAIT_INTERVAL_MS, MAX_API_RESPONSE_BYTES | 이 페이지에서 설명한 기본값들입니다. MAX_API_RESPONSE_BYTES(4 MiB)보다 큰 API 응답은 거부됩니다 |
모든 요청과 응답의 TypeScript 타입도 함께 내보냅니다. 예: import type { TaskRecord, ApiModel, Balance } from "@spicyapi/sdk".
| 가져오기 경로 | 들어 있는 것 |
|---|---|
@spicyapi/sdk | 이 페이지의 모든 것 |
@spicyapi/sdk/webhooks | verifyWebhook, computeWebhookSignature, SpicyWebhookVerificationError만 들어 있어, 웹훅 수신기에서 가볍게 가져올 수 있습니다 |
@spicyapi/sdk/openapi | 원시 OpenAPI 타입 paths, components, operations만 |
웹훅
웹훅을 쓰면 프로그램이 몇 초마다 물어보는 대신, 작업이 끝났을 때 SpicyAPI가 먼저 알려 줍니다. 작업마다 내 서버의 주소인 callBackUrl을 넣어 두면, SpicyAPI가 끝난 작업의 기록을 그 주소로 보냅니다.
웹훅을 반드시 검증해야 하는 이유
웹훅 주소는 공개 URL입니다. 주소를 짐작한 사람은 누구나 "작업 성공, 파일은 여기 있음"이라는 가짜 메시지를 보낼 수 있습니다. 코드가 그 메시지를 믿으면 고객에게 엉뚱한 파일을 전달하거나 주문을 완료로 처리할 수도 있습니다.
진짜 전송에는 모두 나와 SpicyAPI만 아는 비밀 서명 키로 서명이 되어 있습니다. verifyWebhook은 받은 바이트 그대로 서명을 다시 계산해 두 값을 비교합니다. 전송 시각도 확인하므로, 공격자가 진짜 메시지를 가로챘다가 몇 시간 뒤에 다시 보내는 공격도 막습니다. 검증을 통과한 메시지에만 따라 움직이세요.
설정하기
서명 키 받기
가입하면 서명 키가 자동으로 만들어지지만 그 값은 보여 주지 않으므로, 복사할 수 있는 키를 새로 만들어야 합니다. 콘솔에서 웹훅을 열고 발급을 누르세요(카드에 키가 설정되어 있다고 나오면 교체를 누릅니다). 새 키는 한 번만 표시됩니다. 복사해서 서버에 SPICY_WEBHOOK_SECRET으로 저장하세요. 잃어버렸다면 다시 교체하면 됩니다.
교체에는 유예 기간이 없습니다. 이전 키는 즉시 서명을 멈추고, 아직 재시도 중인 전송까지도 새 키로 서명됩니다. 교체한 직후 실행 중인 모든 수신기를 새 키로 업데이트하세요. 자세한 내용은 웹훅 페이지에 있습니다.
공개 HTTPS 주소에서 수신기 실행하기
SpicyAPI는 인터넷에서 접근할 수 있고 https://로 시작하는 주소만 호출할 수 있습니다. 일반 http://, localhost, 사설 네트워크 주소는 거부됩니다. 개발하는 동안에는 로컬 서버에 공개 HTTPS 주소를 붙여 주는 터널 서비스로 서버를 외부에 노출하거나, 배포할 때까지 폴링을 쓰세요. 콜백 URL의 전체 규칙은 웹훅 페이지에 있습니다.
작업에 callBackUrl 넣어 보내기
createTask에 넘기는 요청에 callBackUrl: "https://your-app.example.com/hooks/spicy"를 추가하세요. 폴링 대신 웹훅으로 결과 받기를 보세요.
최소한의 수신기
이 수신기는 Node.js에 내장된 http 모듈만 쓰므로 따로 설치할 것이 없습니다. receiver.mjs로 저장하고 node receiver.mjs로 시작하세요.
import { createServer } from "node:http";
import { SpicyWebhookVerificationError, verifyWebhook } from "@spicyapi/sdk/webhooks";
const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET first");
// 이미 처리한 전송 ID. 운영 환경에서는 데이터베이스 테이블을 써서
// 프로그램을 다시 시작해도 목록이 남게 하세요.
const handled = new Set();
const server = createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/hooks/spicy") {
response.writeHead(404).end();
return;
}
if (request.headers["x-webhook-payload-version"] !== "2") {
response.writeHead(400).end();
return;
}
// 1. 본문을 원시 바이트 그대로 읽습니다. 검증하기 전에 파싱하지 마세요.
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const rawBody = Buffer.concat(chunks);
// 2. 서명과 타임스탬프를 확인합니다.
let event;
try {
event = verifyWebhook({
rawBody,
secret,
timestamp: String(request.headers["x-webhook-timestamp"] ?? ""),
signature: String(request.headers["x-webhook-signature"] ?? ""),
payloadVersion: 2,
});
} catch (error) {
const reason = error instanceof SpicyWebhookVerificationError ? error.reason : String(error);
console.warn("Rejected a webhook:", reason);
response.writeHead(401).end();
return;
}
// 3. 빨리 응답합니다. SpicyAPI는 최대 15초까지 기다린 뒤 재시도합니다.
response.writeHead(204).end();
// 4. 같은 전송이 여러 번 올 수 있습니다. 전송마다 한 번만 처리하세요.
if (!event.deliveryId || handled.has(event.deliveryId)) return;
handled.add(event.deliveryId);
if (!("data" in event.payload)) return; // 버전 2 본문은 작업을 data 아래에 담습니다
const task = event.payload.data; // getTask()가 돌려주는 것과 같은 필드
console.log("Task", task.taskId, "is", task.state);
if (task.state === "succeeded") {
console.log("Result:", task.output?.assets?.[0]?.url);
}
});
server.listen(8080, () => console.log("Listening on http://localhost:8080/hooks/spicy"));이 코드에서 중요한 점은 다음과 같습니다.
- 원시 바이트. 서명은 SpicyAPI가 보낸 바이트 그대로를 대상으로 계산됩니다. 프레임워크가 JSON을 먼저 파싱하고 그것을 다시 텍스트로 바꾸면 바이트가 달라져서 검증이 모두 실패합니다. Express에서는 이 경로에
express.raw({ type: "application/json" })를 쓰세요. - 빠르게 응답하기.
2xx응답이면 무엇이든 "받았음"이라는 뜻입니다. 15초 안에 응답하지 않거나 다른 상태 코드로 응답하면 SpicyAPI가 이후 몇 시간에 걸쳐 여러 번 재시도합니다. 오래 걸리는 처리는 응답한 뒤에 하세요. - 중복은 생깁니다. 재시도나 일시적인 네트워크 문제로 같은 메시지가 두 번 올 수 있습니다.
event.deliveryId는 재시도해도 바뀌지 않으므로, 처리한 ID를 기억해 두세요. - 성공 여부는
state에 있습니다. 실패한 작업에 대한 전송도 올바른 전송입니다. 외피의code가 아니라 언제나task.state를 읽으세요. - 운영 환경에서는 검증한 이벤트를 먼저 데이터베이스에 저장하고, 저장에 성공한 뒤에만
2xx로 응답하세요. 내 애플리케이션이 만들지 않은 작업 ID는 무시하세요. 데이터베이스를 쓰는 버전은 웹훅 가이드에 있습니다.
실제 운영 전에 수신기 테스트하기
computeWebhookSignature는 진짜 서명을 만들어 내므로, 서명된 테스트 전송을 직접 보내 볼 수 있습니다. 한 터미널에서 receiver.mjs를 실행해 두고, 같은 SPICY_WEBHOOK_SECRET을 설정한 다른 터미널에서 다음을 실행하세요.
import { computeWebhookSignature } from "@spicyapi/sdk";
const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET first");
const body = JSON.stringify({
code: 200,
msg: "success",
request_id: "whd_local_test_1",
data: {
taskId: "job_local_test",
state: "succeeded",
cost: "0.01",
settled: true,
output: { assets: [{ url: "https://example.com/result.png" }] },
},
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = computeWebhookSignature("job_local_test", timestamp, body, secret);
const response = await fetch("http://localhost:8080/hooks/spicy", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Webhook-Timestamp": timestamp,
"X-Webhook-Signature": signature,
"X-Webhook-Payload-Version": "2",
},
body,
});
console.log("Your receiver answered", response.status); // 204가 나와야 합니다두 번 실행해 보세요. 두 번째 전송은 request_id가 같으므로 수신기는 작업을 한 번만 출력합니다. 서명한 뒤 body의 글자를 하나만 바꿔도 수신기는 401로 응답합니다.
verifyWebhook(options)
verifyWebhook(options: VerifyWebhookOptions): VerifiedWebhook속성
유형
payload(파싱된 본문. 버전 2에서는 작업이 payload.data에 있습니다), payloadVersion, taskId, deliveryId(버전 2의 request_id로, 재시도해도 같습니다), timestamp를 돌려줍니다. 동기 함수이므로 await가 필요 없습니다.
검증에 실패하면 SpicyWebhookVerificationError를 던지며, 그 reason은 invalid_signature, stale_timestamp, invalid_timestamp, invalid_json, invalid_payload, body_too_large, invalid_secret 중 하나입니다. 401로 응답하고 그 메시지에 따라 아무것도 하지 마세요.
computeWebhookSignature(taskId, timestamp, rawBody, secret)는 본문의 Base64 서명을 돌려줍니다. 위와 같은 테스트에만 필요합니다. 다른 언어에서 쓸 전체 서명 알고리즘은 웹훅에 있습니다.
TypeScript 팁: payload의 타입은 두 형식 중 하나입니다. 수신기 예시처럼 "data" in event.payload로 좁히거나, import type { components } from "@spicyapi/sdk"와 함께 verifyWebhook<components["schemas"]["TaskRecordEnvelope"]>(…)처럼 기대하는 타입을 넘기세요.
오류 처리
문제는 서로 다른 두 가지 방식으로 생기며, 처리 방법도 다릅니다.
- 호출이 실패합니다. SDK가 오류를 던집니다. 키가 틀렸거나, 잔액이 부족하거나, 네트워크가 끊긴 경우입니다.
try/catch로 잡으세요. - 작업이 실패합니다. 호출은 성공했지만 생성이 실패한 경우입니다. 아무것도 던지지 않으며, 반환된 작업에
state: "failed"와errorCode가 들어 있습니다. 실패하거나 만료된 작업은 보통 예약 금액이 전액 해제됩니다. 다만 모델 페이지에 명시된 일부 콘텐츠 거부는 접수 시 견적 금액으로 과금됩니다. 최종 청구액은settled와cost로 확인하세요.
오류 클래스
| 클래스 | 언제 보이나요 | 쓸모 있는 필드 |
|---|---|---|
SpicyApiError | SpicyAPI가 오류로 응답했을 때. 예: 키가 거부됐거나 잔액이 부족할 때 | code(대응 기준으로 삼을 업무 코드, 예: 40201), status(HTTP 상태), message, requestId, retryAfterSeconds, rateLimitLimit, rateLimitRemaining |
SpicyTransportError | 쓸 수 있는 응답이 오지 않았을 때: 네트워크 장애, DNS 문제, 또는 4 MiB보다 큰 응답 | message, cause(원인이 된 오류) |
SpicyTimeoutError | 요청 하나가 timeoutMs(또는 uploadTimeoutMs)보다 오래 걸렸을 때. SpicyTransportError의 한 종류입니다 | timeoutMs |
SpicyWaitTimeoutError | run이나 waitForTask의 시간이 다 됐을 때. 작업은 아직 실행 중입니다 | taskId, timeoutMs, lastTask |
SpicyUploadError | 저장소가 업로드 파일을 거부했거나 파일이 너무 클 때 | status |
SpicyWebhookVerificationError | 웹훅 검증에 실패했을 때 | reason |
TypeError | 호출 자체가 잘못됐을 때. 키가 설정되지 않았거나, 옵션이 범위를 벗어났거나, ID가 비어 있는 경우입니다. 아무것도 전송되지 않았습니다 | message |
대응은 message가 아니라 언제나 code로 정하세요. 메시지는 사람이 읽기 위한 것이라 문구가 바뀔 수 있고, 계정의 오류 언어 설정을 따릅니다. 지원팀에 문의할 때는 requestId를 꼭 함께 알려 주세요. 그 값이 있어야 정확히 어떤 요청인지 찾을 수 있습니다.
import { randomUUID } from "node:crypto";
import {
SpicyApiError,
SpicyClient,
SpicyTransportError,
SpicyWaitTimeoutError,
} from "@spicyapi/sdk";
const client = new SpicyClient();
const MODEL_ID = "MODEL_ID_FROM_CATALOG"; // listModels()에서 가져온 실제 ID
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");
const idempotencyKey = randomUUID(); // 보관하세요: 다시 시도할 때 같은 키가 필요합니다
try {
const task = await client.run(
{ model: MODEL_ID, input: { prompt: "A paper lantern on a rainy street" } },
{ idempotencyKey, onAccepted: ({ taskId }) => console.log("Task ID:", taskId) },
);
if (task.state === "succeeded") console.log("Done:", task.output?.assets?.[0]?.url);
else console.log(`Finished as ${task.state} (${task.errorCode}). Cost: ${task.cost}; settled: ${task.settled}.`);
} catch (error) {
if (error instanceof SpicyWaitTimeoutError) {
// 끝난 것은 기다리기이지 작업이 아닙니다. 나중에 같은 ID로 확인하세요.
console.log(`Still running: ${error.taskId}. Call waitForTask() again later.`);
} else if (error instanceof SpicyApiError) {
console.log(`SpicyAPI said no: ${error.code} (HTTP ${error.status}) ${error.message}`);
console.log(`Quote this if you contact support: ${error.requestId}`);
if (error.code === 40201) console.log("Your balance is too low. Top up in the console.");
if (error.code === 40202) console.log("A spend cap was reached. Raise it on the API keys page.");
if (error.code === 429) console.log(`Too many requests. Wait ${error.retryAfterSeconds ?? 1} s.`);
} else if (error instanceof SpicyTransportError) {
// 네트워크 장애나 로컬 타임아웃: 작업이 있을 수도, 없을 수도 있습니다.
// 반드시 "같은" idempotencyKey로 같은 요청을 다시 실행하면 안전하게 확인할 수 있습니다.
console.log(`Network problem (${error.message}). Retry with key ${idempotencyKey}.`);
} else {
throw error; // 여기서 나는 TypeError는 키 누락 같은 호출 자체의 버그입니다
}
}자주 보는 오류 코드
error.code | 의미 | 재시도할 만한가요? |
|---|---|---|
400 | 잘못된 요청. 대개 모델이 받지 않는 입력 필드 때문입니다 | 아니요. 요청을 고치세요 |
401 | 키가 없거나, 형식이 잘못됐거나, 폐기됐거나, 만료됐습니다 | 아니요. 키를 확인하세요 |
40201 | 잔액이 예약 금액보다 적습니다 | 아니요. 먼저 충전하세요 |
40202 | 지출 한도에 도달했습니다 | 아니요. 한도를 올리거나, 일·월 한도가 초기화될 때까지 기다리세요 |
40301 | 이 키로는 이 모델을 쓸 수 없습니다 | 아니요. 키의 허용 모델 목록을 바꾸세요 |
404 | 이 키로 볼 수 있는 해당 모델이나 작업이 없습니다 | 아니요 |
409 | 현재 상태와 충돌합니다. 예: 멱등 키를 다른 일에 다시 썼을 때 | 경우에 따라 다릅니다. 오류를 보세요 |
40901 | 견적이 만료됐거나 가격이 바뀌었습니다 | 견적을 다시 받은 뒤 확인하세요 |
429 | 요청이 너무 많습니다 | 예, retryAfterSeconds만큼 기다린 뒤에. 조회 요청은 SDK가 이미 대신 재시도합니다 |
500 | SpicyAPI 쪽에 문제가 생겼습니다 | 예, 잠시 쉬었다가 |
50301 | 지금은 이 모델을 쓸 수 없습니다 | 예, 나중에. 또는 다른 모델을 고르세요 |
작업이 실패하면 task.errorCode는 invalid_request, unsupported_combination, content_rejected, rate_limited, upstream_unavailable, generation_failed, timeout, invalid_asset, upstream_failed 중 하나입니다. retryTask는 원래 입력을 바꾸지 않고 그대로 다시 보내므로, 코드에 따라 다음 단계를 고르세요.
task.errorCode | 대응 |
|---|---|
rate_limited, upstream_unavailable, timeout, upstream_failed | 기다렸다가 retryTask를 한 번 실행하세요. timeout이 반복되면 createTask로 더 짧은 길이나 더 낮은 해상도를 시도하세요 |
generation_failed | 프롬프트나 이미지를 바꿔 createTask로 새 작업을 보내세요. 같은 입력은 대개 같은 식으로 실패합니다 |
invalid_asset | 파일을 다시 업로드하고, 새 uri를 넣어 createTask로 새 작업을 보내세요. 재시도하면 예전 파일을 다시 쓰게 됩니다 |
invalid_request, unsupported_combination | 그대로 재시도하지 마세요. 설정을 바꾼 뒤 createTask를 쓰세요 |
content_rejected | 그대로 재시도하지 마세요. 선택한 모델이 자체 정책에 따라 요청을 거절한 것입니다. 프롬프트를 바꾸거나 다른 모델을 고르세요 |
전체 표는 오류에 있습니다.
결과 보관하기
- 결과
url은 약 20분 동안 동작합니다. 새 링크가 필요하면getTask를 다시 호출하세요. - 생성된 파일은 늦어도 약 14일 뒤에 삭제되며, 계정 설정이나
retentionSeconds에 따라 더 일찍 삭제될 수 있습니다. 업로드한 입력 파일은 하루, 저장된 프롬프트는 30일 동안 남습니다. - 남겨 두고 싶은 결과는 작업이 성공하는 대로 내 저장소에 내려받으세요. 임시
url자체를 데이터베이스에 저장하지 마세요. 자세한 내용은 데이터 보관과 파기를 보세요.
자주 쓰는 레시피
레시피에서는 MODEL_ID_FROM_CATALOG를 자리표시자로 씁니다. 실행하기 전에 실시간 카탈로그(listModels() 또는 GET /api/v1/models)에서 용도에 맞고 enabled와 available이 모두 true인 항목을 골라, 그 model 값으로 바꾸세요. 바꾸는 것을 잊으면 레시피마다 알기 쉬운 메시지를 띄우고 멈춥니다.
이미지로 영상 만들기: 로컬 사진을 먼저 업로드
import { randomUUID } from "node:crypto";
import { SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
// 실시간 카탈로그에 있는 image-to-video 모델 ID로 바꾸세요:
// await client.listModels({ modality: "video", task: "image-to-video" })
const MODEL_ID = "MODEL_ID_FROM_CATALOG";
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");
// 1. 로컬 사진을 업로드합니다. 무료이며, spicy:// 주소는 하루 동안 쓸 수 있습니다.
const file = await client.uploadFile("./photo.jpg");
console.log("Uploaded:", file.uri);
// 2. 모델의 스키마를 출력합니다. 어느 필드가 이미지를 받는지, 어떤 필드가 필수인지 확인하세요.
const { inputSchema } = await client.getModel(MODEL_ID);
console.log(JSON.stringify(inputSchema, null, 2));
// 3. 영상을 만들고 기다립니다. 영상은 몇 분씩 걸리는 경우가 많습니다.
const task = await client.run(
{
model: MODEL_ID,
input: {
prompt: "Slow push-in, light rain, neon reflections on wet stone",
image_url: file.uri, // 위에서 출력한 스키마에 나온 필드 이름을 쓰세요
},
},
{
idempotencyKey: randomUUID(),
timeoutMs: 30 * 60_000,
onAccepted: ({ taskId }) => console.log("Task ID (save it):", taskId),
},
);
console.log(task.state, task.output?.assets?.[0]?.url ?? task.errorCode);필드 이름과 필수 필드는 출력된 스키마가 기준입니다. 영상 모델은 길이나 해상도도 필수로 요구할 수 있고, 가격이 그 값에 따라 달라지는 경우가 많습니다. 이미지가 이미 공개 HTTPS 주소에 올라가 있다면 업로드를 건너뛰고 그 주소를 이미지 필드에 바로 넣으세요. 가격을 먼저 보고 싶다면 같은 요청으로 quoteTask부터 실행하세요.
동시 실행 수를 제한해 일괄 처리하기
작업 수백 개를 한꺼번에 시작하면 지출 한도나 레이트 제한에 걸릴 수 있습니다. 이 스크립트는 프롬프트 목록을 실행하되, 동시에 진행되는 작업을 최대 두 개로 제한합니다.
import { SpicyApiError, SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
const MODEL_ID = "MODEL_ID_FROM_CATALOG"; // listModels()에서 가져온 실제 ID
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");
// 프롬프트를 바꿀 때마다 배치 이름도 바꾸세요.
const BATCH_NAME = "lanterns-2026-09-17";
const prompts = [
"A paper lantern on a rainy street",
"A paper lantern floating on a lake",
"A paper lantern in a snowy forest",
"A paper lantern above a night market",
];
const CONCURRENCY = 2; // 동시에 실행할 수 있는 작업 수
const results = prompts.map((prompt) => ({ prompt, outcome: "not started", detail: "" }));
let nextIndex = 0;
async function worker() {
while (nextIndex < prompts.length) {
const index = nextIndex++;
try {
const task = await client.run(
{ model: MODEL_ID, input: { prompt: prompts[index] } },
{
// 같은 배치 이름 + 같은 위치 = 같은 키. 스크립트가 중간에 죽은 뒤
// 24시간 안에 다시 실행하면, 같은 작업에 두 번 돈을 내는 대신
// 이미 있는 작업을 돌려받습니다.
idempotencyKey: `${BATCH_NAME}-${index}`,
timeoutMs: 20 * 60_000,
},
);
results[index].outcome = task.state;
results[index].detail = task.output?.assets?.[0]?.url ?? task.errorCode ?? "";
} catch (error) {
results[index].outcome = "error";
results[index].detail = error instanceof Error ? error.message : String(error);
// 잔액 부족(40201)이나 지출 한도 도달(40202): 새 작업 시작을 멈춥니다.
if (error instanceof SpicyApiError && (error.code === 40201 || error.code === 40202)) {
nextIndex = prompts.length;
}
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
console.table(results);- 키를 배치 이름으로 만드는 이유. 멱등 키를 "이 배치의 3번 항목"에 묶어 두면, 스크립트가 죽은 뒤 다시 실행해도 이미 만든 작업을 다시 씁니다. 실행할 때마다 무작위 키를 쓰면 같은 이미지에 또 돈을 내게 됩니다.
- 프롬프트가 바뀌면 배치 이름도 바꿔야 하는 이유. 키를 다른 요청에 다시 쓰면
409로 거부됩니다. CONCURRENCY정하기. 실행 중인 작업은 저마다 잔액의 일부를 예약하고, 상태를 확인할 때마다 계정의 레이트 제한에 포함됩니다. 작게 시작해서 배치가 문제없이 돌면 늘리세요.
폴링 대신 웹훅으로 결과 받기
웹 앱이나 백그라운드 워커에서는 요청 안에서 결과를 기다리는 것이 낭비입니다. callBackUrl을 넣어 작업을 만들고, 작업 ID를 저장한 다음, 결과가 도착하면 수신기가 처리하게 하세요.
import { randomUUID } from "node:crypto";
import { SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
const MODEL_ID = "MODEL_ID_FROM_CATALOG"; // listModels()에서 가져온 실제 ID
if (MODEL_ID === "MODEL_ID_FROM_CATALOG") throw new Error("Replace MODEL_ID_FROM_CATALOG first");
// 실제 앱에서는 호출하기 전에 고객의 주문 정보와 함께 이 키를 저장하세요.
const idempotencyKey = randomUUID();
const accepted = await client.createTask(
{
model: MODEL_ID,
input: { prompt: "A paper lantern on a rainy street" },
callBackUrl: "https://your-app.example.com/hooks/spicy",
},
{ idempotencyKey },
);
console.log(`Accepted ${accepted.taskId}; $${accepted.estimatedCost} is on hold.`);
// 지금 accepted.taskId를 저장하세요. 작업이 끝나면 SpicyAPI가 수신기를 호출합니다.끝내 도착하지 않는 전송에 대비한 안전장치도 두세요. 예를 들어 재시도가 이어지는 내내 내 서버가 멈춰 있었을 수 있습니다. 가끔씩, 저장해 둔 작업 중 deadlineAt이 한참 지났는데도 웹훅이 오지 않은 작업을 getTask로 조회하거나, listTasks로 페이지를 넘겨 가며 확인하세요. 웹훅 전송 기록과 직접 다시 보내는 버튼은 콘솔의 웹훅 페이지에 있습니다.
실패한 작업 다시 시도하기
import { SpicyClient } from "@spicyapi/sdk";
const client = new SpicyClient();
const taskId = process.argv[2]; // 실행 방법: node retry.mjs job_...
if (!taskId) throw new Error("Usage: node retry.mjs <taskId>");
// 재시도는 원래 입력을 바꾸지 않고 다시 보내므로, 일시적인 실패일 때만 해 볼 만합니다.
// 다른 코드라면 요청을 바꿔서 대신 createTask를 쓰세요.
const WORTH_ONE_RETRY = new Set(["rate_limited", "upstream_unavailable", "timeout", "upstream_failed"]);
const source = await client.getTask(taskId);
if (
(source.state !== "failed" && source.state !== "expired") ||
!WORTH_ONE_RETRY.has(source.errorCode ?? "")
) {
console.log(`Not retrying: the task is ${source.state} (${source.errorCode ?? "no error code"}).`);
} else {
// 재시도 시도마다 고정된 키: 이 스크립트를 두 번 실행해도 재시도 작업은 두 개가 아니라 하나만 생깁니다.
const retry = await client.retryTask(source.taskId, {
idempotencyKey: `retry-${source.taskId}-attempt-1`,
});
console.log(`New task ${retry.taskId}; $${retry.estimatedCost} is on hold.`);
const finished = await client.waitForTask(retry.taskId, { timeoutMs: 20 * 60_000 });
console.log("The retry finished as", finished.state);
}이 스크립트는 다음번에는 결과가 달라질 수 있는 실패, 즉 서비스가 바쁘거나 일시적으로 쓸 수 없는 경우, 타임아웃, 알 수 없는 오류만 재시도하며, 그것도 고정된 멱등 키로 딱 한 번만 재시도합니다. invalid_asset이면 재시도가 만료된 업로드를 다시 쓰게 되므로, 파일을 다시 업로드하고 새 작업을 만드세요. generation_failed면 프롬프트나 이미지를 바꿔 새 작업을 만드세요. content_rejected, invalid_request, unsupported_combination이면 재시도하지 말고 요청을 바꾸세요. 한 번의 재시도마저 실패하면 멈추고 오류를 살펴보세요. timeout이 반복된다면 createTask로 더 짧은 길이나 더 낮은 해상도를 시도하세요. 오류를 보세요.
문제 해결과 FAQ
소스
이 패키지는 MIT 라이선스이고 **github.com/spicyapi-ai/spicy-sdk**에서 공개적으로 개발됩니다. 이 페이지에서 설명한 메서드의 구현을 읽거나, 문서와 다르게 동작하는 부분을 알리거나, 변경을 제안하는 일 모두 저장소에서 하면 됩니다.
다음 단계
모델 카탈로그와 스키마
모델의 inputSchema를 읽고 올바른 입력을 고르는 방법
비동기 작업
작업 상태, 폴링, 각 필드의 의미
웹훅
전송 형식, 재시도 일정, 다른 언어에서 검증하는 방법
미디어
업로드, Base64 한도, 결과 다운로드
오류
모든 오류 코드와 재시도할 만한 코드
과금
예약, 정산, 지출 한도, 잔액
멱등성
두 번 돈 내지 않고 안전하게 재시도하기
명령줄 도구
코드를 짜지 않고 터미널에서 같은 기능을 쓰기
MCP 서버
AI 코딩 어시스턴트가 대신 SpicyAPI를 쓰게 하기
Python SDK
같은 워크플로를 런타임 의존성 없이 Python에서
Go SDK
같은 워크플로를 표준 라이브러리만으로 Go에서
PHP SDK
같은 워크플로를 라이브러리 의존성 없이 PHP에서
Java SDK
같은 워크플로를 봉인된 오류 분류 체계와 함께 Java에서

