# Nhận cảnh báo bằng webhook của bạn

> Belvador gửi JSON có chữ ký chuẩn Standard Webhooks tới địa chỉ HTTPS của bạn: cách kiểm chữ ký, loại sự kiện, thử lại, gửi lại và xoay mã ký.

**Webhook tự cấu hình** gửi mọi cảnh báo dưới dạng JSON có chữ ký tới một địa chỉ HTTPS do bạn vận hành — công cụ xử lý sự cố, bot nội bộ, hàng đợi, serverless function. Chữ ký theo chuẩn [Standard Webhooks](https://www.standardwebhooks.com/), nên các thư viện `standardwebhooks` chính thức kiểm được ngay, không cần sửa gì.

## Thêm webhook

Ở **Cảnh báo → Thêm đích nhận** chọn **Webhook**, đặt tên và dán **URL nhận**. Owner và admin làm được việc này; Belvador hỏi mã xác thực của bạn.

URL phải:

- dùng `https://` trên tên miền công khai — không dùng địa chỉ IP, `localhost`, tên nội bộ hay địa chỉ của chính Belvador, không có `user:mật-khẩu@`;
- dùng cổng 443 hoặc cổng lớn hơn 1023, dài tối đa 1024 ký tự;
- trả lời trong 10 giây và không chuyển hướng.

Trước khi lưu, Belvador gửi một sự kiện `alert.test` có chữ ký. Chỉ khi địa chỉ của bạn trả `2xx` thì mới lưu. Sau đó Belvador hiện **mã ký một lần duy nhất** (`whsec_…`) — hãy cất cùng các secret của dịch vụ nhận. Belvador lưu mã ở dạng mã hoá và không hiện lại.

Rồi gắn đích nhận này vào một quy tắc cảnh báo như các đích khác.

## Kiểm chữ ký mọi request

Mỗi request là `POST` với `content-type: application/json` và ba header:

| Header | Giá trị |
|---|---|
| `webhook-id` | Riêng cho từng lần giao và **giữ nguyên qua mọi lần thử lại** — dùng để bỏ qua bản trùng |
| `webhook-timestamp` | Giây Unix lúc lần thử này được ký |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` của `webhook-id.webhook-timestamp.body`; khi đang xoay mã sẽ có nhiều giá trị cách nhau bằng dấu cách |

Từ chối request có chữ ký không khớp hoặc có timestamp lệch quá 5 phút so với đồng hồ của bạn. Luôn kiểm trên **chuỗi body gốc** — parse rồi stringify lại JSON sẽ làm đổi byte.

### Node.js / TypeScript

```ts title="belvador-webhook.ts"
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.BELVADOR_WEBHOOK_SECRET!); // whsec_…

export async function POST(request: Request) {
  const body = await request.text(); // the raw text — not re-serialized JSON
  let event: { type: string; data: Record<string, unknown> };
  try {
    event = wh.verify(body, Object.fromEntries(request.headers)) as typeof event;
  } catch {
    return new Response("invalid signature", { status: 401 });
  }
  // webhook-id stays the same on every retry of one delivery: skip ids you already handled.
  if (await alreadyHandled(request.headers.get("webhook-id")!)) return new Response(null, { status: 204 });
  await handle(event); // e.g. page on "monitor.missed", resolve on "monitor.recovered"
  return new Response(null, { status: 204 });
}
```

### Python

```python title="belvador_webhook.py"
# pip install standardwebhooks
import os
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["BELVADOR_WEBHOOK_SECRET"])  # whsec_…

def receive(raw_body: str, headers: dict) -> tuple[int, str]:
    try:
        event = wh.verify(raw_body, headers)  # raw text, not re-serialized JSON
    except Exception:
        return 401, "invalid signature"
    if already_handled(headers["webhook-id"]):  # same id on every retry of one delivery
        return 204, ""
    handle(event)
    return 204, ""
```

### Không dùng thư viện (Web Crypto)

Chạy được trên Cloudflare Workers, Deno, Bun và Node.js 20 trở lên.

```js title="verify-belvador-webhook.js"
// Returns the parsed event, or throws. `body` must be the raw request text.
export async function verifyBelvadorWebhook(secret, headers, body, toleranceSeconds = 300) {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signatures = headers.get("webhook-signature");
  if (!id || !timestamp || !signatures) throw new Error("missing webhook headers");
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > toleranceSeconds) throw new Error("stale webhook");

  const keyBytes = Uint8Array.from(atob(secret.replace(/^whsec_/, "")), (c) => c.charCodeAt(0));
  const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${id}.${timestamp}.${body}`));
  const expected = btoa(String.fromCharCode(...new Uint8Array(mac)));

  // While a secret is rotated, the header holds several "v1,<sig>" values: any match is enough.
  const ok = signatures.split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig || sig.length !== expected.length) return false;
    let diff = 0;
    for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
    return diff === 0;
  });
  if (!ok) throw new Error("bad signature");
  return JSON.parse(body);
}
```

## Các sự kiện

| `type` | Gửi khi |
|---|---|
| `alert.test` | Bạn chọn **Gửi thử**, hoặc Belvador kiểm địa chỉ trước khi lưu |
| `issue.new`, `issue.regressed`, `issue.escalating` | Một quy tắc lỗi được kích hoạt |
| `monitor.missed`, `monitor.failed`, `monitor.timeout`, `monitor.unknown` | Một monitor mở sự cố — với ping URL, `monitor.missed` nghĩa là **không nhận được tín hiệu** |
| `monitor.recovered` | Sự cố đó đóng lại |
| `integration.problem`, `integration.recovered` | Sự cố kết nối SDK mở hoặc đóng |
| `uptime.down`, `uptime.recovered` | Một [check uptime](https://belvador.dev/vi/docs/uptime/quickstart) mở hoặc đóng sự cố |
| `uptime.cert_expiring` | Chứng chỉ HTTPS hết hạn trong khoảng cảnh báo của check (tối đa một lần mỗi ngày) |
| `slo.burn`, `slo.recovered` | Một [SLO](https://belvador.dev/vi/docs/slos/alerts) bắt đầu đốt ngân sách lỗi, hoặc đã trở lại trong ngân sách |
| `node.unreachable` | Node của bạn ngừng trả lời |
| `incident.opened`, `incident.reopened` | Các cảnh báo liên quan đã mở (hoặc mở lại) một [sự cố](https://belvador.dev/vi/docs/incidents) |
| `incident.severity_changed`, `incident.escalated` | Mức của sự cố tăng, hoặc chưa ai nhận kịp ([leo thang](https://belvador.dev/vi/docs/alerts/policies)) |
| `incident.acknowledged`, `incident.resolved` | Có người nhận xử lý; sự cố đã đóng (đã xác minh, hoặc đóng tay kèm lý do) |
| `alert.digest` | Các tin đang giữ được gửi thành một bản tóm tắt |

Body cố định và có phiên bản. Một heartbeat bị lỡ trông như sau:

```json
{
  "type": "monitor.missed",
  "timestamp": "2026-10-23T09:58:00.000Z",
  "data": {
    "event_id": "0192f3a4-5b6c-7d8e-9f01-23456789abcd",
    "workspace": {
      "id": "01a13b11-6f28-76f2-9f51-35f898e0e2e5",
      "slug": "acme",
      "name": "Acme"
    },
    "project": {
      "name": "Billing",
      "slug": "billing"
    },
    "environment": "production",
    "occurred_at": "2026-10-23T09:58:00.000Z",
    "detected_late": false,
    "url": "https://belvador.dev/en/w/acme/monitors/6b0f6a1e-0b8a-4f38-9d43-2a3c4b5d6e7f?env=production",
    "monitor": {
      "id": "6b0f6a1e-0b8a-4f38-9d43-2a3c4b5d6e7f",
      "slug": "ops-tick",
      "name": "ops tick",
      "mode": "heartbeat",
      "incident_id": "7c1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b",
      "code": "MONITOR_MISSED",
      "reason": null,
      "opened_at": "2026-10-23T09:58:00.000Z",
      "resolved_at": null,
      "last_healthy_at": "2026-10-23T09:52:00.000Z",
      "duration_ms": null
    }
  }
}
```

- `data.event_id` chính là lần giao đứng sau `webhook-id`; `data.monitor.incident_id` nối tin mở sự cố với tin `monitor.recovered` của nó, và `data.uptime.incident_id` / `data.slo.incident_id` làm điều tương tự cho sự kiện uptime và SLO.
- Sự kiện issue có `data.issue` (short id, tiêu đề, mức, culprit, release); sự kiện kết nối có `data.integration` (mã, số lần, thời điểm); sự kiện uptime có `data.uptime` (id và tên check, đích không kèm query, mã, lý do, các vùng lỗi, thời điểm, số ngày chứng chỉ còn lại); sự kiện SLO có `data.slo` (tên, mục tiêu, cửa sổ, mức độ, tốc độ đốt, ngân sách còn, thời điểm); `node.unreachable` có `data.node.name`; sự kiện sự cố có `data.incident` (id, số, tiêu đề, mức, trạng thái, số cảnh báo liên kết, cách đóng); `alert.digest` có `data.digest` (số tin, các dòng, còn bao nhiêu).
- Tên và tiêu đề đến từ chính các app của bạn — hãy coi là văn bản không tin cậy khi hiển thị.
- Body **không bao giờ** chứa ping URL, khoá DSN, body request hay stack trace. Mở `data.url` để xem chi tiết.

## Trả lời, thử lại và gửi lại

| Bạn trả lời | Belvador làm gì |
|---|---|
| `2xx` | Đã giao |
| `408`, `429`, `5xx`, quá thời gian, lỗi kết nối | Thử lại sau khoảng 1, 5 và 15 phút (lệch ngẫu nhiên một chút) — tổng cộng 4 lần. Với `429` thì chờ theo `Retry-After` của bạn |
| `3xx` | Không đi theo. Tính là lỗi cấu hình: không thử lại nữa |
| `4xx` khác | Lỗi cấu hình: không thử lại nữa |

Belvador đọc tối đa 4 KB phản hồi và chỉ lưu một đoạn ngắn đã lọc. Hãy trả lời nhanh và xử lý việc chậm ở nền.

Sau ba lần lỗi liên tiếp, đích nhận hiện **Đang lỗi**. Mở **Lần gửi** trên đích nhận để xem từng lần thử — mã HTTP, thời gian và đoạn phản hồi. Một lần giao đã bỏ cuộc hiện **Lỗi** trong **Lịch sử gửi**; owner và admin có thể chọn **Gửi lại** trong vòng 7 ngày — mỗi lần bấm là thêm một lần thử, có ghi vào nhật ký kiểm toán.

## Đổi URL hoặc xoay mã ký

Chọn biểu tượng chìa khoá trên đích nhận:

- **URL nhận mới** giữ nguyên mã ký. Belvador gửi một sự kiện thử có chữ ký tới URL mới trước khi lưu.
- **Xoay mã ký** tạo mã mới và hiện một lần. Mã cũ vẫn ký **song song** với mã mới thêm 24 giờ, nên `webhook-signature` mang hai giá trị và dịch vụ của bạn chấp nhận cả hai trong lúc đổi. Đánh dấu **Dừng mã hiện tại ngay** nếu mã bị lộ.

Cả hai đều hỏi mã xác thực của bạn.

## Bảo mật

- Trước **mỗi** lần thử, Belvador phân giải tên máy của bạn và từ chối gửi nếu có bất kỳ địa chỉ nào là nội bộ, loopback, link-local hay thuộc dải dành riêng — cả IPv4 lẫn IPv6, kể cả IPv6 ánh xạ IPv4. Nếu DNS về sau đổi sang địa chỉ nội bộ, lần thử kế tiếp sẽ bị chặn.
- Không bao giờ đi theo chuyển hướng, không chuyển tiếp header tuỳ ý.
- Lỗi lưu trong Belvador đã được xoá URL và các chuỗi trông giống bí mật.
