# webhooks

来源：https://model.dflop.top/docs/guides/webhooks

视频、异步图片、音乐、异步语音、声音克隆、数字人形象这几类任务是异步的：提交后先拿到任务 id，过一会儿才有结果。除了轮询任务的 `GET`，也可以在提交时带上 `callback_url`，任务结束时平台会主动 POST 一次结果到这个地址。

## 1. 生成签名密钥

在控制台「API Key」页的「回调签名密钥」里生成一把密钥，形如 `whsec_…`。它只用来校验回调确实来自本平台，请像 API Key 一样保管。

- 账号没有密钥时，提交里的 `callback_url` 会被忽略，响应头是 `X-Webhook: ignored_no_secret`。
- 轮换密钥后，旧密钥继续签 24 小时：这期间每次回调带两个签名，新旧密钥都能校验通过，可以从容切换。

## 2. 提交时带上 `callback_url`

```bash
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：

```http
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](https://www.standardwebhooks.com) 格式：对 `{webhook-id}.{webhook-timestamp}.{请求体原始字节}` 做 HMAC-SHA256，密钥是 `whsec_` 后面那段 base64 解码后的字节，结果 base64 编码，写成 `v1,<签名>`。`webhook-signature` 里可能有多个签名（用空格分隔），任意一个对上即可。请同时拒绝时间戳与当前时间相差超过 5 分钟的请求，并且一定用**原始请求体**校验，不要先解析再序列化。

OpenAI 官方 SDK 的 `webhooks.unwrap()` 用的就是这个格式，可以直接用：

```python
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)
```

```javascript
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 时，自己校验也只要几行：

```python
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())
```

```javascript
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` 字段：

```json
"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。
