# 望月号池第三方 API 对接文档

版本：v1（对接版 2026-10-20）  
适用范围：第三方服务端调用望月自有号池，提交视频生成任务、上传私有参考素材、查询任务和下载结果。

这份文档按实际接入顺序编写。第三方完成接入前，请至少跑通文末的验收清单。生产调用必须在第三方自己的服务端完成；不要把 Secret 放到浏览器、移动端、桌面客户端或公开仓库。

## 1. 地址、边界和最短流程

### 1.1 正式地址

| 用途 | 地址 |
| --- | --- |
| 生产 API 根地址 | `https://1456719883-78ndn27wdv.ap-guangzhou.tencentscf.com` |
| 开发者站 | `https://developers.wangyueaigc.cn/` |
| 在线 Markdown 文档 | `https://developers.wangyueaigc.cn/API.md` |
| API 健康检查 | `GET /health` |
| 公开发现 | `GET /v1/pool/api/discovery` |
| 公开模型目录 | `GET /v1/pool/api/models` |

`developers.wangyueaigc.cn` 是静态文档和开发者控制台，不是业务 API 根域。不要把它拼成 `/v1/pool/api/...` 调用；这些路径在文档站返回 404 是正常的。`api.wangyueaigc.cn` 当前不是生产入口，也不要使用。第三方服务端不受浏览器 CORS 限制。

### 1.2 最短成功链路

1. 在开发者站注册并登录望月账号。
2. 创建并保存生产 API Client 的 `keyId` 和 `secret`。
3. 调用 `/v1/pool/api/models`，按实时目录选择模型和能力参数。
4. 文生视频直接提交任务；图生视频先申请素材租约、上传二进制、确认素材为 `ready`。
5. 调用 `/v1/pool/api/tasks` 提交任务，保存返回的 `task.id`。
6. 轮询 `/v1/pool/api/tasks/{taskId}`，或配置可选 Webhook。
7. `completed` 后调用 `/v1/pool/api/tasks/{taskId}/result`，立即下载短时签名地址。

### 1.3 公开接口和签名接口

公开发现接口 `GET /v1/pool/api/discovery` 和模型目录 `GET /v1/pool/api/models` 不需要 API Client 凭证，也不需要签名：

- `GET /health`
- `GET /v1/pool/api/discovery`
- `GET /v1/pool/api/models`

以下业务接口全部需要 HMAC 签名：

- `POST /v1/pool/api/assets/input/lease`
- `POST /v1/pool/api/assets/{assetId}/confirm`
- `POST /v1/pool/api/tasks`
- `GET /v1/pool/api/tasks`
- `GET /v1/pool/api/tasks/{taskId}`
- `POST /v1/pool/api/tasks/{taskId}/cancel`
- `GET /v1/pool/api/tasks/{taskId}/result`

## 2. 创建生产凭证

在开发者站登录后进入“生产凭证”，创建 API Client。自助创建接口属于网页登录会话，不属于第三方 HMAC API；第三方不要尝试签名调用 `/v1/pool/api-clients`。

创建成功时只显示一次：

- `keyId`：UUID，放入 `X-Wangyue-Key-Id`。
- `secret`：HMAC-SHA256 密钥，只保存在第三方服务端密钥管理系统。

当前自助策略默认每个账号最多 1 个 active Client，默认并发上限 8、每日任务额度 500；可配置上限以控制台和创建响应为准（并发最多 8、每日额度最多 5000）。轮换 Secret 后旧 Secret 立即失效。创建或轮换响应中的 Secret 不会再次显示，请在第一次响应时安全保存。

Webhook 的 `webhookSecret` 也只在配置或轮换时显示一次，和 API `secret` 是两把不同的密钥。

当前公开 API 没有独立的 sandbox 地址；除非望月另行发放联调凭证，调用会使用该账号的正式余额、并发和每日额度。建议先用一条低成本任务完成联调，再扩大并发；不要用生产 Secret 做离线签名测试后把请求直接重放到线上。

## 3. HMAC-SHA256 请求签名

### 3.1 请求头

签名业务请求至少带：

```http
X-Wangyue-Key-Id: 你的 keyId
X-Wangyue-Timestamp: 1776936222
X-Wangyue-Nonce: 每次请求唯一的随机串
X-Wangyue-Signature: 小写十六进制 HMAC
Content-Type: application/json
```

只有 `POST /v1/pool/api/tasks` 必须额外带幂等键：

```http
X-Idempotency-Key: partner-task-20261020-0001
```

请求体如果同时存在 `idempotencyKey`，必须逐字匹配请求头中的 `X-Idempotency-Key`。其他请求不需要幂等键；签名原文第五行使用空字符串即可。

生产接入请始终发送请求头，并在需要时把同一个值同时放入 JSON 的 `idempotencyKey` 字段。当前网关为兼容旧客户端也接受“只放 JSON 字段”的任务提交，但第三方不要依赖这个兼容行为；请求头是公开协议中的稳定写法。

约束：

- `X-Wangyue-Key-Id` 必须是 UUID。
- 时间戳是 Unix 秒，默认允许与服务端相差不超过 300 秒。
- Nonce 只能使用 `[A-Za-z0-9_-]`，长度 16–96；默认 600 秒内不可重复。
- Signature 必须是 64 位小写十六进制字符串。
- 幂等键只能使用 `[A-Za-z0-9][A-Za-z0-9._:@-]{7,99}`。
- 请求头名称大小写不敏感，但签名值必须小写十六进制。

### 3.2 六行签名原文

签名原文严格使用 LF (`\n`) 连接以下六行，不要增加空格或末尾换行：

```text
METHOD
PATH_WITH_SORTED_QUERY
TIMESTAMP
NONCE
IDEMPOTENCY_KEY
SHA256(STABLE_JSON_BODY)
```

说明：

- `METHOD` 使用大写，例如 `GET`、`POST`。
- `PATH_WITH_SORTED_QUERY` 使用实际请求路径，不带域名；去掉末尾多余 `/`。Query 参数先按键名、再按值排序，再使用 `URLSearchParams` 形式编码。签名中的 Query 必须和实际发送的 Query 完全一致。
- `TIMESTAMP`、`NONCE` 与请求头完全一致。
- 非任务提交请求的 `IDEMPOTENCY_KEY` 为空字符串。
- `STABLE_JSON_BODY` 是递归按对象键名排序的 JSON；数组顺序保持不变；无请求体使用 `{}`。签名用的 JSON 与实际发送的 JSON 字段和值必须一致。
- 用 `secret` 对六行原文做 HMAC-SHA256，输出小写 hex。

### 3.3 Node.js 签名示例

```js
const crypto = require('node:crypto');

const API_BASE = 'https://1456719883-78ndn27wdv.ap-guangzhou.tencentscf.com';
const KEY_ID = process.env.WANGYUE_KEY_ID;
const SECRET = process.env.WANGYUE_API_SECRET;

function stable(value) {
  if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
  if (value && typeof value === 'object') {
    return `{${Object.keys(value).sort()
      .map(key => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`;
  }
  return JSON.stringify(value);
}

function canonicalQuery(query = {}) {
  const pairs = Object.entries(query).flatMap(([key, value]) =>
    (Array.isArray(value) ? value : [value]).map(item => [String(key), String(item ?? '')])
  );
  pairs.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
  const encoded = new URLSearchParams(pairs).toString();
  return encoded ? `?${encoded}` : '';
}

function signRequest({ method, path, query, body = {}, idempotencyKey = '' }) {
  const timestamp = Math.floor(Date.now() / 1000);
  const nonce = crypto.randomBytes(18).toString('base64url');
  const target = `${path}${canonicalQuery(query)}`;
  const bodyHash = crypto.createHash('sha256').update(stable(body), 'utf8').digest('hex');
  const canonical = [method.toUpperCase(), target, timestamp, nonce, idempotencyKey, bodyHash].join('\n');
  const signature = crypto.createHmac('sha256', SECRET).update(canonical, 'utf8').digest('hex');
  return { timestamp, nonce, target, signature };
}

async function wangyueRequest({ method, path, query = {}, body = {}, idempotencyKey = '' }) {
  const signed = signRequest({ method, path, query, body, idempotencyKey });
  const url = `${API_BASE}${signed.target}`;
  const headers = {
    'Content-Type': 'application/json',
    'X-Wangyue-Key-Id': KEY_ID,
    'X-Wangyue-Timestamp': String(signed.timestamp),
    'X-Wangyue-Nonce': signed.nonce,
    'X-Wangyue-Signature': signed.signature,
  };
  if (idempotencyKey) headers['X-Idempotency-Key'] = idempotencyKey;
  const response = await fetch(url, {
    method,
    headers,
    body: method === 'GET' ? undefined : JSON.stringify(body),
  });
  const payload = await response.json();
  if (!response.ok) {
    const error = new Error(payload.message || `HTTP ${response.status}`);
    error.status = response.status;
    error.code = payload.error;
    error.details = payload.details;
    throw error;
  }
  return payload;
}
```

重试时必须重新生成时间戳和 Nonce；只有任务提交的幂等键保持不变。不要重发同一个 Nonce。

## 4. 发现和模型目录

### 4.1 发现

```http
GET https://1456719883-78ndn27wdv.ap-guangzhou.tencentscf.com/v1/pool/api/discovery
```

返回包含 `apiVersion`、`gatewayVersion`、`docsPath`、`modelsPath`、`authentication`、`supportedModels`、`billing` 和 `resultDelivery`。发现接口和健康检查只能证明入口可访问，不代表某个模型当前有在线号池节点。

### 4.2 模型目录

```http
GET https://1456719883-78ndn27wdv.ap-guangzhou.tencentscf.com/v1/pool/api/models
```

返回：

```json
{ "ok": true, "models": [ /* 当前公开模型 */ ] }
```

第三方每次发布或启动时都应读取目录，使用 `modelId` 和 `metadata` 里的实时能力，不要永久写死价格和参数。常用公开字段包括：

- `modelId`、`displayName`、`provider`、`fixedPriceMicros`、`priceVersion`、`priceScope`
- `metadata.poolChannel`：Seedance 为 `doubao`，Vidu Q3 为 `vidu`
- `durations`、`defaultDuration`
- `aspectRatios`、`defaultAspectRatio`
- `resolutions`、`defaultResolution`
- `referenceModes`、`maxReferenceImages`、`maxReferenceAudios`
- Vidu 的 `codecs`、`defaultCodec`、`generationModes`、`defaultGenerationMode`、`quantities`、`defaultQuantity`

价格字段单位是微元（`1 CNY = 1,000,000`）；例如 `fixedPriceMicros: 100000` 表示人民币 `0.10` 元。提交时服务端以当前 `priceVersion` 预冻结账号余额，目录价格仅用于展示和预算，不能在任务请求中覆盖。

当前目录通常包含：

| modelId | 公开渠道 | 当前常见能力 |
| --- | --- | --- |
| `seedance2.0fast` | `doubao` | 10 或 15 秒，720P，常用比例 16:9/9:16/4:3/3:4/1:1 |
| `seedance2.0mini` | `doubao` | 15 秒，720P，常用比例 16:9/9:16/4:3/3:4/1:1 |
| `vidu-q3` | `vidu` | 3–16 秒，720P/1080P，H264/H265，数量 1–4；只支持图片参考，不支持音频参考 |

以上表格是当前目录的便捷摘要；实时 `/models` 返回值优先。`channelId` 是内部目录标识，任务请求应使用 `metadata.poolChannel` 对应的 `settings.channel`，不要把 Seedance 的 `channelId: doubao_pool` 当成任务渠道。

Vidu Q3 的 `metadata.referenceModes` 现已统一为 `auto`、`start_frame`、`start_end`，与任务提交校验一致。若本地缓存仍显示历史值 `image_reference`，请刷新模型目录；不要把 `image_reference` 原样提交。

## 5. 文生视频任务

### 5.1 任务接口

```http
POST /v1/pool/api/tasks
```

推荐请求结构：

```json
{
  "type": "doubao_video",
  "taskId": "11111111-1111-4111-8111-111111111111",
  "payload": {
    "projectId": "partner-demo",
    "episodeId": "episode-1",
    "shotId": "shot-1",
    "rowId": "shot-1",
    "prompt": "雨夜霓虹街道，镜头缓慢推进，电影感光影",
    "settings": {
      "model": "seedance2.0fast",
      "channel": "doubao",
      "duration": 10,
      "aspectRatio": "16:9",
      "resolution": "720P"
    }
  }
}
```

`X-Idempotency-Key` 是任务提交必填项；也可以在 JSON 中放同名 `idempotencyKey`，但两者必须完全一致。`taskId` 建议由第三方预先生成 UUID；如果有素材，必须在申请租约、确认素材和提交任务中复用同一个 `taskId`。

必填字段：

- `type`：`doubao_video` 或 `pool_video`。
- `payload.projectId`：第三方自己的项目标识。
- `payload.rowId`：第三方自己的镜头/任务行标识；也可由 `shotId` 兜底，但建议显式传。
- `payload.prompt`：最长 50000 字符。
- `payload.settings.model`：以公开模型目录为准；不传时服务端默认 `seedance2.0fast`。

`payload` 序列化后最大 512 KiB；不要把视频、图片、音频或超大的自定义对象直接嵌入 JSON。媒体必须走素材租约和 COS 二进制直传流程。

`episodeId`、`shotId`、`settings.channel`、`settings.duration`、`settings.aspectRatio`、`settings.resolution` 是可选字段，但生产请求建议显式传递。服务端会把请求归一化后保存，任务查询返回安全的项目、模型和状态字段，不返回提示词、Cookie 或节点凭据。

### 5.2 Seedance

Fast 10 秒：`model=seedance2.0fast`、`channel=doubao`、`duration=10`。  
Fast 15 秒：同一模型，`duration=15`。  
Mini：`model=seedance2.0mini`、`channel=doubao`、`duration=15`。

如果望月为你的 API Client 单独开通了豆包企业号池，可将渠道写成 `doubao_enterprise`（也接受“豆包企业账号”等历史别名）。企业渠道由号池节点能力决定，不在公开 `/models` 的普通模型列表中；请求可省略 `settings.model`（服务端会把企业任务路由为企业能力），时长固定为 10 秒。不要把 Vidu 模型和企业渠道混用；没有企业节点时任务会保持 `queued`，应按状态轮询并联系望月确认节点能力。

Fast 10 秒和 Fast 15 秒是号池内部不同工作流，第三方只需按目录传正确的模型、渠道和时长；不要把 15 秒 Mini 写成 Fast，也不要省略时长后依赖默认值。

### 5.3 Vidu Q3

```json
{
  "type": "pool_video",
  "taskId": "22222222-2222-4222-8222-222222222222",
  "payload": {
    "projectId": "partner-demo",
    "rowId": "vidu-1",
    "prompt": "一只小猫在阳光下奔跑",
    "settings": {
      "model": "vidu-q3",
      "channel": "vidu",
      "duration": 8,
      "resolution": "1080P",
      "codec": "H264",
      "generationMode": "营销广告",
      "aspectRatio": "16:9",
      "quantity": 1
    }
  }
}
```

服务端当前约束：`duration` 为 3–16 的整数；`resolution` 为 `720P` 或 `1080P`；`codec` 为 `H264` 或 `H265`；`generationMode` 为 `闪电出片`、`电影大片` 或 `营销广告`；`aspectRatio` 为 `16:9`、`9:16`、`1:1`、`4:3`、`3:4`；`quantity` 为 1–4。仍应以实时模型目录为准。

## 6. 参考图片/音频：租约、直传、确认

图片或音频不能直接作为公网 URL 传入任务。必须使用私有素材流程：

`lease → PUT 原始二进制 → confirm → task`。

### 6.1 支持类型和摘要

当前支持的 `contentType`：

- 图片：`image/jpeg`、`image/png`、`image/webp`
- 音频：`audio/mpeg`、`audio/mp3`、`audio/wav`、`audio/x-wav`、`audio/mp4`、`audio/aac`、`audio/ogg`、`application/ogg`、`audio/flac`、`audio/webm`、`audio/x-ms-wma`

申请租约时必须先读取完整文件并计算：

- `sizeBytes`：原始字节数，必须大于 0；默认最大 50 MiB，最终以返回的 `upload.maxBytes` 为准。
- `sha256`：原始字节的 64 位小写 hex SHA-256。
- `md5Base64`：原始字节的标准 Base64 MD5，通常形如 22 个字符加 `==`。

Node.js 计算示例（`bytes` 必须是未经转码的原始文件内容）：

```js
const fs = require('node:fs/promises');
const crypto = require('node:crypto');

const bytes = await fs.readFile('reference.jpg');
const sizeBytes = bytes.length;
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
const md5Base64 = crypto.createHash('md5').update(bytes).digest('base64');
```

### 6.2 申请租约

```http
POST /v1/pool/api/assets/input/lease
```

```json
{
  "taskId": "33333333-3333-4333-8333-333333333333",
  "fileName": "reference.jpg",
  "contentType": "image/jpeg",
  "sizeBytes": 123456,
  "sha256": "64位小写SHA-256",
  "md5Base64": "MD5_BASE64_DIGEST"
}
```

图生视频必须预先传 `taskId`；不传时服务端会自动生成，但第三方随后必须从响应中取回该 `taskId`，不能另起一个任务 ID。相同 API Client、相同 `taskId`、类型、大小和摘要重试申请时：`pending` 会复用原 asset 并重新签发短时上传 URL；`ready` 会返回 `alreadyUploaded: true` 且 `upload: null`。这就是素材申请的幂等恢复机制。

返回中的 `upload` 是唯一可信上传指令：

```json
{
  "ok": true,
  "taskId": "33333333-3333-4333-8333-333333333333",
  "alreadyUploaded": false,
  "asset": {
    "id": "44444444-4444-4444-8444-444444444444",
    "taskId": "33333333-3333-4333-8333-333333333333",
    "kind": "input",
    "state": "pending",
    "fileName": "reference.jpg",
    "contentType": "image/jpeg",
    "sizeBytes": 123456,
    "sha256": "64位小写SHA-256",
    "confirmedAt": null
  },
  "upload": {
    "method": "PUT",
    "url": "SIGNED_UPLOAD_URL",
    "headers": {
      "content-md5": "MD5_BASE64_FROM_SERVER",
      "content-type": "image/jpeg",
      "x-cos-meta-sha256": "SHA256_FROM_SERVER"
    },
    "maxBytes": 52428800,
    "expiresAt": "2026-10-20T12:00:00.000Z"
  }
}
```

### 6.3 PUT 上传和确认

按返回的 `upload.method`、`upload.url` 和 `upload.headers` 原样发送原始字节：

```bash
curl --fail-with-body -X PUT \
  -H "content-md5: MD5_BASE64_FROM_SERVER" \
  -H "content-type: image/jpeg" \
  -H "x-cos-meta-sha256: SHA256_FROM_SERVER" \
  --data-binary @reference.jpg \
  "SIGNED_UPLOAD_URL"
```

禁止：

- 使用 `multipart/form-data`。
- 修改签名 URL 的任何 Query 参数。
- 修改、遗漏或自行重算租约返回的三个请求头。
- 把 COS 长期 Secret、对象 Key 或本地文件路径传给任务接口。

PUT 成功后调用：

```http
POST /v1/pool/api/assets/44444444-4444-4444-8444-444444444444/confirm
```

确认通常使用空 JSON 对象 `{}`。网关会通过 COS HEAD 校验文件大小、Content-Type、`x-cos-meta-sha256` 和 ETag/MD5；通过后 `asset.state` 变为 `ready`。重复 confirm 是幂等的，会返回 `idempotent: true`。

网络中断、408、429、5xx 时可以在租约未过期前重试同一个 PUT；每次 API 重试要生成新时间戳和 Nonce。租约过期后重新申请租约，不要继续使用旧 URL。confirm 返回 `pool_asset_not_uploaded`、大小/类型/摘要不匹配时，先修正文件或请求头，再重试 confirm。

### 6.4 在任务中引用素材

```json
{
  "type": "doubao_video",
  "taskId": "33333333-3333-4333-8333-333333333333",
  "payload": {
    "projectId": "partner-demo",
    "rowId": "shot-image-1",
    "prompt": "从参考画面开始，镜头缓慢推进，人物自然转身",
    "referenceImageAssetIds": ["44444444-4444-4444-8444-444444444444"],
    "settings": {
      "model": "seedance2.0mini",
      "channel": "doubao",
      "duration": 15,
      "referenceMode": "start_frame",
      "aspectRatio": "16:9",
      "resolution": "720P"
    }
  }
}
```

支持的参考模式：

- `auto`：自动参考。
- `start_frame`：必须正好 1 张参考图片。
- `start_end`：必须正好 2 张参考图片。

每个任务最多 9 张图片、最多 1 个音频（Vidu Q3 的 `maxReferenceAudios=0`，不能传音频），所有素材必须属于当前 API Client、同一望月账号和同一 `taskId`，且状态为 `ready`。不支持把 `referenceImages`、`referenceAudios` 公网 URL 直接塞进任务；会返回 `pool_reference_assets_required`。

## 7. 任务状态、轮询、结果和取消

### 7.1 查询

单个任务：

```http
GET /v1/pool/api/tasks/{taskId}
```

任务列表：

```http
GET /v1/pool/api/tasks?limit=30&status=queued
```

`limit` 为 1–100；`status` 可选值为 `queued`、`leased`、`review_required`、`completed`、`failed`、`cancelled`。当前没有 `offset` 参数；需要分页时保存上次结果并按业务策略继续查询。

### 7.2 状态含义

- `queued`：任务已入队，等待匹配的号池节点和账号。
- `leased`：节点已领取并执行。
- `review_required`：提交状态不确定，系统不会自动重复提交，需要继续查询并联系我方。
- `completed`：生成完成，可调用 result 接口。
- `failed`：任务失败，查看 `task.error.code` 和 `task.error.message`。
- `cancelled`：任务已取消。

`queued` 不等于失败；可能是当前没有匹配模型、渠道、工作流或可用账号。建议从 2 秒开始轮询，使用 2–10 秒指数退避，并设置业务侧总超时。不要因为超时就换一个幂等键重新提交。

任务响应示例：

```json
{
  "ok": true,
  "task": {
    "id": "33333333-3333-4333-8333-333333333333",
    "status": "queued",
    "type": "doubao_video",
    "attemptCount": 0,
    "maxAttempts": 3,
    "submissionState": "not_submitted",
    "progressStatus": "",
    "projectId": "partner-demo",
    "rowId": "shot-image-1",
    "channel": "doubao",
    "model": "seedance2.0mini",
    "duration": 15,
    "aspectRatio": "16:9",
    "resolution": "720P",
    "result": null,
    "error": null,
    "createdAt": "2026-10-20T12:00:00.000Z",
    "updatedAt": "2026-10-20T12:00:00.000Z",
    "completedAt": null
  }
}
```

### 7.3 下载结果

只有 `status=completed` 后才调用：

```http
GET /v1/pool/api/tasks/{taskId}/result
```

返回 `asset` 元数据和短时 `download.url`（默认约 5 分钟，实际以 `download.expiresAt` 为准）。第三方应立即下载到自己的对象存储；URL 过期时重新调用 result 接口获取新 URL，不要把它当永久地址。

### 7.4 取消

```http
POST /v1/pool/api/tasks/{taskId}/cancel
```

只有尚未被节点领取的 `queued` 任务可以安全取消。`leased`、`review_required`、已完成、失败或已取消的任务可能返回 `pool_task_cancel_unsafe` 或状态冲突，不会强行中止上游执行。

### 7.5 幂等和重试原则

| 场景 | 时间戳/Nonce | 幂等键 |
| --- | --- | --- |
| 连接中断、超时、5xx 重试任务提交 | 每次重新生成 | 原值不变 |
| 业务返回 401/`pool_api_replay` | 重新检查签名并生成新 Nonce | 任务提交仍用原值 |
| 同一任务内容再次提交 | 新签名 | 原值不变，返回原任务 |
| 换了 prompt、模型或素材 | 新签名 | 必须新建幂等键 |
| 素材 PUT 重试 | 使用租约原 URL/headers，直到过期 | 不涉及 API 幂等键 |

## 8. Webhook（可选）

不配置 Webhook 不影响任务提交和轮询。可以在开发者站为对应 API Client 配置公开 HTTPS 回调地址；Webhook 配置属于网页登录会话，不是上述 HMAC 业务 API。

事件类型：`task.completed`、`task.failed`、`task.cancelled`、`task.review_required`。

回调 JSON 是一个事件对象；`id` 是本次投递的唯一 ID（也是接收方去重键），`data.task` 与任务查询接口返回的安全任务对象一致：

```json
{
  "id": "55555555-5555-4555-8555-555555555555",
  "type": "task.completed",
  "createdAt": "2026-10-20T12:03:00.000Z",
  "data": {
    "task": {
      "id": "33333333-3333-4333-8333-333333333333",
      "status": "completed",
      "result": { "assetId": "66666666-6666-4666-8666-666666666666" },
      "error": null
    }
  }
}
```

`task.failed` 和 `task.review_required` 时应读取 `data.task.error`；收到 `task.completed` 后仍需调用 result 接口取得最新的短时下载 URL。事件可能重复投递，不能用 `createdAt` 代替 `id` 去重。

请求头：

```http
X-Wangyue-Webhook-Timestamp: 1776936222
X-Wangyue-Webhook-Id: DELIVERY_UUID
X-Wangyue-Webhook-Signature: 小写十六进制 HMAC
Content-Type: application/json; charset=utf-8
```

签名原文：

```text
TIMESTAMP
DELIVERY_ID
RAW_JSON_BODY
```

必须使用收到的原始 HTTP body 字节对应的 JSON 文本计算 HMAC-SHA256；不要先解析再重新序列化。Webhook Secret 只在配置/轮换时显示一次。以 `X-Wangyue-Webhook-Id` 做接收方幂等，重复投递必须返回 2xx。

回调地址必须是公开 HTTPS，不能是 localhost、私有网段、带账号密码的 URL，也不能发生重定向。服务端超时约 10 秒；非 2xx、超时或网络失败会重试，最多 8 次投递尝试（首次加最多 7 次重试），间隔依次约为 30 秒、60 秒、5 分钟、15 分钟、1 小时、3 小时、6 小时。轮询始终是最终兜底。

Node.js 验签示例：

```js
const crypto = require('node:crypto');

function verifyWebhook(rawBody, headers, webhookSecret) {
  const timestamp = headers['x-wangyue-webhook-timestamp'];
  const deliveryId = headers['x-wangyue-webhook-id'];
  const received = headers['x-wangyue-webhook-signature'];
  if (!/^\d+$/.test(String(timestamp)) ||
      !/^[0-9a-f-]{36}$/i.test(String(deliveryId)) ||
      !/^[0-9a-f]{64}$/.test(String(received))) return false;
  const canonical = `${timestamp}\n${deliveryId}\n${rawBody}`;
  const expected = crypto.createHmac('sha256', webhookSecret)
    .update(canonical, 'utf8').digest('hex');
  return crypto.timingSafeEqual(Buffer.from(String(received), 'hex'), Buffer.from(expected, 'hex'));
}
```

生产接收方还应拒绝与本机时间相差超过 300 秒的 `X-Wangyue-Webhook-Timestamp`，并在验签成功后按 delivery ID 原子落库再返回 2xx；这样既能防止重放，也能安全处理重复投递。

## 9. 错误响应和处理表

错误响应统一为 JSON；请保存 HTTP 状态、`error`、`message` 和 `details`，不要只记录“请求失败”：

```json
{
  "ok": false,
  "error": "pool_api_unauthorized",
  "message": "API signature is invalid"
}
```

| HTTP | 常见错误码 | 处理方式 |
| --- | --- | --- |
| 400 | `request_json_invalid`、`pool_task_type_unsupported`、`task_id_invalid`、`video_model_unsupported`、`video_channel_unsupported`、`video_channel_model_mismatch` | 修正请求结构、UUID、模型或渠道 |
| 400 | `doubao_duration_unsupported`、`vidu_duration_unsupported`、`vidu_resolution_unsupported`、`vidu_codec_unsupported`、`vidu_generation_mode_invalid`、`vidu_aspect_ratio_unsupported`、`vidu_quantity_unsupported` | 按 `/models` 的实时能力修改参数 |
| 400 | `idempotency_key_invalid`、`idempotency_key_mismatch`、`pool_reference_mode_unsupported`、`pool_reference_mode_image_count_invalid` | 修正幂等键或参考图数量 |
| 400 | `pool_reference_assets_required`、`pool_reference_asset_type_mismatch`、`pool_reference_asset_type_invalid`、`reference_assets_too_many`、`reference_images_too_many`、`reference_audios_too_many`、`vidu_audio_reference_unsupported` | 改用已确认的私有 asset ID，并按模型目录的图片/音频限制 |
| 400 | `asset_type_invalid`、`asset_size_invalid`、`asset_sha256_invalid`、`asset_md5_invalid` | 重新计算原始文件摘要，使用支持的 MIME 类型后重新申请租约（文件名会由服务端清洗并补齐扩展名） |
| 401 | `pool_api_unauthorized`、`pool_api_clock_skew` | 检查 Key、Secret、签名路径、body、时钟；不要重用旧 Nonce |
| 402 | `balance_insufficient` | API Client 所属望月账号余额不足；先充值或联系望月开通计费，再用原幂等键重试任务提交 |
| 404 | `pool_task_not_found`、`pool_asset_not_found`、`wallet_not_found` | 检查 taskId/assetId、API Client 归属；若为 `wallet_not_found`，联系望月初始化账号计费 |
| 409 | `pool_api_replay` | 生成新 Nonce；任务提交保留原幂等键 |
| 409 | `pool_task_idempotency_conflict` | 同一幂等键内容不能变化；需要新任务时使用新幂等键 |
| 409 | `pool_api_asset_scope_invalid`、`pool_input_asset_unavailable` | 确认素材属于当前 Client、同一 taskId 且为 `ready` |
| 409 | `pool_asset_not_uploaded`、`pool_asset_size_mismatch`、`pool_asset_type_mismatch`、`pool_asset_checksum_mismatch` | 检查原始字节和租约请求头后重新上传/确认 |
| 409 | `pool_task_already_submitted` | 不能给已提交任务追加素材；新建 taskId |
| 409 | `pool_result_not_ready`、`pool_task_cancel_unsafe`、`pool_task_cancel_changed` | 继续轮询或接受任务当前不可取消；取消竞争状态变化时以最新任务状态为准 |
| 429 | `pool_api_concurrency_limit`、`pool_api_daily_limit`、`pool_task_asset_limit`、`pool_daily_asset_limit` | 等待并发/额度恢复；不要刷请求 |
| 502 | `pool_asset_verification_failed` | 稍后重试 confirm；保存时间和错误 details |
| 503 | `pool_api_disabled`、`pool_submission_disabled`、`pool_asset_store_unavailable` | 联系望月确认 API 开关、号池和 COS 状态 |

网络超时、408、429、5xx：使用指数退避；任务提交重新签名但复用幂等键，其他请求重新生成 Nonce。对 401、400、409 参数错误不要盲目重试。

## 10. 线上验收清单

第三方上线前逐项确认：

- [ ] 使用 API 根地址，而不是开发者站域名。
- [ ] Secret 只在服务端保存，日志已脱敏。
- [ ] 能成功读取 `/v1/pool/api/discovery` 和 `/v1/pool/api/models`。
- [ ] 已实现递归稳定 JSON、Query 排序编码、六行 HMAC 签名。
- [ ] 每个请求使用唯一 Nonce；服务器时钟已同步。
- [ ] 任务提交带唯一幂等键；超时重试复用原幂等键。
- [ ] 图生视频已完成 `lease → PUT → confirm`，且素材状态为 `ready`。
- [ ] PUT 使用原始二进制，未使用 multipart，未修改租约头。
- [ ] 能处理 `queued`、`leased`、`review_required`、`completed`、`failed`、`cancelled`。
- [ ] 只在 `completed` 后下载结果，并能处理下载 URL 过期。
- [ ] Webhook 验签使用 raw body，并按 delivery ID 去重；轮询仍可用。
- [ ] 已配置调用超时、指数退避、错误码监控和业务总超时。
- [ ] 已用望月提供的联调凭证，或用正式凭证完成一次低成本小任务验收；确认成功后再开放业务并发。

## 11. 联调故障排查信息

联系望月时请提供：API 根地址、发生时间（含时区）、脱敏后的 HTTP 方法和路径、HTTP 状态、`error`、`message`、`details`、taskId/assetId、是否重试、是否复用幂等键，以及 COS PUT 的状态码和 RequestId（如有）。不要提供 Secret、完整签名 URL、Cookie、原始素材或完整 Authorization 日志。

当前文档、API 根域 `/API.md`、`/developers/API.md` 和开发者站文档内容保持一致。SCF Timer 触发器负责 Webhook 投递、过期租约回收和节点状态维护；如果任务长期停留在 `leased` 或 `review_required`，继续轮询并联系望月检查节点和定时器。
