Webhook

非同步生成任務一完成就立刻收到通知——不必再輪詢

概覽

當您提交非同步生成任務時,Atlas Cloud 會在背景處理,結果要過一段時間才會產生。您不必反覆呼叫 預測 端點直到任務結束,而是可以要求 Atlas Cloud 在任務進入最終狀態的當下主動回呼您

做法是在提交任務時附上 webhook_url。當任務結束時——無論是成功失敗還是逾時——Atlas Cloud 都會向該 URL 發送一次帶簽章的 POST,內容包含最終結果。

支援的任務類型

Webhook 適用於非同步的影片圖片音訊生成。投遞引擎與任務類型無關——event_type(以及 X-AtlasCloud-Webhook-Event 標頭)會標示模態:video.task.terminalimage.task.terminalaudio.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.terminalimage.task.terminalaudio.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-IdEd25519 簽署金鑰的 kid——對應 JWKS 中的某一把金鑰。
User-AgentAtlasCloud-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

步驟

  1. 讀取 X-AtlasCloud-Webhook-Timestamp(ts)、X-AtlasCloud-Webhook-Key-Id(kid)以及 Ed25519 簽章標頭。
  2. (建議)若時間戳記與您的時鐘相差超過約 5 分鐘,就直接拒絕(重放保護)。
  3. 取得 JWKS,挑出 kid 相符的金鑰。請快取 JWKS;若遇到不認得的 kid,就重新取得一次(簽署金鑰可能已輪替)。
  4. 將 JWK 的 x(base64url)解碼 → 32 位元組的 Ed25519 公開金鑰。
  5. 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 False
const 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", 200
func 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 分流OKERROR);從 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/generateVideoPOST /api/v1/model/generateImagePOST /api/v1/model/generateAudio——加上 webhook_url
  • 事件類型: video.task.terminalimage.task.terminalaudio.task.terminal
  • JWKS(公開金鑰): GET /api/v1/webhooks/jwks.json
  • 簽章(Ed25519,建議): 針對 "<timestamp>.<raw_body>" 的 base64url Ed25519 簽章,放在 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)。
  • 輪詢替代方案: 預測