# Webhooks

> 接收 analysis.completed、analysis.failed 和 balance.low 事件。包括 JavaScript 和 Python 中的 Standard Webhooks 签名校验、重试、自动停用和测试事件。

来源： https://fileverity.com/zh/docs/webhooks

有事件发生时，Webhook 会向您的服务器发送 HTTPS 请求，因此无需轮询。它们遵循 [Standard Webhooks](https://www.standardwebhooks.com) 规范。

在控制台的 **Webhooks** 中添加端点，选择其 URL 和要接收的事件。签名密钥以 `whsec_` 开头，仅在创建端点时显示一次。您可以随时修改端点接收的事件，而不会更改其密钥。

如需替换丢失或已泄露的密钥，请在该端点上选择**轮换密钥**。新密钥只显示一次，旧密钥会立即失效，因此请立即更新您的校验代码。

- URL 必须使用 HTTPS 和默认端口 443，并指向公网地址。包含凭据或使用其他端口的 URL 会被拒绝。
- 每个工作区最多可添加 10 个端点。
- 测试密钥永远不会发送事件。

## 事件

| 事件 | 发送时机 |
| --- | --- |
| `analysis.completed` | 图片或文档分析成功。 |
| `analysis.failed` | 分析失败或被取消，未扣费。已取消的分析带有 `status: canceled` 和 `error: canceled`。 |
| `balance.low` | 余额低于您在控制台中设置的提醒阈值。 |

支持的事件类型有 `analysis.completed`、`analysis.failed`、`balance.low`。

## 请求内容

所有事件的外层结构相同：`id`、`object`（固定为 `event`）、`type`、`created`（Unix 时间戳）和 `data`。`id` 同时也通过 `webhook-id` 请求头发送。

### analysis.completed 和 analysis.failed

`data` 与 `GET /analyses/{id}` 返回的分析对象相同，字段名称和类型也相同，唯一的区别是 `result` 始终为 `null`：请通过该接口获取检测结果。`status` 为 `succeeded`、`failed` 或 `canceled`；`error` 是包含 `code` 和 `message` 的对象，或为 `null`。时间戳为 Unix 秒数。

**analysis.completed**

```json
{
  "id": "evt_Lm8sK3tR6yZ2cXaQ9vB4nP1w",
  "object": "event",
  "type": "analysis.completed",
  "created": 1793700131,
  "data": {
    "id": "an_8Hk2pQ4vN9sL1xR7cW3mY6zB",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "succeeded",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": "FV-7K2M-9QXD-4T8R",
    "report_url": "https://fileverity.com/verify/FV-7K2M-9QXD-4T8R",
    "summary": {
      "level": "L2",
      "assessment": "authentic_untouched",
      "action": "accept",
      "ai": "very_low",
      "edit": "none"
    },
    "result": null,
    "error": null,
    "progress": null,
    "usage": {
      "amount": 199,
      "currency": "usd",
      "funding": "balance"
    },
    "metadata": {
      "order_id": "A-10492"
    },
    "completed_at": 1793700131,
    "expires_at": 1794304931
  }
}
```

**analysis.failed**

```json
{
  "id": "evt_Zc4vB7nP1sK8tL3wR6yQm2Xa",
  "object": "event",
  "type": "analysis.failed",
  "created": 1793700131,
  "data": {
    "id": "an_8Hk2pQ4vN9sL1xR7cW3mY6zB",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "failed",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": null,
    "report_url": null,
    "summary": null,
    "result": null,
    "error": {
      "code": "analysis_failed",
      "message": "The analysis could not be completed. You were not charged; retry it, and contact support with the request ID if it fails again."
    },
    "progress": null,
    "usage": {
      "amount": 0,
      "currency": "usd",
      "funding": null
    },
    "metadata": null,
    "completed_at": 1793700135,
    "expires_at": null
  }
}
```

### balance.low

`balance` 和 `threshold` 以美分为单位。

**balance.low**

```json
{
  "id": "evt_Lm8sK3tR6yZ2cXaQ9vB4nP1w",
  "object": "event",
  "type": "balance.low",
  "created": 1793700500,
  "data": {
    "balance": 640,
    "threshold": 1000
  }
}
```

## 校验签名

每个请求都带有 Standard Webhooks 规范定义的三个请求头：

| 请求头 | 值 |
| --- | --- |
| `webhook-id` | 事件 ID。重试时保持不变；可用它忽略重复投递。 |
| `webhook-timestamp` | 本次投递尝试的 Unix 时间（秒）。每次重试都会变化。 |
| `webhook-signature` | `v1,` 后接 Base64 编码的 HMAC-SHA256。一个请求头可以包含多个以空格分隔的签名。 |

校验请求的步骤：

1. 在进行任何 JSON 解析之前，先将原始请求体读取为文本。
2. 构造待签名内容：`webhook-id`、一个英文句点、`webhook-timestamp`、一个英文句点，再加上原始请求体。
3. 取出您的签名密钥，去掉 `whsec_` 前缀，对其余部分进行 Base64 解码，结果即为 HMAC 密钥。
4. 对待签名内容计算 HMAC-SHA256，并进行 Base64 编码。
5. 使用恒定时间比较，将其与请求头中的每个 `v1` 签名进行比对。
6. 拒绝与您的系统时钟相差超过五分钟的时间戳。

**校验 Webhook: JavaScript**

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) throw new Error("Missing webhook headers");
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("Timestamp outside tolerance");
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(id + "." + timestamp + "." + body).digest();
  const valid = signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("Invalid signature");
  return JSON.parse(body);
}

app.post("/webhooks/fileverity", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verify(process.env.FILEVERITY_WEBHOOK_SECRET, req.headers, req.body.toString("utf8"));
    handle(event);
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});
```

**校验 Webhook: Python**

```python
import base64
import binascii
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

def verify(secret, headers, body):
    message_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp outside tolerance")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = (message_id + "." + timestamp + ".").encode() + body
    expected = hmac.new(key, signed, hashlib.sha256).digest()
    for entry in headers["webhook-signature"].split(" "):
        version, _, value = entry.partition(",")
        try:
            given = base64.b64decode(value, validate=True)
        except binascii.Error:
            continue
        if version == "v1" and hmac.compare_digest(given, expected):
            return json.loads(body)
    raise ValueError("Invalid signature")

@app.post("/webhooks/fileverity")
async def receive(request: Request):
    body = await request.body()
    try:
        event = verify(os.environ["FILEVERITY_WEBHOOK_SECRET"], request.headers, body)
    except (KeyError, ValueError):
        raise HTTPException(status_code=400)
    handle(event)
    return {"ok": True}
```

> **注意。** 请针对实际发送的原始字节进行校验。先解析再重新序列化 JSON 会改变这些字节，导致签名校验失败。

## 投递与重试

- 请在 10 秒内返回任意 `2xx` 状态码。不会跟随重定向，重定向视为失败。
- 其他任何情况，包括超时或连接错误，都会安排重试，间隔逐步增大：5、10、20、40、80、160、320 和 640 分钟，之后每 12 小时一次。事件发生 3 天后停止重试。重试按计划调度，因此实际重试可能晚于既定间隔，但绝不会提前。
- 投递至少发生一次，且不保证顺序。请使用 `webhook-id` 忽略重复投递；如需最新状态，请获取该分析。
- 控制台会列出最近 50 次投递及其状态和错误，保留 30 天，并支持重新发送某一次投递。打开某次投递，可以查看其请求内容、响应时间以及您的端点所返回响应的前 2 KB，因此请勿在该响应中返回密钥或个人数据。重新发送时沿用原来的事件 ID。
- 请求的 User-Agent 为 `Fileverity-Webhooks/1.0`。

### 自动停用

连续失败 3 天的端点会被停用。停用后不再接收新事件，其未送达的事件会标记为失败。在此之前只要有一次投递成功，失败状态即被清除。端点被停用时，工作区所有者和管理员会收到邮件通知。修复问题后，请在控制台中重新启用该端点。

## 测试事件

在控制台中，对端点使用**发送测试**，即可立即投递一个示例事件。它是经过签名的 `analysis.completed` 事件，`data.test` 为 `true`，且不含报告，因此您可以在真实事件到达之前检查您的 URL 和签名校验代码。

**测试事件**

```json
{
  "id": "evt_Qm4vB7nP1sK8tL3wR6yZ2cXa",
  "object": "event",
  "type": "analysis.completed",
  "created": 1793700500,
  "data": {
    "id": "an_Pw3mY6zB8Hk2pQ4vN9sL1xR7",
    "object": "analysis",
    "created": 1793700120,
    "model": "fvimg-2-2026-10-30",
    "livemode": true,
    "status": "succeeded",
    "file": {
      "name": "claim-photo.jpg",
      "bytes": 2481920,
      "format": "jpeg"
    },
    "report_id": null,
    "report_url": null,
    "summary": null,
    "result": null,
    "error": null,
    "progress": null,
    "usage": {
      "amount": 199,
      "currency": "usd",
      "funding": "balance"
    },
    "metadata": {
      "order_id": "A-10492"
    },
    "completed_at": 1793700131,
    "expires_at": 1794304931,
    "test": true
  }
}
```

相关内容：[分析](https://fileverity.com/zh/docs/analyses.md)、[幂等性](https://fileverity.com/zh/docs/idempotency.md)和[错误](https://fileverity.com/zh/docs/errors.md)。

