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。