# Webhook

> Nhận các sự kiện analysis.completed, analysis.failed và balance.low. Xác minh chữ ký Standard Webhooks bằng JavaScript và Python, cơ chế thử lại, tự động tắt endpoint và sự kiện thử.

Nguồn: https://fileverity.com/vi/docs/webhooks

Webhook gửi một yêu cầu HTTPS đến máy chủ của bạn khi có sự kiện xảy ra, nên bạn không phải truy vấn định kỳ (polling). Webhook tuân theo đặc tả [Standard Webhooks](https://www.standardwebhooks.com).

Thêm endpoint trong Console, tại mục **Webhook**. Chọn URL và các sự kiện mà endpoint nhận. Khóa bí mật dùng để ký (signing secret), bắt đầu bằng `whsec_`, chỉ hiển thị một lần khi bạn tạo endpoint. Bạn có thể đổi các sự kiện của một endpoint bất cứ lúc nào mà không làm thay đổi khóa bí mật của nó.

Để thay một khóa bí mật bị mất hoặc bị lộ, hãy chọn **Đổi khóa bí mật** trên endpoint. Khóa bí mật mới chỉ hiển thị một lần và khóa cũ ngừng hoạt động ngay lập tức, vì vậy hãy cập nhật phần mã xác minh chữ ký của bạn ngay.

- URL phải dùng HTTPS trên cổng mặc định 443 và trỏ đến một địa chỉ công khai. URL có kèm thông tin xác thực hoặc dùng cổng khác sẽ bị từ chối.
- Một không gian làm việc có thể có tối đa 10 endpoint.
- Khóa thử nghiệm không bao giờ gửi sự kiện.

## Sự kiện

| Sự kiện | Được gửi khi |
| --- | --- |
| `analysis.completed` | Một lượt phân tích ảnh hoặc tài liệu đã thành công. |
| `analysis.failed` | Một lượt phân tích thất bại hoặc bị hủy. Lượt này không bị tính phí. Lượt phân tích bị hủy có `status: canceled` và `error: canceled`. |
| `balance.low` | Số dư đã xuống dưới ngưỡng cảnh báo bạn đặt trong Console. |

Các loại sự kiện được hỗ trợ là `analysis.completed`, `analysis.failed`, `balance.low`.

## Payload

Mọi sự kiện đều có chung một lớp bao ngoài (envelope): `id`, `object` (luôn là `event`), `type`, `created` (dấu thời gian Unix) và `data`. `id` cũng được gửi trong header `webhook-id`.

### analysis.completed và analysis.failed

`data` là chính đối tượng lượt phân tích mà `GET /analyses/{id}` trả về, với cùng tên trường và kiểu dữ liệu, ngoại trừ `result` luôn là `null`: hãy lấy kết quả từ endpoint đó. `status` là `succeeded`, `failed` hoặc `canceled`, còn `error` là một đối tượng có `code` và `message`, hoặc `null`. Dấu thời gian tính bằng giây 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` và `threshold` tính bằng cent Mỹ.

**balance.low**

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

## Xác minh chữ ký

Mỗi yêu cầu có ba header, theo định nghĩa của đặc tả Standard Webhooks:

| Header | Giá trị |
| --- | --- |
| `webhook-id` | Mã sự kiện. Giữ nguyên qua các lần thử lại; dùng để bỏ qua các yêu cầu trùng lặp. |
| `webhook-timestamp` | Thời gian Unix, tính bằng giây, của lần gửi này. Giá trị thay đổi ở mỗi lần thử lại. |
| `webhook-signature` | `v1,` theo sau là một chuỗi HMAC-SHA256 mã hóa base64. Một header có thể chứa nhiều chữ ký, cách nhau bằng dấu cách. |

Để xác minh một yêu cầu:

1. Đọc nội dung yêu cầu thô (raw body) dưới dạng văn bản, trước khi phân tích cú pháp JSON.
2. Ghép nội dung cần ký: `webhook-id`, một dấu chấm, `webhook-timestamp`, một dấu chấm và nội dung yêu cầu thô.
3. Lấy khóa bí mật dùng để ký của bạn, bỏ tiền tố `whsec_` và giải mã base64 phần còn lại. Đó là khóa HMAC.
4. Tính HMAC-SHA256 của nội dung cần ký rồi mã hóa kết quả bằng base64.
5. So sánh kết quả đó, trong thời gian không đổi (constant time), với từng chữ ký `v1` trong header.
6. Từ chối các dấu thời gian lệch hơn năm phút so với đồng hồ của bạn.

**Xác minh 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);
  }
});
```

**Xác minh 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}
```

> **Lưu ý.** Hãy xác minh trên đúng các byte đã được gửi. Việc phân tích rồi tuần tự hóa lại JSON sẽ làm thay đổi các byte này và khiến chữ ký không còn khớp.

## Gửi sự kiện và thử lại

- Phản hồi bằng bất kỳ mã trạng thái `2xx` nào trong vòng 10 giây. Chuyển hướng (redirect) không được đi theo và bị tính là thất bại.
- Mọi phản hồi khác, kể cả hết thời gian chờ hoặc lỗi kết nối, đều lên lịch một lần thử lại với khoảng chờ tăng dần: 5, 10, 20, 40, 80, 160, 320 và 640 phút, sau đó cứ 12 giờ một lần. Việc thử lại dừng sau 3 ngày kể từ khi sự kiện xảy ra. Các lần thử lại chạy theo lịch, nên một lần thử lại có thể đến muộn hơn khoảng chờ của nó, nhưng không bao giờ sớm hơn.
- Mỗi sự kiện được gửi ít nhất một lần và không đảm bảo thứ tự. Dùng `webhook-id` để bỏ qua các yêu cầu trùng lặp, và lấy lượt phân tích nếu bạn cần trạng thái mới nhất.
- Console liệt kê 50 lần gửi gần nhất cùng trạng thái và lỗi, lưu chúng trong 30 ngày và cho phép bạn gửi lại từng lần. Mở một lần gửi để xem nội dung yêu cầu (payload), thời gian phản hồi và 2 KB đầu tiên trong phản hồi của endpoint, vì vậy đừng trả về thông tin bí mật hoặc dữ liệu cá nhân trong phản hồi đó. Lần gửi lại giữ nguyên mã sự kiện ban đầu.
- Các yêu cầu có user agent `Fileverity-Webhooks/1.0`.

### Tự động tắt

Endpoint liên tục thất bại trong 3 ngày sẽ bị tắt. Khi đó endpoint không nhận thêm sự kiện mới, và các sự kiện chưa gửi được sẽ bị đánh dấu là thất bại. Một lần gửi thành công trước thời điểm đó sẽ xóa trạng thái thất bại. Chủ sở hữu và quản trị viên không gian làm việc được gửi email khi một endpoint bị tắt. Hãy bật lại endpoint trong Console sau khi bạn khắc phục sự cố.

## Sự kiện thử

Dùng **Gửi thử** trên một endpoint trong Console để gửi ngay một sự kiện mẫu. Đây là sự kiện `analysis.completed` đã được ký, với `data.test` đặt là `true` và không có báo cáo, nhờ đó bạn có thể kiểm tra URL và mã xác minh chữ ký trước khi có sự kiện thật.

**Sự kiện thử**

```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
  }
}
```

Xem thêm: [Lượt phân tích](https://fileverity.com/vi/docs/analyses.md), [Idempotency](https://fileverity.com/vi/docs/idempotency.md) và [Lỗi](https://fileverity.com/vi/docs/errors.md).

