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:// 会被拒绝。
主机必须是可路由的公网地址。私有地址、回环地址、链路本地地址以及 CGNAT(100.64.0.0/10)网段都会被拒绝。
长度最多 1024 个字符。
可达性必须能从公网访问,以便 Atlas Cloud 向其发送 POST 请求。

这些检查是为了防范 SSRF。本地开发时,请使用公网隧道(webhook 测试服务、ngrok 或 Cloudflare 隧道),而不是私有地址。

回调请求

当任务进入终态时,Atlas Cloud 会发送一个 Content-Type: application/jsonPOST 请求,并带上以下请求头:

请求头说明
X-AtlasCloud-Webhook-Id任务的 session_id——你的关联标识与幂等键。
X-AtlasCloud-Webhook-Event事件类型,例如 video.task.terminalimage.task.terminalaudio.task.terminal
X-AtlasCloud-Webhook-Timestamp本次投递尝试发生时的 Unix 时间戳()。已被 Ed25519 签名覆盖。
X-AtlasCloud-Webhook-Signature原始请求体HMAC-SHA256,十六进制编码(HMAC 方案)。在纯 Ed25519 方案下,该请求头改为携带 Ed25519 签名——参见 验证签名
X-AtlasCloud-Webhook-Signature-Ed25519<timestamp>.<raw_body>Ed25519 签名,base64url 编码(在 HMAC→Ed25519 迁移期间发送)。
X-AtlasCloud-Webhook-Key-IdEd25519 签名密钥的 kid——对应 JWKS 中的某个密钥。
User-AgentAtlasCloud-Webhook/1.0

载荷

{
  "session_id": "string",       // 任务 id;你的幂等键
  "event_type": "string",       // 例如 "video.task.terminal"
  "status": "OK" | "ERROR",     // 顶层结果——按它分支处理
  "created_at": 1782295062952,  // 任务创建时间(毫秒时间戳)
  "payload": {                  // 结果,结构与 Predictions 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 密钥迁移到 基于公开 JWKS 端点的 Ed25519。在过渡期内,每次投递会 同时 携带 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. 用 base64url 解码后的签名对 ts + "." + raw_body 进行验证。
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 公钥。实际代码中请加上缓存,
// 以及未命中时重新获取一次(应对密钥轮换)。
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", "")
    # 以字节比较:对 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>"Ed25519 签名,base64url 编码,位于 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 中)。
  • 轮询替代方案: 预测任务