回呼
識別 v1 與 v2 回呼載荷,依版本取得任務 ID 並驗證簽章。附可直接使用的 Node.js、Python 與 curl 範例。
在 createTask 時傳 callBackUrl,任務進入終態時我們會向它發起一次 POST 投遞;投遞失敗會按下文的策略重試。
回呼只在終態發出,中間態(queued → running)不推送。
先設定簽章金鑰
必須設定簽章金鑰
沒有回呼簽章金鑰時,SpicyAPI 會拒絕投遞回呼。新帳號會自動產生金鑰;如果金鑰被清空,請先在控制台重新設定,再提交帶回呼網址的任務。
金鑰只儲存在伺服器端,並依照下面的程式碼驗證每一次回呼。金鑰不可用期間若錯過回呼,請透過 recordInfo 查詢任務結果。
先用 SDK 完成簽章驗證
新建 v2 接收端可直接使用 @spicyapi/sdk/webhooks 的 verifyWebhook。它會檢查原始請求本文的簽章和五分鐘時間窗,回傳 taskId 與 deliveryId。這個工具使用 Node.js crypto,在你的伺服器端執行。
下例使用 Express 和 PostgreSQL(npm install @spicyapi/sdk express pg)。資料庫透過標準的 PG* 環境變數設定,SPICY_WEBHOOK_SECRET 使用控制台的回呼簽章金鑰,接收網址透過 HTTPS 對外提供。先在你應用程式的資料庫建立兩張表:
CREATE TABLE spicy_tasks (task_id text PRIMARY KEY);
CREATE TABLE spicy_webhook_inbox (
delivery_id text PRIMARY KEY,
task_id text NOT NULL REFERENCES spicy_tasks(task_id),
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);把你自己提交任務後收到的 taskId 儲存到 spicy_tasks,只處理這些任務的事件。極快的回呼可能先於這次儲存到達,此時回傳非 2xx 後會重試。多人共用的應用程式還應檢查任務是否屬於目前的業務租戶。
import express from "express";
import pg from "pg";
import { verifyWebhook } from "@spicyapi/sdk/webhooks";
const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET");
const db = new pg.Pool();
const app = express();
app.post("/hooks/spicy", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
if (req.get("X-Webhook-Payload-Version") !== "2") return res.sendStatus(400);
let event;
try {
event = verifyWebhook({
rawBody: req.body,
timestamp: req.get("X-Webhook-Timestamp") ?? "",
signature: req.get("X-Webhook-Signature") ?? "",
payloadVersion: 2,
secret,
});
if (!event.deliveryId) return res.sendStatus(400);
} catch { return res.sendStatus(401); }
try {
const owned = await db.query("SELECT 1 FROM spicy_tasks WHERE task_id=$1", [event.taskId]);
if (!owned.rowCount) return res.sendStatus(404);
await db.query(
"INSERT INTO spicy_webhook_inbox(delivery_id,task_id,payload) VALUES($1,$2,$3) ON CONFLICT(delivery_id) DO NOTHING",
[event.deliveryId, event.taskId, JSON.stringify(event.payload)],
);
return res.sendStatus(204);
} catch { return res.sendStatus(503); }
});
app.listen(8080, "127.0.0.1");這個 raw-body 處理函式要掛在全域 express.json() 中介軟體之前。收件記錄提交成功後才回傳 2xx,再由你自己的背景工作處理已儲存事件;唯一主鍵讓重複投遞直接跳過。判斷生成成功和最終收費時仍要讀取 data.state、data.settled。延後處理時如果媒體 URL 已過期,在結果留存期內重新查詢任務取得新連結。
上例針對新串接,只接收 v2。仍需接收歷史 v1 的應用程式可以保留下文依版本驗證的邏輯;同一 SDK 支援 payloadVersion: 1,但 v1 不回傳 deliveryId。
請求
POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk…(base64)
X-Webhook-Payload-Version: 2| 標頭 | 說明 |
|---|---|
X-Webhook-Timestamp | 本次投遞的 Unix 秒。用於防重放 |
X-Webhook-Signature | HMAC-SHA256 後 base64 |
X-Webhook-Payload-Version | 請求本文的形狀版本:新串接預設是 2,歷史投遞也可能是 1。必須依這個標頭選擇解析方式。見載荷 |
User-Agent | 恆為 SpicyAPI-Webhook/1 |
我們不跟隨重新導向。回呼網址回傳 3xx 會被記為一次失敗投遞。逾時是 15 秒。
載荷
v2 與 recordInfo 同形;v1 是歷史平鋪結構
版本 2 的回呼請求本文與 GET /jobs/recordInfo 的回應同形:同一個 {code, msg, data, request_id} 信封,data 裡同一組 camelCase 欄位。版本 1 沒有信封,任務欄位以 snake_case 平鋪在頂層。
新串接請依 v2 實作,但驗證簽章的入口仍應讀取 X-Webhook-Payload-Version:v1 從頂層 task_id 取得任務 ID,v2 從 data.taskId 取得任務 ID。
下方範例用 MODEL_ID_FROM_CATALOG 表示建立任務時從已認證即時目錄選出的精確模型 ID,它不是可直接傳送的字面 ID。
{
"code": 200,
"msg": "success",
"request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w4",
"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.mp4?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"
}
}上面是 v2 範例。data 的欄位表見非同步任務模型——那一張表同時管著 recordInfo 與 v2 回呼,不在這裡重複一遍。
和 recordInfo 一樣,就緒的 data.output.assets[] 直接包含 url、expiresAt 與媒體後設資料。直接 GET 該 URL,不攜帶 API Key,無需先請求下載票據。金額欄位(cost)是不帶 $ 的十進位字串。
v2 的 code 恆為 200,即使任務失敗
不要拿 code 判斷任務成沒成功
信封上的 code 說的是「這次傳遞本身成不成立」,不是「那個任務成沒成功」。一次失敗任務的回呼,code 同樣是 200:
{
"code": 200,
"msg": "success",
"request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w5",
"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",
"completedAt": "2026-08-28T09:12:39Z"
}
}寫 if (body.code === 200) markSucceeded() 的程式碼會把每一個失敗任務判成成功,而且永遠不會自己暴露出來。任務的成敗在 data.state,失敗原因在 data.errorCode / data.errorMessage。回呼沒有請求標頭,errorMessage 的語言照帳戶設定,預設英文,見錯誤說明的語言。
這與 recordInfo 是一致的:查詢一個失敗的任務,code 也是 200。兩個出口在這一點上刻意不分叉——否則同一份資料又會有兩種判讀方式。
v2 的 request_id 是投遞 ID,可以直接當冪等鍵
信封裡的 request_id 形如 whd_…,它是這一條投遞記錄的識別碼,不是任務 ID:
- 重推時不變。 自動重試與控制台手動重推會重複使用同一條業務事件和
request_id。只有臨時媒體url/expiresAt在傳送前重新整理;v2 不要用整個 body 的摘要來去除重複。 - 所以「這條回呼我是不是已經處理過了」這個問題,直接用它回答就行——在你的資料庫裡給它建唯一索引,插入衝突就回傳 2xx。
recordInfo回應裡同一個位置放的是 HTTP 請求 ID(req_…),語義一致:都是「這一次傳遞的識別碼」。
v2 的任務 ID 在 data.taskId,那是「哪個任務」;request_id 是「哪一次送達」。v1 沒有 request_id,請依 task_id、狀態與請求本文摘要自行去除重複。
版本標頭跟著位元組走
X-Webhook-Payload-Version 報的是這份請求本文是按哪一版拼的,不是「我們現在設定成哪一版」。
業務載荷與版本在排隊時定型並寫入資料庫。重推只重新整理臨時媒體 URL,再對實際傳送的 body 計算簽章,不換成最新任務快照——版本標頭仍跟著原事件版本,否則一條排了幾小時的重試會帶著一個與自己內容對不上的版本號到達,而你正是照著這個標頭選解析器的。
v1 是凍結的相容層,新串接不要使用
有一個相容期設定可以把載荷切回 1:那是歷史形狀——snake_case、沒有信封、欄位平鋪在頂層,且比 v2 少了 input、settled、completedAt 幾個欄位。它存在的唯一理由是「已經按舊形狀接好的人不要當場炸」。
v1 不接受任何新欄位,將來的欄位一律只加在 v2 上。確認沒有 v1 串接方之後,它會連同那個設定項一起被刪除。
新串接方一律依 v2 撰寫。歷史投遞仍可能按 v1 重推,所以不要根據 JSON 外觀猜版本;請讀取 X-Webhook-Payload-Version,依下面範例的分支驗證簽章並解析。
簽章演算法
簽章 = base64( HMAC-SHA256( 金鑰, "任務 ID.時間戳記.sha256十六進位(請求本文原始位元組)" ) )三段用 . 連接:
- 任務 ID,依
X-Webhook-Payload-Version取值:v1 用頂層task_id,v2 用data.taskId(都不是request_id) X-Webhook-Timestamp的值- 請求本文的 SHA-256 摘要,小寫十六進位
請求本文的摘要必須納入簽章,你的驗證程式碼也必須真的計算它
如果只對「任務 ID + 時間戳記」簽章,攻擊者只要截獲過任意一次合法投遞的這兩個值,就能配上自己偽造的請求本文重新打到你的網址——你依公開演算法驗證,簽章是對的。
那等於我們替一份偽造的「任務已完成、結果在這裡」背書,而你會拿著它去給你的客戶扣錢、發貨。所以摘要不是可有可無的。
用原始位元組算摘要
不要先反序列化再重新序列化。JSON 的鍵序、空格、Unicode 跳脫在不同函式庫裡不一致,重新序列化出來的位元組和我們計算簽章時用的位元組幾乎肯定不同,簽章驗證必然失敗。
各框架裡拿原始位元組的方法:Express 用 express.raw(),Fastify 用 rawBody,Flask 用 request.get_data(),Django 用 request.body,FastAPI 用 await request.body()。
簽章驗證程式碼
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.SPICY_WEBHOOK_SECRET;
// 必須拿原始位元組。用了 express.json() 就拿不到了。
app.post('/hooks/spicy', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.headers, SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
const version = req.headers['x-webhook-payload-version'];
const task = version === '1' ? event : event.data;
// 先回 2xx,重活丟進自己的佇列。別讓我們等你處理完。
res.status(200).end();
enqueue(task);
});
function verify(rawBody, headers, secret) {
const ts = headers['x-webhook-timestamp'];
const sig = headers['x-webhook-signature'];
const version = headers['x-webhook-payload-version'];
if (!ts || !sig || (version !== '1' && version !== '2')) return false;
// 防重放:超出容許時間窗的請求一律拒絕,就算簽章是對的也一樣
const timestamp = Number(ts);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
// v1 的任務 ID 在頂層 task_id;v2 在 data.taskId。
// 簽章驗證通過之前,解析出來的物件都不可信。
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return false;
}
const taskId = version === '1' ? event.task_id : event.data?.taskId;
if (typeof taskId !== 'string' || !taskId) return false;
const digest = crypto.createHash('sha256').update(rawBody).digest('hex');
const expected = crypto
.createHmac('sha256', secret)
.update(`${taskId}.${ts}.${digest}`)
.digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(sig);
// 長度不等時 timingSafeEqual 會拋例外,先擋一道
if (a.length !== b.length) return false;
// 必須常數時間比較,普通的 === 會透過耗時差異洩露資訊
return crypto.timingSafeEqual(a, b);
}import base64, hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["SPICY_WEBHOOK_SECRET"]
@app.post("/hooks/spicy")
def hook():
# request.get_data() 拿的是原始位元組,request.json 不是
raw = request.get_data()
if not verify(raw, request.headers, SECRET):
return "", 401
event = json.loads(raw)
version = request.headers["X-Webhook-Payload-Version"]
task = event if version == "1" else event["data"]
enqueue(task) # 重活丟進自己的佇列,先回 2xx
return "", 200
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers.get("X-Webhook-Timestamp")
sig = headers.get("X-Webhook-Signature")
version = headers.get("X-Webhook-Payload-Version")
if not ts or not sig or version not in ("1", "2"):
return False
# 防重放:超出容許時間窗的請求一律拒絕,就算簽章是對的也一樣
try:
timestamp = int(ts)
event = json.loads(raw_body)
except (ValueError, TypeError, json.JSONDecodeError):
return False
if abs(time.time() - timestamp) > 300:
return False
# v1 的任務 ID 在頂層 task_id;v2 在 data.taskId。
# 簽章驗證通過之前,解析出來的內容都不可信。
task_id = event.get("task_id") if version == "1" else event.get("data", {}).get("taskId")
if not isinstance(task_id, str) or not task_id:
return False
digest = hashlib.sha256(raw_body).hexdigest()
expected = base64.b64encode(
hmac.new(secret.encode(), f"{task_id}.{ts}.{digest}".encode(), hashlib.sha256).digest()
).decode()
# 必須常數時間比較
return hmac.compare_digest(expected, sig)# 把一份載荷存成檔案,算出簽章,打給自己的端點。
# 這是上線前唯一能確認「簽章驗證程式碼真的能通過」的辦法。
BODY_FILE=payload.json
VERSION=2
TS=$(date +%s)
TASK_ID=$(jq -r --arg version "$VERSION" \
'if $version == "1" then .task_id else .data.taskId end' "$BODY_FILE")
DIGEST=$(openssl dgst -sha256 -hex "$BODY_FILE" | awk '{print $NF}')
SIG=$(printf '%s.%s.%s' "$TASK_ID" "$TS" "$DIGEST" \
| openssl dgst -sha256 -hmac "$SPICY_WEBHOOK_SECRET" -binary \
| base64)
curl -X POST https://your-app.example.com/hooks/spicy \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Signature: $SIG" \
-H "X-Webhook-Payload-Version: $VERSION" \
--data-binary "@$BODY_FILE"回什麼
回傳 2xx 表示收到。其他任何狀態碼、連線失敗、或超過 15 秒都算失敗,會觸發重試。
在做重活之前就先回 2xx——把任務 ID 丟進你自己的佇列再回傳。在回呼請求裡同步跑一遍下載、轉碼、通知,只會讓投遞逾時然後被重推,而你那邊其實已經處理了一半。
重試策略
失敗後按固定間隔退避重投:
| 第幾次重試 | 距上一次 |
|---|---|
| 1 | 10 秒 |
| 2 | 30 秒 |
| 3 | 2 分鐘 |
| 4 | 10 分鐘 |
| 5 | 30 分鐘 |
| 6 | 2 小時 |
| 7 | 6 小時 |
包括首投在內,共有八次自動投遞機會。排定的退避間隔合計 8 小時 42 分 40 秒,實際經過時間還包含每次請求耗時和排程延遲。次數耗盡後停止自動重試,但投遞記錄會保留,你可以在控制台手動重推。
每次重推都重新計算簽章
用的是重推那一刻的時間戳記,不是首投的。否則一條排到六小時後才發出的重試,會帶著六小時前的時間戳記到達,被你正確實作的防重放時間窗拒掉——那會讓重試機制在最需要它的時候完全失效。
對你的影響:不要快取或比對時間戳記,每次都用目前時間檢查時間窗就行。
冪等
同一個任務的回呼可能到達多次:重試、網路重複、以及人工重推。別假設只會收到一次。
v2 請用信封裡的 request_id(whd_…)去除重複:它是這條投遞記錄的識別碼,重推時不變,正是為這個用途準備的。最省事的做法是在你自己的資料庫裡給它建唯一索引,插入衝突就直接回傳 2xx。
v1 沒有 request_id;如果仍需接收 v1,請用 task_id、狀態與請求本文摘要組成冪等鍵。不要只依任務 ID 去除重複,否則同一任務未來出現不同終態記錄時會誤吞更新。
回呼網址的限制
- 只支援使用
80或443埠的http與https網址,建議使用 HTTPS。 - URL 中不能包含使用者名稱或密碼。
- 主機名稱必須能夠解析,且所有解析結果都必須是公開 IP 位址;loopback、私有網段、link-local 與雲端中繼資料位址都會被拒絕。
- 我們在真正建立連線的那一刻會再檢查一次目標 IP,因此 DNS 重新繫結這條路也是堵死的。
建任務、串流請求或報價時,若 callBackUrl 無效,回傳 HTTP 400,信封內為業務 code: 400、msg: "Invalid callback URL",並附帶 request_id。回應不會回傳提交的 URL、主機名稱、IP 或 DNS 明細。
這兩道檢查是為了防止有人把我們的伺服器當成攻擊內部網路的跳板。它的副作用是本機開發時 http://localhost:3000 用不了——請用 ngrok、Cloudflare Tunnel 這類通道工具取得一個公開網址,或者本機開發先用輪詢。

