webhooks
视频、异步图片、音乐、异步语音、声音克隆、数字人形象这几类任务是异步的:提交后先拿到任务 id,过一会儿才有结果。除了轮询任务的 GET,也可以在提交时带上 callback_url,任务结束时平台会主动 POST 一次结果到这个地址。
1. 生成签名密钥#
在控制台「API Key」页的「回调签名密钥」里生成一把密钥,形如 whsec_…。它只用来校验回调确实来自本平台,请像 API Key 一样保管。
- 账号没有密钥时,提交里的
callback_url会被忽略,响应头是X-Webhook: ignored_no_secret。 - 轮换密钥后,旧密钥继续签 24 小时:这期间每次回调带两个签名,新旧密钥都能校验通过,可以从容切换。
2. 提交时带上 callback_url#
curl https://api.dflop.top/v1/videos/generations \
-H "Authorization: Bearer $XIAOZHANAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "doubao-seedance-2.0",
"prompt": "海边的日落,镜头缓慢推进",
"callback_url": "https://your.app/hooks/xiaozhanai"
}'
| 任务 | 提交接口 | 条件 |
|---|---|---|
| 视频 | POST /v1/videos/generations | — |
| 图片 | POST /v1/images/generations、POST /v1/images/edits | 需要 "async": true |
| 音乐 | POST /v1/music/generations | — |
| 语音 | POST /v1/audio/speech | 需要 "async": true |
| 声音克隆 | POST /v1/audio/voices | 需要 "async": true |
| 数字人形象 | POST /v1/videos/avatars | — |
提交的响应头 X-Webhook 说明这个地址有没有登记:
| 值 | 含义 |
|---|---|
registered | 已登记,任务结束后回调 |
ignored_no_secret | 账号还没有签名密钥 |
ignored_sync | 同步请求(没带 "async": true),结果已在响应里,不回调 |
ignored_invalid_url | 地址不可用:不是 http / https、端口不是 80 / 443、指向内网或保留地址、或超过 2048 个字符 |
ignored_unavailable | 暂时查不到签名密钥,这一单不回调,请照常轮询 |
callback_url 无论是否登记都不会出现在发给模型服务商的请求里。
3. 收到的请求#
任务结束后,平台向 callback_url 发一个 POST:
POST /hooks/xiaozhanai HTTP/1.1
content-type: application/json
user-agent: xiaozhanai-webhooks/1
webhook-id: evt_6f1c…
webhook-timestamp: 1731600000
webhook-signature: v1,K7f3…=
{"id":"evt_6f1c…","object":"event","type":"video.completed","created_at":1731600000,"data":{…}}
type是<任务>.completed(成功:succeeded,声音和形象是ready)或<任务>.failed(failed/expired/cancelled);data.status是确切的终态。任务名:video、image、music、speech、voice、avatar。data就是第一次投递时该任务的GET返回的内容(例如视频是GET /v1/videos/generations/{id}的返回)。- 图片任务结束后的
GET原样重放出图结果,没有任务 id,所以图片事件的data是{id, status, model, created_at, http_status, result},result就是那份结果。 - 结果里带内联 base64 图片、或者
data超过 256 KB 时,data只有{id, status, truncated: true},完整内容请用GET读。 - 视频成功后,平台会等成片转存到自己的存储(最多等 15 分钟)再发,所以事件里的
video_url是长期有效的那个。 - 数字人形象
ready之后仍有可能被上游判定失效而变成failed,这种变化不会再发第二个事件,以GET为准。
4. 校验签名#
签名是 Standard Webhooks 格式:对 {webhook-id}.{webhook-timestamp}.{请求体原始字节} 做 HMAC-SHA256,密钥是 whsec_ 后面那段 base64 解码后的字节,结果 base64 编码,写成 v1,<签名>。webhook-signature 里可能有多个签名(用空格分隔),任意一个对上即可。请同时拒绝时间戳与当前时间相差超过 5 分钟的请求,并且一定用原始请求体校验,不要先解析再序列化。
OpenAI 官方 SDK 的 webhooks.unwrap() 用的就是这个格式,可以直接用:
import os
from openai import OpenAI
client = OpenAI(api_key="unused", webhook_secret=os.environ["XIAOZHANAI_WEBHOOK_SECRET"])
def handle(raw_body: bytes, headers: dict):
event = client.webhooks.unwrap(raw_body, headers) # 签名不对时抛 InvalidWebhookSignatureError
print(event.type, event.data.id)
import OpenAI from "openai";
const client = new OpenAI({ apiKey: "unused", webhookSecret: process.env.XIAOZHANAI_WEBHOOK_SECRET });
export async function handle(rawBody, headers) {
const event = await client.webhooks.unwrap(rawBody, headers); // 签名不对时抛错
console.log(event.type, event.data.id);
}
不想引入 SDK 时,自己校验也只要几行:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(expected, s.removeprefix("v1,")) for s in headers["webhook-signature"].split())
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, headers, rawBody /* Buffer */) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${ts}.`).update(rawBody).digest("base64"));
return headers["webhook-signature"].split(" ").some((s) => {
const sig = Buffer.from(s.replace(/^v1,/, ""));
return sig.length === expected.length && timingSafeEqual(sig, expected);
});
}
5. 回应与重试#
- 10 秒内回 2xx 即算送达。其余情况(非 2xx、超时、连不上)在 30 秒、2 分钟、10 分钟、1 小时、6 小时、24 小时后重试,一共 7 次,之后放弃。
- 不跟随重定向;你的响应体只保留前 4 KB 用于排查。
- 至少送达一次:同一个事件可能收到不止一次,请按
webhook-id去重。重试发的是完全相同的请求体。 - 不保证顺序。
- 任务提交 48 小时后仍未结束,不再回调。
6. 查看投递状态#
任务的 GET 会多一个 webhook 字段:
"webhook": {"state": "delivered", "attempts": 1, "last_status": 200, "delivered_at": "2026-11-14T16:20:00.123+00:00"}
state 是 pending(等待任务结束或等待重试)、delivered、failed(重试用完)或 abandoned(任务不存在了、超过 48 小时未结束、或账号已注销)。图片任务结束后的 GET 原样重放结果,投递状态改放在响应头 X-Webhook-State 和 X-Webhook-Attempts 里。
7. 暂不支持#
- 智能字幕(
/v1/videos/clip-subtitles)的任务还不能回调,请轮询。 - 回调地址只能是公网的 http / https 地址,端口 80 或 443。