Webhook
非同步生成任務一完成就立刻收到通知——不必再輪詢
概覽
當您提交非同步生成任務時,Atlas Cloud 會在背景處理,結果要過一段時間才會產生。您不必反覆呼叫 預測 端點直到任務結束,而是可以要求 Atlas Cloud 在任務進入最終狀態的當下主動回呼您。
做法是在提交任務時附上 webhook_url。當任務結束時——無論是成功、失敗還是逾時——Atlas Cloud 都會向該 URL 發送一次帶簽章的 POST,內容包含最終結果。
支援的任務類型
Webhook 適用於非同步的影片、圖片與音訊生成。投遞引擎與任務類型無關——event_type(以及 X-AtlasCloud-Webhook-Event 標頭)會標示模態:video.task.terminal、image.task.terminal 或 audio.task.terminal。
Webhook 是輪詢的補充,而非取代。預測 端點的行為完全不變,而 webhook 的內容與您輪詢會取得的結果結構相同。您可以擇一使用,也可以兩者並用。
快速開始
在既有的提交請求中加上 webhook_url 欄位:
curl -X POST https://api.atlascloud.ai/api/v1/model/generateVideo \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"model": "bytedance/seedance-2.0/text-to-video",
"prompt": "A calico kitten chasing a butterfly in a garden, cinematic",
"duration": 5,
"resolution": "1080p",
"webhook_url": "https://your-app.example.com/hooks/atlascloud"
}'import requests
response = requests.post(
"https://api.atlascloud.ai/api/v1/model/generateVideo",
headers={
"Authorization": "Bearer your-api-key",
"Content-Type": "application/json",
},
json={
"model": "bytedance/seedance-2.0/text-to-video",
"prompt": "A calico kitten chasing a butterfly in a garden, cinematic",
"duration": 5,
"resolution": "1080p",
"webhook_url": "https://your-app.example.com/hooks/atlascloud",
},
)
print(response.json()["data"]["id"]) # 任務 id(session_id)const res = await fetch("https://api.atlascloud.ai/api/v1/model/generateVideo", {
method: "POST",
headers: {
Authorization: "Bearer your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "bytedance/seedance-2.0/text-to-video",
prompt: "A calico kitten chasing a butterfly in a garden, cinematic",
duration: 5,
resolution: "1080p",
webhook_url: "https://your-app.example.com/hooks/atlascloud",
}),
});
const { data } = await res.json();
console.log(data.id); // 任務 id(session_id)同一個 webhook_url 欄位在 POST /api/v1/model/generateImage(非同步圖片,event_type: "image.task.terminal")與 POST /api/v1/model/generateAudio(非同步音訊,event_type: "audio.task.terminal")上的運作方式完全相同。
提交回應維持不變——您仍會立刻拿到任務 id(即 session_id)。webhook_url 由 Atlas Cloud 自行使用,絕不會轉發給上游模型供應商。任務完成後,您的端點就會收到一個 POST。
webhook_url 的要求
您的回呼 URL 會在提交時進行驗證。若未通過驗證,提交請求會以 HTTP 400 被拒絕,且不會建立任何任務(也不會向您收費)。
| 規則 | 說明 |
|---|---|
| 協定 | 必須是 https://。純 http:// 會被拒絕。 |
| 主機 | 必須是可路由的公開位址。私有網段、回送(loopback)、連結本機(link-local)與 CGNAT(100.64.0.0/10)範圍都會被拒絕。 |
| 長度 | 最多 1024 個字元。 |
| 可連線性 | 必須可從公開網際網路連線,Atlas Cloud 才能對它發送 POST。 |
這些檢查是為了防範 SSRF。本機開發時,請使用對外通道(webhook 測試服務、ngrok 或 Cloudflare tunnel),而不要使用私有位址。
回呼請求
當任務進入最終狀態時,Atlas Cloud 會以 Content-Type: application/json 發送 POST,並帶上下列標頭:
| 標頭 | 說明 |
|---|---|
X-AtlasCloud-Webhook-Id | 任務的 session_id——您的關聯鍵與冪等鍵。 |
X-AtlasCloud-Webhook-Event | 事件類型,例如 video.task.terminal、image.task.terminal 或 audio.task.terminal。 |
X-AtlasCloud-Webhook-Timestamp | 送出這次投遞時的 Unix epoch 秒數。已納入 Ed25519 簽章範圍。 |
X-AtlasCloud-Webhook-Signature | 原始請求主體的 HMAC-SHA256,以十六進位編碼(HMAC 方案)。在純 Ed25519 方案中,此標頭改為攜帶 Ed25519 簽章——請參閱驗證簽章。 |
X-AtlasCloud-Webhook-Signature-Ed25519 | <timestamp>.<raw_body> 的 base64url Ed25519 簽章(於 HMAC→Ed25519 轉換期間一併發送)。 |
X-AtlasCloud-Webhook-Key-Id | Ed25519 簽署金鑰的 kid——對應 JWKS 中的某一把金鑰。 |
User-Agent | AtlasCloud-Webhook/1.0 |
酬載(Payload)
{
"session_id": "string", // 任務 id;您的冪等鍵
"event_type": "string", // 例如 "video.task.terminal"
"status": "OK" | "ERROR", // 最上層結果——請依此分流
"created_at": 1782295062952, // 任務建立時間(epoch 毫秒)
"payload": { // 結果內容,與預測 API 的結構相同
"model": "string",
"status": "completed" | "failed" | "timeout",
"outputs": ["https://..."], // 成功時才會出現
"error_code": 0 // 失敗時才會出現
},
"error": "string" // 僅在 status == "ERROR" 時出現
}請依最上層的 status 欄位分流處理:OK 代表 payload.outputs 中有可用的結果;ERROR 代表任務沒有產出結果,error 會說明原因。
音訊轉錄結果
文字轉語音與其他生成式音訊任務,會像影片和圖片一樣在 payload.outputs 回傳音訊檔 URL。至於**語音轉文字(轉錄)**模型,已完成的 payload 還會額外帶有結構化的 stt_result 物件(完整文字、偵測到的語言,以及字詞層級的時間戳記)——與預測端點回傳的欄位相同。
成功範例
一個已完成的 bytedance/seedance-2.0/text-to-video 任務的實際投遞內容:
{
"session_id": "6a0c02cdb4b147b7bc78881eb7229ece",
"event_type": "video.task.terminal",
"status": "OK",
"created_at": 1782295062952,
"payload": {
"model": "bytedance/seedance-2.0/text-to-video",
"status": "completed",
"outputs": [
"https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260624-0.mp4"
]
}
}失敗範例
{
"session_id": "9b2f4e7a1c0d4f5e8a6b3c2d1e0f9a8b",
"event_type": "video.task.terminal",
"status": "ERROR",
"created_at": 1782200000000,
"payload": {
"model": "bytedance/seedance-2.0/text-to-video",
"status": "failed",
"error_code": 1039
},
"error": "the input was rejected by content moderation"
}timeout 的結果外觀相同,只是 payload.status 為 "timeout",並附上一則通用的 error 訊息。
驗證簽章
在信任 webhook 之前,請務必先驗證簽章。簽章可證明請求確實來自 Atlas Cloud,且未遭竄改。
轉換期間的兩種方案
Atlas Cloud 正將 webhook 簽章從共用 HMAC 密鑰改為 Ed25519 搭配公開的 JWKS 端點。在轉換期間,每次投遞會同時帶上 HMAC 簽章(X-AtlasCloud-Webhook-Signature)與 Ed25519 簽章(X-AtlasCloud-Webhook-Signature-Ed25519)。建議優先採用 Ed25519——它只需用您從 URL 取得的公開金鑰驗證,不必保存任何共用密鑰。
Ed25519 + JWKS(建議)
Atlas Cloud 會以 Ed25519 私鑰簽署每一次投遞,並在 JWKS 端點發布對應的公開金鑰。您只要用該公開金鑰驗證即可——您這端不需要配置或輪替任何密鑰。
- 公開金鑰(JWKS):
GET https://api.atlascloud.ai/api/v1/webhooks/jwks.json(免驗證):
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "Wn_rgZDBO6nv4-ka97PPf5z8WXbM25o75dR6YukTqqI",
"use": "sig",
"alg": "EdDSA",
"kid": "cnut8IN2blKoB5zRJr9th0HoLzH-iBH3WYoUtkcJZQE"
}
]
}- 簽署訊息:
"<timestamp>.<raw_body>"——也就是X-AtlasCloud-Webhook-Timestamp的值、一個字面的.,再接上完全原始的請求主體。與 HMAC 不同,時間戳記有納入簽章範圍,因此您可以強制執行重放時間窗檢查。 - 簽章標頭:
X-AtlasCloud-Webhook-Signature-Ed25519(base64url)。HMAC 淘汰後,Ed25519 簽章會移到X-AtlasCloud-Webhook-Signature——因此請優先讀取存在的-Ed25519標頭,若無則回退到-Signature。 - 金鑰 id:
X-AtlasCloud-Webhook-Key-Id就是簽署該次投遞的 JWK 的kid。
步驟
- 讀取
X-AtlasCloud-Webhook-Timestamp(ts)、X-AtlasCloud-Webhook-Key-Id(kid)以及 Ed25519 簽章標頭。 - (建議)若時間戳記與您的時鐘相差超過約 5 分鐘,就直接拒絕(重放保護)。
- 取得 JWKS,挑出
kid相符的金鑰。請快取 JWKS;若遇到不認得的kid,就重新取得一次(簽署金鑰可能已輪替)。 - 將 JWK 的
x(base64url)解碼 → 32 位元組的 Ed25519 公開金鑰。 - 以
ts + "." + raw_body驗證 base64url 解碼後的簽章。
const crypto = require("crypto");
const JWKS_URL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json";
let jwks = {}; // kid -> jwk 對應表
async function publicKey(kid) {
if (!jwks[kid]) {
const { keys } = await (await fetch(JWKS_URL)).json();
jwks = Object.fromEntries(keys.map((k) => [k.kid, k]));
}
const jwk = jwks[kid];
return jwk && crypto.createPublicKey({ key: jwk, format: "jwk" });
}
// req.body 必須是原始請求主體的 Buffer(例如 express.raw())。
async function verifyEd25519(req) {
const ts = req.get("X-AtlasCloud-Webhook-Timestamp");
const kid = req.get("X-AtlasCloud-Webhook-Key-Id");
const sig =
req.get("X-AtlasCloud-Webhook-Signature-Ed25519") ||
req.get("X-AtlasCloud-Webhook-Signature");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 重放時間窗
const pub = await publicKey(kid);
if (!pub) return false;
const msg = Buffer.concat([Buffer.from(`${ts}.`), req.body]);
return crypto.verify(null, msg, pub, Buffer.from(sig, "base64url"));
}import time, json, base64, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
JWKS_URL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json"
_jwks = {} # kid -> jwk 對應表
def _b64u(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def _public_key(kid: str):
if kid not in _jwks:
keys = json.load(urllib.request.urlopen(JWKS_URL, timeout=5))["keys"]
_jwks.clear()
_jwks.update({k["kid"]: k for k in keys})
jwk = _jwks.get(kid)
return Ed25519PublicKey.from_public_bytes(_b64u(jwk["x"])) if jwk else None
def verify_ed25519(headers, raw_body: bytes) -> bool:
ts = headers["X-AtlasCloud-Webhook-Timestamp"]
kid = headers["X-AtlasCloud-Webhook-Key-Id"]
sig = headers.get("X-AtlasCloud-Webhook-Signature-Ed25519") \
or headers["X-AtlasCloud-Webhook-Signature"]
if abs(time.time() - int(ts)) > 300: # 重放時間窗
return False
pub = _public_key(kid)
if pub is None:
return False
try:
pub.verify(_b64u(sig), ts.encode() + b"." + raw_body)
return True
except InvalidSignature:
return Falseconst jwksURL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json"
// fetchKey 回傳 kid 對應的 base64url 公開金鑰。實際程式碼請加上快取,
// 並在找不到 kid 時(金鑰輪替)重新取得一次。
func fetchKey(kid string) (string, bool) {
resp, err := http.Get(jwksURL)
if err != nil {
return "", false
}
defer resp.Body.Close()
var set struct {
Keys []struct{ Kid, X string } `json:"keys"`
}
if json.NewDecoder(resp.Body).Decode(&set) != nil {
return "", false
}
for _, k := range set.Keys {
if k.Kid == kid {
return k.X, true
}
}
return "", false
}
func verifyEd25519(h http.Header, body []byte) bool {
ts := h.Get("X-AtlasCloud-Webhook-Timestamp")
sigB64 := h.Get("X-AtlasCloud-Webhook-Signature-Ed25519")
if sigB64 == "" {
sigB64 = h.Get("X-AtlasCloud-Webhook-Signature")
}
t, _ := strconv.ParseInt(ts, 10, 64)
if math.Abs(float64(time.Now().Unix()-t)) > 300 { // 重放時間窗
return false
}
xB64, ok := fetchKey(h.Get("X-AtlasCloud-Webhook-Key-Id"))
if !ok {
return false
}
pub, e1 := base64.RawURLEncoding.DecodeString(xB64)
sig, e2 := base64.RawURLEncoding.DecodeString(sigB64)
if e1 != nil || e2 != nil || len(pub) != ed25519.PublicKeySize {
return false
}
return ed25519.Verify(ed25519.PublicKey(pub), append([]byte(ts+"."), body...), sig)
}簽章只能證明回呼來自 Atlas Cloud,無法證明任務屬於哪個帳戶。由於 webhook_url 是逐次請求指定的,在依結果採取行動前,也請確認 session_id 對應到您確實建立過的任務。
HMAC(舊版)
HMAC 簽署即將被上述的 Ed25519/JWKS 取代,新的整合請改用 Ed25519。在轉換期間,HMAC 的 X-AtlasCloud-Webhook-Signature 仍會持續發送。
簽章的計算方式為:
HMAC-SHA256( signing_secret, raw_request_body ) → lowercase hex- 金鑰是您的共用簽署密鑰(為您的帳戶配發)。
- 訊息是請求主體的完全原始位元組——請在任何 JSON 重新序列化之前驗證,因為重新序列化可能改變欄位順序或空白字元。
- 以常數時間比較法,把結果與
X-AtlasCloud-Webhook-Signature標頭比對。
X-AtlasCloud-Webhook-Timestamp 標頭僅供參考(可用於選用的重放時間窗檢查)。它不屬於簽署內容的一部分——只有原始請求主體會被簽署。
const crypto = require("crypto");
const express = require("express");
const app = express();
const SIGNING_SECRET = process.env.ATLASCLOUD_WEBHOOK_SECRET;
// 取得原始請求主體——不要讓 JSON 解析器先執行。
app.use("/hooks/atlascloud", express.raw({ type: "*/*" }));
app.post("/hooks/atlascloud", (req, res) => {
const sig = req.get("X-AtlasCloud-Webhook-Signature") || "";
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(req.body) // req.body 是 Buffer(原始位元組)
.digest("hex");
// 以 Buffer 比較並檢查「位元組長度」:長度不一致時 timingSafeEqual 會拋出
// 例外,而格式錯誤的多位元組標頭可能在 JS 字串長度上相同,
// 位元組長度卻不同。
const sigBuf = Buffer.from(sig);
const expBuf = Buffer.from(expected);
const ok =
sigBuf.length === expBuf.length &&
crypto.timingSafeEqual(sigBuf, expBuf);
if (!ok) return res.status(401).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// ... 依 event.session_id 排入佇列,然後快速回應 ...
res.status(200).send("ok");
});import hmac, hashlib, os
from flask import Flask, request, abort
SIGNING_SECRET = os.environ["ATLASCLOUD_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/hooks/atlascloud")
def atlascloud_webhook():
raw = request.get_data() # 原始位元組,尚未經過 JSON 解析
expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-AtlasCloud-Webhook-Signature", "")
# 以 bytes 比較:對 str 使用 compare_digest 在非 ASCII 輸入時會拋出例外。
if not hmac.compare_digest(expected.encode(), received.encode()):
abort(401)
event = request.get_json()
# ... 依 event["session_id"] 排入佇列,然後盡快回傳 200 ...
return "ok", 200func verify(secret string, body []byte, sigHeader string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sigHeader))
}投遞語意
確認收到投遞
以任何 2xx 狀態碼回應即代表確認收到。其他狀態碼——或連線逾時——都會被視為失敗,該次投遞會被重試。
請盡快回應(務必在數秒內完成)。真正的處理請以非同步方式進行:先驗證簽章,再以 session_id 為鍵把事件排入佇列,然後立刻回傳 200。回應太慢有逾時的風險,並會觸發不必要的重試。
重試與退避
若投遞未獲確認,Atlas Cloud 會以指數退避重試(大約 10s → 20s → 40s → …,上限約 30 分鐘),最多重試 約 10 次。次數用盡後,該次投遞會被標記為無法送達,不再重試。
至少一次——依 session_id 去重
投遞保證為至少一次。在少數情況下,您可能會收到同一個 webhook 多次。請讓處理程式具備冪等性,並依 session_id 去重。
session_id 在多次重試之間保持不變。若某個 session_id 的 webhook 您已完整處理過,請視為無操作並回傳 200。
時效性
絕大多數 webhook 會在任務結束後數秒內送達。內建的對帳安全網可確保即使快速路徑漏掉(例如服務部署期間)也一定會送達,代價是在這些少見情況下最多可能延遲約 30 分鐘。請以最終一致、至少一次的投遞來設計,而非即時且恰好一次。
最佳實務
- 以 HTTPS 在可從公開網路連線的主機上提供回呼端點。
- 在信任事件之前先驗證 webhook 簽章——建議採用 Ed25519/JWKS(不必保存密鑰;以依
kid取得的公開金鑰驗證)。HMAC 只是轉換期間的舊版備援。 - 驗證 Ed25519 時,請快取 JWKS,並在遇到未知的
kid時重新取得;簽章檢查的內容是"<timestamp>.<raw_body>",並請強制執行重放時間窗。 - 快速回傳
2xx;把真正的處理搬到背景佇列。 - 依
session_id去重——處理程式必須具備冪等性。 - 依最上層的
status分流(OK或ERROR);從payload.outputs讀取結果。 - 不要假設順序或恰好一次;請針對至少一次來設計。
- 保留 預測 端點作為備援/對帳路徑。
- 僅限舊版 HMAC: 請妥善保管您的簽署密鑰,一旦外洩就立即輪替。(Ed25519/JWKS 在您這端沒有密鑰。)
疑難排解
| 症狀 | 可能原因/處理方式 |
|---|---|
提交時回傳 400 webhook_url ... is not a routable public address | 主機屬於私有/回送/CGNAT 位址。請改用公開的 HTTPS URL 或對外通道。 |
提交時回傳 400 webhook_url must use https | 請把協定改為 https://。 |
提交時回傳 400 webhook_url exceeds the 1024-character limit | 請縮短 URL(把狀態改存在自己的儲存體,以 session_id 為鍵)。 |
| 沒有收到 webhook | 確認端點可從公開網路連線且會回傳 2xx;並透過 預測 端點確認任務是否真的進入最終狀態。 |
| 簽章不符(Ed25519) | 請針對 "<timestamp>.<raw_body>" 簽署(而非只有請求主體),將簽章以 base64url 解碼,並依 kid 在 JWKS 中尋找金鑰。 |
| 簽章不符(HMAC) | 確認您是對原始請求主體位元組做 HMAC(而非重新序列化後的 JSON 物件),並使用正確的簽署密鑰。 |
JWKS 中找不到 kid | 簽署金鑰已輪替——請重新取得 JWKS(不要永久快取單一把金鑰)。 |
| 同一個事件收到兩次 | 這在至少一次投遞下屬正常現象——請依 session_id 去重。 |
參考
- 提交(含 webhook):
POST /api/v1/model/generateVideo、POST /api/v1/model/generateImage或POST /api/v1/model/generateAudio——加上webhook_url。 - 事件類型:
video.task.terminal、image.task.terminal、audio.task.terminal。 - JWKS(公開金鑰):
GET /api/v1/webhooks/jwks.json。 - 簽章(Ed25519,建議): 針對
"<timestamp>.<raw_body>"的 base64urlEd25519簽章,放在X-AtlasCloud-Webhook-Signature-Ed25519(金鑰 id 放在X-AtlasCloud-Webhook-Key-Id)。 - 簽章(HMAC,舊版):
HMAC-SHA256(signing_secret, raw_body),十六進位,放在X-AtlasCloud-Webhook-Signature。 - 冪等鍵:
session_id(也會出現在X-AtlasCloud-Webhook-Id)。 - 輪詢替代方案: 預測。