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:// 会被拒绝。 |
| 主机 | 必须是可路由的公网地址。私有地址、回环地址、链路本地地址以及 CGNAT(100.64.0.0/10)网段都会被拒绝。 |
| 长度 | 最多 1024 个字符。 |
| 可达性 | 必须能从公网访问,以便 Atlas Cloud 向其发送 POST 请求。 |
这些检查是为了防范 SSRF。本地开发时,请使用公网隧道(webhook 测试服务、ngrok 或 Cloudflare 隧道),而不是私有地址。
回调请求
当任务进入终态时,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 时间戳(秒)。已被 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-Id | Ed25519 签名密钥的 kid——对应 JWKS 中的某个密钥。 |
User-Agent | AtlasCloud-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。
步骤
- 读取
X-AtlasCloud-Webhook-Timestamp(ts)、X-AtlasCloud-Webhook-Key-Id(kid)以及 Ed25519 签名请求头。 - (推荐)如果时间戳与你的时钟相差超过约 5 分钟,直接拒绝(防重放)。
- 获取 JWKS,并选出
kid匹配的密钥。请 缓存 JWKS;遇到无法识别的kid时 重新获取一次(签名密钥可能已轮换)。 - 解码 JWK 的
x(base64url)→ 得到 32 字节的 Ed25519 公钥。 - 用 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 Falseconst 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", 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>"的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中)。 - 轮询替代方案: 预测任务。