> ## Documentation Index
> Fetch the complete documentation index at: https://docs.apistable.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook 安全与验签

> 从控制台获取签名密钥并验证回调请求。

## 从控制台获取密钥

登录控制台 → 账户设置 → Webhook 安全，点击“显示密钥”，再“复制密钥”。仅保存在服务端密钥配置中，例如 APISTABLE\_WEBHOOK\_SECRET。它是账户级签名密钥，独立于 API Key；更换 API Key 不会改变它。密钥默认隐藏，普通设置响应不包含明文密钥。

[打开账户设置](https://apistable.ai/console/settings)

## 签名格式

读取 X-APIstable-Timestamp（Unix 秒）、X-APIstable-Signature 和 X-APIstable-Event-Id。将复制的密钥原文作为 UTF-8 字符串，对 timestamp + "." 加未经修改的请求正文原始字节计算 HMAC-SHA256，摘要编码为小写十六进制。签名头为 v1=\<64 位十六进制摘要>。不要将密钥做十六进制解码，也不要对解析后重新序列化的 JSON 验签。此协议与 Kie 不同：签名覆盖完整正文，而非仅任务 ID。

* 拒绝缺失或格式错误的签名，以及与当前时间相差超过 300 秒的过去或未来时间戳，保持服务端时钟同步。
* 使用恒定时间比较。密钥重置过渡期内，签名头包含逗号分隔的多个 v1 值；任一值与已配置密钥匹配即可接受。

## Node.js 验签

```javascript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyWebhook(rawBody, timestamp, signatureHeader, secret, now = Date.now()) {
  if (!secret || !Buffer.isBuffer(rawBody)) return false;
  if (typeof timestamp !== 'string' || !/^\d{1,12}$/.test(timestamp)) return false;
  if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > 300) return false;
  if (typeof signatureHeader !== 'string') return false;
  const expected = createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(rawBody)
    .digest();
  return signatureHeader.split(',').some((entry) => {
    const match = /^v1=([a-f0-9]{64})$/.exec(entry.trim());
    return match !== null && timingSafeEqual(expected, Buffer.from(match[1], 'hex'));
  });
}
```

> Express 回调路由应先使用 express.raw(\{type:"application/json"})，且必须位于 express.json() 中间件之前。将 Buffer 类型的 req.body 和 req.get(...) 获取的头传入函数，通过 verifyWebhook 后再解析 JSON。

## Python 验签

```python theme={null}
import hashlib
import hmac
import re
import time

def verify_webhook(raw_body, timestamp, signature_header, secret, now=None):
    if not secret or not isinstance(raw_body, bytes):
        return False
    if not isinstance(timestamp, str) or not re.fullmatch(r"[0-9]{1,12}", timestamp):
        return False
    current = time.time() if now is None else now
    if abs(int(current) - int(timestamp)) > 300:
        return False
    if not isinstance(signature_header, str):
        return False
    expected = hmac.new(secret.encode("utf-8"),
                        timestamp.encode("ascii") + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return any(re.fullmatch(r"v1=[a-f0-9]{64}", entry.strip())
               and hmac.compare_digest(expected, entry.strip()[3:])
               for entry in signature_header.split(","))
```

> Flask 使用 request.get\_data() 的原始字节和 request.headers 头值，在调用 request.get\_json() 前验签。两个示例在签名无效时返回 false，应以 HTTP 401 拒绝。

## 去重与处理

验签后解析 JSON，并校验 X-APIstable-Event-Id 等于已签名正文的 data.task\_id。当前事件 ID 即任务 ID，重试时保持不变；每次投递的时间戳和签名可以变化。通过唯一约束持久记录事件 ID，并在同一事务中入队处理；持久受理后返回 2xx，已受理的重复事件也返回 2xx。五分钟时间窗口本身不能防止窗口内的重放。

按所选模型的回调格式区分任务成功和失败。歌曲成功直接发送模型结果，其他终态回调使用通用任务外层结构。漏收时按任务 ID 查询；投递重试不会重新生成任务或重复扣费。

## 重置与更新密钥

在控制台点击“重置密钥”并确认后立即生效，将新密钥更新到所有接收端。之后 24 小时回调同时携带新旧密钥签名，显示的 UTC 到期时间之后仅使用新密钥。过渡期间不能再次重置，验签端须支持检查全部 v1 值。旧密钥在过渡期内仍可使用，若已泄露请联系支持处理；不要通过更换平台主密钥来重置单个账户。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.