快速入門
拿金鑰、建任務、取結果。從零到第一張圖的完整流程。
SpicyAPI 把影像、影片、語音等多模態生成模型收斂成一套身分驗證、任務與結果協定。切換模型時保留相同的呼叫流程,並按該模型的 inputSchema 組裝輸入。
本頁使用原生任務 API:https://api.spicyapi.ai/api/v1。文字與影片相容介面使用 https://api.spicyapi.ai/v1,兩者共用 API Key,但請求和回應形狀不同。
首次串接請依本頁選擇模型並取回結果;實際提交前依報價與協定相容確認費用。要串接聊天或串流輸出請看文字模型與串流,部署自己的整合前依正式環境串接指南逐項檢查,遇到問題直接查疑難排解與請求恢復。
五步跑通
拿一把金鑰
在控制台建立 API Key。金鑰形如 sk-spicy-…,建立後只顯示一次,請當場存好。
export SPICY_API_KEY="sk-spicy-你的金鑰"新金鑰預設帶一個較低的日消費上限,可在控制台調整。這是為了讓設定錯誤或金鑰洩露的損失有個天花板。詳見認證。
選擇目前可呼叫的模型
已認證的即時目錄是模型 ID、目前的 Schema 與範例的唯一依據。組裝請求前先讀取目錄,只選擇同時滿足 enabled: true 與 available: true 的條目。下面還要求模型帶有伺服器端已驗證的輸入範例,避免猜欄位:
CATALOG_JSON="$(curl --fail-with-body \
"https://api.spicyapi.ai/api/v1/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1" \
-H "Authorization: Bearer $SPICY_API_KEY")"
MODEL_ID="$(printf '%s' "$CATALOG_JSON" | jq -er \
'[.data.items[] | select(.enabled == true and .available == true and (.examples | length > 0))][0].model')"
MODEL_INPUT="$(printf '%s' "$CATALOG_JSON" | jq -cer \
'[.data.items[] | select(.enabled == true and .available == true and (.examples | length > 0))][0].examples[0].input')"
printf 'Selected %s\n' "$MODEL_ID"如果沒有相符的條目,就在建立付費任務之前停止。即時目錄為空時,首發快照、行銷頁或記憶中的模型 ID 都不能充當備用值。
提交任務
SPICY_IDEMPOTENCY_KEY="${SPICY_IDEMPOTENCY_KEY:-$(uuidgen)}"
TASK_PAYLOAD="$(jq -cn \
--arg model "$MODEL_ID" \
--argjson input "$MODEL_INPUT" \
'{model: $model, input: $input}')"
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: $SPICY_IDEMPOTENCY_KEY" \
--data "$TASK_PAYLOAD"input 裡能放什麼由模型自己的 Schema 決定,多傳一個欄位就會 400。目前的 Schema 請從模型目錄讀取,它才是唯一依據。
連線不會被掛住,立刻回傳任務 ID:
HTTP/1.1 202 Accepted{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "queued",
"estimatedCost": "0.008",
"deadlineAt": "2026-09-06T12:30:00Z"
},
"request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}202 只表示受理,不表示生成完成
新建任務的 state 為 queued;如果是同一 Idempotency-Key 的安全重放,則回傳原任務的目前狀態。HTTP 202 表示請求已受理,不代表生成完成。首次受理時會凍結預估金額但尚未收費;凍結額也是這次任務的收費上限,少用會釋放差額,之後不會補扣超過凍結額的金額。回應本文沿用統一信封,所以業務 code 仍為 200。
取結果
正式環境的請求如果帶了 callBackUrl,建議讓我們在任務進入終態時回呼你。上面的最小範例為了不需要公開端點也能執行,省略了回呼網址。回呼必須驗證簽章,見回呼。
沒有設定回呼、或者想主動查一下:
curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=job_01k3m8x9q2z4v7n5p6r8s0t1w3" \
-H "Authorization: Bearer $SPICY_API_KEY"{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "succeeded",
"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",
"completedAt": "2026-08-28T09:12:11Z"
}
}下載輸出檔案
直接 GET 成功回應中的 data.output.assets[0].url,不攜帶 API Key,無需再申請下載票據。expiresAt 是本次短期連結有效期,到期可在14天結果留存期內重新輪詢。
curl "$RESULT_URL_FROM_RESPONSE" -o result.png完整範例
下面三段依序讀取目錄、建立任務、退避輪詢並取得下載網址。正式環境請改用回呼,輪詢只作為備援。
const BASE = 'https://api.spicyapi.ai/api/v1';
const KEY = process.env.SPICY_API_KEY;
const headers = {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
};
async function call(path, init) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { ...headers, ...init?.headers },
signal: AbortSignal.timeout(30_000),
});
const body = await res.json();
// 判斷成功看 code,不要只看 res.ok —— 業務碼比 HTTP 狀態碼的表達力強得多
if (body.code !== 200) {
throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
}
return body.data;
}
async function generate() {
const catalog = await call(
'/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1',
);
const selected = catalog.items.find(
(item) => item.enabled && item.available && item.examples?.length,
);
if (!selected) throw new Error('No callable text-to-image model with a validated example');
const idempotencyKey = crypto.randomUUID();
const { taskId } = await call('/jobs/createTask', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({
model: selected.model,
input: selected.examples[0].input,
}),
});
// 2 秒起步,每次乘 1.5,封頂 15 秒。第一次查詢幾乎必然還是 queued,
// 起步給短一點只是浪費一次往返。
const deadline = Date.now() + 10 * 60_000;
let wait = 2000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, wait));
wait = Math.min(wait * 1.5, 15000);
const task = await call(`/jobs/recordInfo?taskId=${taskId}`);
if (task.state === 'succeeded') {
const asset = task.output?.assets?.find((item) => item.url);
if (asset) return asset.url;
if (task.output?.assets?.some((item) => item.pending)) continue;
throw new Error('Result is unavailable');
}
// 除 queued / running 外均按終態處理,可相容歷史 canceled 與未來新增終態。
if (task.state !== 'queued' && task.state !== 'running') {
throw new Error(`${task.state}: ${task.errorMessage ?? '無更多資訊'}`);
}
}
throw new Error(`任務 ${taskId} 等待逾時`);
}
generate().then(console.log);import os, time, uuid, requests
BASE = "https://api.spicyapi.ai/api/v1"
HEADERS = {
"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
"Content-Type": "application/json",
}
def call(method: str, path: str, headers=None, **kwargs) -> dict:
res = requests.request(
method, f"{BASE}{path}", headers={**HEADERS, **(headers or {})}, timeout=30, **kwargs
)
body = res.json()
# 判斷成功看 code,不要只看 status_code
if body["code"] != 200:
raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
return body["data"]
def generate() -> str:
catalog = call(
"GET",
"/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1",
)
selected = next(
(
item
for item in catalog["items"]
if item["enabled"] and item["available"] and item.get("examples")
),
None,
)
if selected is None:
raise RuntimeError("No callable text-to-image model with a validated example")
idempotency_key = str(uuid.uuid4())
task_id = call("POST", "/jobs/createTask", headers={"Idempotency-Key": idempotency_key}, json={
"model": selected["model"],
"input": selected["examples"][0]["input"],
})["taskId"]
# 2 秒起步,每次乘 1.5,封頂 15 秒
deadline = time.monotonic() + 600
wait = 2.0
while time.monotonic() < deadline:
time.sleep(wait)
wait = min(wait * 1.5, 15.0)
task = call("GET", "/jobs/recordInfo", params={"taskId": task_id})
if task["state"] == "succeeded":
assets = task.get("output", {}).get("assets", [])
ready = next((asset for asset in assets if asset.get("url")), None)
if ready:
return ready["url"]
if any(asset.get("pending") for asset in assets):
continue
raise RuntimeError("Result is unavailable")
# 除 queued / running 外均按終態處理。
if task["state"] not in {"queued", "running"}:
raise RuntimeError(f'{task["state"]}: {task.get("errorMessage", "無更多資訊")}')
raise TimeoutError(f"任務 {task_id} 等待逾時")
print(generate())package main
import (
"bytes"
"crypto/rand"
"encoding/json"
"encoding/hex"
"fmt"
"math"
"net/http"
"os"
"time"
)
const base = "https://api.spicyapi.ai/api/v1"
// envelope 是全站統一的回應外殼。Data 留成 RawMessage,
// 由各介面自己解成需要的形狀。
type envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
RequestID string `json:"request_id"`
}
var client = &http.Client{Timeout: 30 * time.Second}
func newIdempotencyKey() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return "job-" + hex.EncodeToString(b)
}
func call(method, path string, body any, idempotencyKey string, out any) error {
var rdr *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(raw)
} else {
rdr = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("SPICY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return err
}
// 判斷成功看 code,不要只看 res.StatusCode
if env.Code != 200 {
return fmt.Errorf("%d %s (request_id=%s)", env.Code, env.Msg, env.RequestID)
}
return json.Unmarshal(env.Data, out)
}
func generate() (string, error) {
var catalog struct {
Items []struct {
Model string `json:"model"`
Enabled bool `json:"enabled"`
Available bool `json:"available"`
Examples []struct {
Input map[string]any `json:"input"`
} `json:"examples"`
} `json:"items"`
}
if err := call(http.MethodGet,
"/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1",
nil, "", &catalog); err != nil {
return "", err
}
var modelID string
var modelInput map[string]any
for _, item := range catalog.Items {
if item.Enabled && item.Available && len(item.Examples) > 0 {
modelID, modelInput = item.Model, item.Examples[0].Input
break
}
}
if modelID == "" {
return "", fmt.Errorf("no callable text-to-image model with a validated example")
}
var created struct {
TaskID string `json:"taskId"`
}
err := call(http.MethodPost, "/jobs/createTask", map[string]any{
"model": modelID,
"input": modelInput,
}, newIdempotencyKey(), &created)
if err != nil {
return "", err
}
// 2 秒起步,每次乘 1.5,封頂 15 秒
wait := 2 * time.Second
deadline := time.Now().Add(10 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(wait)
wait = time.Duration(math.Min(float64(wait)*1.5, float64(15*time.Second)))
var task struct {
State string `json:"state"`
ErrorMsg string `json:"errorMessage"`
Output struct { Assets []struct { URL string `json:"url"`; Pending bool `json:"pending"` } `json:"assets"` } `json:"output"`
}
if err := call(http.MethodGet, "/jobs/recordInfo?taskId="+created.TaskID, nil, "", &task); err != nil {
return "", err
}
if task.State == "succeeded" {
pending := false
for _, asset := range task.Output.Assets {
if asset.URL != "" { return asset.URL, nil }
pending = pending || asset.Pending
}
if pending { continue }
return "", fmt.Errorf("result is unavailable")
}
// 判終態:不在這兩個中間態裡就是終態,不要逐個列舉終態
if task.State != "queued" && task.State != "running" {
return "", fmt.Errorf("%s: %s", task.State, task.ErrorMsg)
}
}
return "", fmt.Errorf("任務 %s 等待逾時", created.TaskID)
}
func main() {
url, err := generate()
if err != nil {
panic(err)
}
fmt.Println(url)
}回應外殼
原生任務 API 的 JSON 回應使用以下外殼;/v1 相容介面和 SSE 按各自協定解析:
{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }code是業務碼,200表示成功。HTTP 狀態碼同時也會正確設定,兩者並行,你可以任選一種判斷方式——但只選一種,混著判斷遲早會踩到code與 HTTP 碼不同名的情況(例如餘額不足是code: 40201+ HTTP402)。- 失敗時
data不出現(不是null)。 request_id成功失敗都帶,也在回應標頭X-Request-Id上。回報問題時附上它。

