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ý.
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:
| 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#
// 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#
# 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.
// 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 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 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ố |
incident.severity_changed, incident.escalated | Mức của sự cố tăng, hoặc chưa ai nhận kịp (leo thang) |
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:
{ "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_idchính là lần giao đứng sauwebhook-id;data.monitor.incident_idnối tin mở sự cố với tinmonitor.recoveredcủa nó, vàdata.uptime.incident_id/data.slo.incident_idlà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.unreachablecó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.digestcó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-signaturemang 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- MCP cho observability: agent có quyền truy cập, chưa chắc có chẩn đoánAI agent làm gì với tool MCP của error tracker: 60 lượt, ba model. Tìm đúng issue thì dễ, đúng nguyên nhân thì ít hơn, có quyền ghi là tự đóng bug.
- Belvador không có MCP server: ba cách đưa lỗi cho agentBelvador không có MCP server. Ba cách làm việc với coding agent: dán bằng chứng của issue, tài liệu Markdown và llms.txt, cảnh báo qua Standard Webhooks.
Thấy chỗ sai hoặc thiếu? Gửi cho chúng tôi: hello@belvador.dev