Bỏ qua tới nội dung
Mục lục

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ý.

Bản Markdown
Trong bài này

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, 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:

HeaderGiá trị
webhook-idRiê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-timestampGiây Unix lúc lần thử này được ký
webhook-signaturev1,<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#

belvador-webhook.ts
// npm install standardwebhooksimport { 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#

belvador_webhook.py
# pip install standardwebhooksimport osfrom standardwebhooks.webhooks import Webhookwh = 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.

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#

typeGửi khi
alert.testBạn chọn Gửi thử, hoặc Belvador kiểm địa chỉ trước khi lưu
issue.new, issue.regressed, issue.escalatingMột quy tắc lỗi được kích hoạt
monitor.missed, monitor.failed, monitor.timeout, monitor.unknownMột monitor mở sự cố — với ping URL, monitor.missed nghĩa là không nhận được tín hiệu
monitor.recoveredSự cố đó đóng lại
integration.problem, integration.recoveredSự cố kết nối SDK mở hoặc đóng
uptime.down, uptime.recoveredMột check uptime mở hoặc đóng sự cố
uptime.cert_expiringChứ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.recoveredMột SLO bắt đầu đốt ngân sách lỗi, hoặc đã trở lại trong ngân sách
node.unreachableNode của bạn ngừng trả lời
incident.opened, incident.reopenedCác cảnh báo liên quan đã mở (hoặc mở lại) một sự cố
incident.severity_changed, incident.escalatedMức của sự cố tăng, hoặc chưa ai nhận kịp (leo thang)
incident.acknowledged, incident.resolvedCó người nhận xử lý; sự cố đã đóng (đã xác minh, hoặc đóng tay kèm lý do)
alert.digestCá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ờiBelvador làm gì
2xxĐã giao
408, 429, 5xx, quá thời gian, lỗi kết nốiThử 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
3xxKhông đi theo. Tính là lỗi cấu hình: không thử lại nữa
4xx khácLỗ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.

Bài viết về chủ đề này

Tất cả bài viết

Thấy chỗ sai hoặc thiếu? Gửi cho chúng tôi: hello@belvador.dev