任务完成回调
在任务进入终态时接收 Webhook 通知。
创建任务时填写 callBackUrl 后,任务进入 success 或 fail 状态时,xbaiapi 会发送一次 POST 请求。
回调内容
{
"code": 200,
"msg": "success",
"data": {
"taskId": "xb_a1b2c3...",
"model": "seedance-2.0",
"state": "success",
"resultUrl": "https://media.xbaiapi.com/video/2026/09/17/xb_a1b2c3.mp4",
"failMsg": null,
"creditsCharged": 165,
"completeTime": 1789651045,
"expireTime": 1792243000
}
}请求头
| 请求头 | 取值 |
|---|---|
Content-Type | application/json |
User-Agent | xbaiapi-webhook/1.0 |
X-Xbaiapi-Task-Id | 与 data.taskId 相同,便于去重 |
X-Xbaiapi-Timestamp | 本次投递签名时的 Unix 时间戳(秒) |
X-Xbaiapi-Signature | v1= 加上 HMAC-SHA256 签名的十六进制串 |
投递规则
| 规则 | 行为 |
|---|---|
| 时机 | 只在任务进入终态时发送,不推送中间进度 |
| 成功 | 任意 HTTP 2xx 响应都表示接收成功 |
| 重试 | 网络错误、超时(10 秒)以及 408、429、5xx 响应会触发重试,总共尝试 4 次 |
| 不重试 | 其他 4xx 响应会停止投递,回调标记为失败。修好接收端后,可在控制台的任务页手动重发 |
| 签名 | 每次投递都用新的时间戳重新签名 |
| 重复 | 极端情况下可能重复投递,请按 taskId 去重 |
expireTime 是结果文件的删除时间,为任务创建后 30 天;失败任务为 null。请在此之前下载文件。
请尽快返回 2xx,耗时处理应转入你自己的任务队列。
验证签名
任何知道你回调地址的人都能向它发请求。处理回调之前请先校验签名,确认请求来自 xbaiapi,且内容没有被改过。
签名密钥在控制台的 API Key 页,回调签名密钥 一栏。密钥以 whsec_ 开头,包括前缀在内的整串都是 HMAC 的 key。每个账号一把密钥,所有任务共用。重置后旧密钥立即失效,此后发出的回调(包括重试)都用新密钥签名。
校验步骤:
- 按字节读取原始请求体,不要先做 JSON 解析。解析后再序列化,字节会变,签名就对不上了。
- 如果
X-Xbaiapi-Timestamp与你的当前时间相差超过 5 分钟,拒绝请求,防止旧请求被拿来重放。 - 用密钥对
{时间戳}.{原始请求体}计算 HMAC-SHA256,转成十六进制。 - 用常量时间比较,与
X-Xbaiapi-Signature中v1=之后的值对比。 - 按
taskId去重,这样 5 分钟窗口内的重放也不会产生影响。
校验不通过的请求请返回 4xx。xbaiapi 不会重试 4xx 响应,所以如果密钥配错了,更新密钥后在控制台重发受影响的回调即可。
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.XBAIAPI_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;
function verifyXbaiapiSignature(rawBody, timestamp, signature) {
if (!timestamp || !signature?.startsWith("v1=")) return false;
const sentAt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const received = Buffer.from(signature.slice(3), "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
// express.raw 让请求体保持为 Buffer,不做解析
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyXbaiapiSignature(
req.body,
req.get("X-Xbaiapi-Timestamp"),
req.get("X-Xbaiapi-Signature"),
);
if (!ok) return res.status(401).end();
const { data } = JSON.parse(req.body.toString("utf8"));
// 按 data.taskId 去重,再交给你自己的队列处理
res.status(204).end();
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["XBAIAPI_WEBHOOK_SECRET"].encode() # whsec_...
TOLERANCE_SECONDS = 5 * 60
def verify_xbaiapi_signature(raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
if not timestamp or not signature or not signature.startswith("v1="):
return False
try:
sent_at = int(timestamp)
except ValueError:
return False
if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature[3:])
app = Flask(__name__)
@app.post("/webhook")
def webhook():
raw_body = request.get_data() # 原始字节,未经 JSON 解析
if not verify_xbaiapi_signature(
raw_body,
request.headers.get("X-Xbaiapi-Timestamp"),
request.headers.get("X-Xbaiapi-Signature"),
):
abort(401)
data = json.loads(raw_body)["data"]
# 按 data["taskId"] 去重,再交给你自己的队列处理
return "", 204涉及资金或对外发布的操作,例如给用户加额度、上架视频,建议再用 taskId 调一次 GET /api/v1/jobs/recordInfo,以查询结果中的状态和产物为准。回调只用来通知你该去查了。