# Webhook回调

> 配置转录完成或失败的回调通知，验证请求签名，并处理重试和重复通知。

Canonical: https://transcript1.com/zh/docs/webhooks
Language: zh-Hans
Last updated: 2026-10-14

创建转录时填写`webhook_url`。任务完成或失败后，transcript1会向这个地址发送带签名的`POST`请求。

只有REST API支持Webhook，[MCP服务器](https://transcript1.com/zh/docs/mcp)不提供这个参数。

## 配置回调地址

提交视频链接或创建分块上传时，将`webhook_url`放进`POST /v1/transcriptions`的JSON请求体。如果是直接上传文件，则将它作为表单字段发送：

```json
{
  "platform": "youtube",
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "webhook_url": "https://example.com/hooks/transcript1"
}
```

回调地址必须使用HTTPS，不能带用户名或密码，且域名必须解析到公网地址。解析到私有地址、环回地址、链路本地地址或其他非公网地址时，接口会返回`422 invalid_request`。创建任务和每次发送回调时，系统都会检查地址。

设置了`webhook_url`，就不能同时使用`reuse_recent=true`。

## 哪些状态会触发回调

| `type` | 触发条件 |
|---|---|
| `transcription.completed` | 转录成功，任务状态变为`completed`。 |
| `transcription.failed` | 转录失败，任务状态变为`failed`。错误码和说明分别在`data.error`的`code`、`message`中。 |

取消任务不会触发Webhook。

## 回调请求的内容

回调会发送如下JSON请求体，示例省略了部分字段：

```json
{
  "type": "transcription.completed",
  "created_at": "2026-10-13T09:30:04.000Z",
  "data": {
    "id": "tr_8fK2Lm0PqRsTuVwXyZaBcD",
    "object": "transcription",
    "status": "completed",
    "transcript_source": "youtube_captions",
    "billed_minutes": 4,
    "error": null
  }
}
```

`created_at`记录事件创建时间。`data`包含转录对象，但不含`transcript`。需要正文时，取出`data`中的`id`，再调用`GET /v1/transcriptions/:id`获取转录文本。

请求头包括`Content-Type: application/json`、`User-Agent: transcript1-webhooks/1.0`，以及用于验签的请求头。

## 先验签，再处理通知

每次回调都会携带`X-Transcript1-Signature`，格式如下：

```text
X-Transcript1-Signature: t=<unix seconds>,v1=<hex digest>
```

- `t`是签名时的Unix时间戳，单位为秒。
- `v1`是HMAC-SHA256摘要，使用十六进制编码。待签名字符串为`"<t>.<raw body>"`，即`t`的值、英文句点和原始请求体，依次拼接。计算时使用完整签名密钥，不要去掉`whsec_`前缀。

必须用收到的原始请求体字节验签，通过后再解析JSON。先解析再序列化，可能改变空白字符或字段顺序，导致验签失败。比较摘要时使用恒定时间比较，同时检查`t`与当前时间的差值，拒绝时间相差过大的请求。以下示例允许5分钟偏差。

### Node.js

```javascript
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';

// header: X-Transcript1-Signature, rawBody: the request body exactly as received,
// secret: your full signing secret including the whsec_ prefix.
export const verifySignature = (secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) => {
  const fields = Object.fromEntries(header.split(',').map(part => part.trim().split('=')));
  const t = Number(fields.t);

  if (!Number.isInteger(t) || !fields.v1 || Math.abs(nowSeconds - t) > 300) {
    return false;
  }

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');

  return fields.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(fields.v1), Buffer.from(expected));
};
```

Express中先将请求体作为原始文本读取，不要提前解析JSON：

```javascript
import process from 'node:process';
import express from 'express';
import { verifySignature } from './verify-signature.js';

const app = express();

app.post('/hooks/transcript1', express.text({ type: 'application/json' }), (request, response) => {
  const header = request.get('X-Transcript1-Signature') ?? '';

  if (!verifySignature(process.env.TRANSCRIPT1_WEBHOOK_SECRET, header, request.body)) {
    response.sendStatus(400);
    return;
  }

  const { type, data } = JSON.parse(request.body);
  console.log(type, data.id, data.status);
  response.sendStatus(200);
});
```

### Python

```python
import hashlib
import hmac
import time


def verify_signature(secret, header, raw_body, now=None):
    # header: the X-Transcript1-Signature header, raw_body: the request body exactly as received,
    # secret: your full signing secret including the whsec_ prefix.
    fields = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    try:
        t = int(fields["t"])
    except (KeyError, ValueError):
        return False

    now = int(time.time()) if now is None else now
    if abs(now - t) > 300:
        return False

    expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(fields.get("v1", ""), expected)
```

Flask中用`request.get_data(as_text=True)`读取原文，再作为`raw_body`传入。

## 查看和轮换签名密钥

签名密钥以`whsec_`开头，团队内所有转录任务的Webhook共用这个密钥。进入控制台的[“Webhooks”](https://transcript1.com/dashboard/webhooks)页面（`/dashboard/webhooks`）可以查看或轮换密钥。轮换会立即生效，之后发送的回调都使用新密钥签名。

## 回调重试和去重

- 接口返回任意`2xx`状态码，即视为回调成功。
- 返回其他状态码、连接失败或10秒内未响应，都算发送失败。
- 发送失败后会重试，含首次发送在内最多6次。重试采用指数退避，间隔依次为2、4、8、16和32分钟；最后一次约在首次发送后1小时。
- transcript1不跟随重定向，`3xx`也算失败。请直接配置最终接收地址。
- 同一通知可能收到多次，例如服务器已经处理，但响应晚于超时时间。用`data.id`和`type`组合去重，跳过已处理的通知。
