Receive alerts on your own webhook
Belvador sends signed JSON in the Standard Webhooks format to your HTTPS endpoint: verifying signatures, event types, retries, replays and secret rotation.
On this page
A custom webhook sends every alert as signed JSON to an HTTPS endpoint you run — your incident tool, an internal bot, a queue, a serverless function. Signatures follow Standard Webhooks, so the official standardwebhooks libraries verify them unchanged.
Add one#
Under Alerts → Add destination choose Webhook, give it a name and paste your Endpoint URL. Owners and admins can do this; Belvador asks for your authenticator code.
The URL must:
use
https://on a public domain name — no IP addresses,localhost, local names or Belvador's own addresses, and nouser:password@;use port 443 or a port above 1023, and be at most 1024 characters;
answer within 10 seconds without redirecting.
Before saving, Belvador sends one signed alert.test event. Nothing is stored unless your endpoint answers 2xx. Then Belvador shows the signing secret once (whsec_…) — store it with your endpoint's secrets. Belvador keeps it encrypted and never shows it again.
Then add the destination to an alert rule like any other destination.
Verify every request#
Each request is a POST with content-type: application/json and three headers:
| Header | Value |
|---|---|
webhook-id | Unique per delivery and the same on every retry of it — use it to skip duplicates |
webhook-timestamp | Unix seconds when this attempt was signed |
webhook-signature | v1,<base64 HMAC-SHA256> of webhook-id.webhook-timestamp.body; several values separated by spaces while a secret is rotated |
Reject a request whose signature does not match or whose timestamp is more than 5 minutes away from your clock. Always verify the raw body text — parsing and re-serializing the JSON changes the bytes.
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, ""Without a library (Web Crypto)#
Works in Cloudflare Workers, Deno, Bun and Node.js 20 or newer.
// 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);}Events#
type | Sent when |
|---|---|
alert.test | You choose Send test, or Belvador checks the endpoint before saving |
issue.new, issue.regressed, issue.escalating | An error rule fires |
monitor.missed, monitor.failed, monitor.timeout, monitor.unknown | A monitor opens an incident — for ping URLs, monitor.missed means no signal received |
monitor.recovered | That incident closes |
integration.problem, integration.recovered | An SDK connection problem opens or closes |
uptime.down, uptime.recovered | An uptime check opens or closes an incident |
uptime.cert_expiring | An HTTPS certificate expires within the check's warning period (at most once a day) |
slo.burn, slo.recovered | An SLO starts burning its error budget, or is back within budget |
node.unreachable | Your node stopped answering |
incident.opened, incident.reopened | Related alerts opened (or reopened) an incident |
incident.severity_changed, incident.escalated | An incident's severity rose, or nobody took it in time (escalation) |
incident.acknowledged, incident.resolved | Someone took the incident; it closed (verified, or by hand with a reason) |
alert.digest | Held messages went out as one digest |
The body is fixed and versioned. A missed heartbeat looks like this:
{ "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_idequals the delivery behindwebhook-id;data.monitor.incident_idlinks an opening to itsmonitor.recovered, anddata.uptime.incident_id/data.slo.incident_iddo the same for uptime and SLO events.Issue events carry
data.issue(short id, title, level, culprit, release); connection events carrydata.integration(code, count, times); uptime events carrydata.uptime(check id and name, target without its query string, code, reason, failing regions, times, certificate days left); SLO events carrydata.slo(name, objective, window, severity, burn rates, budget left, times);node.unreachablecarriesdata.node.name; incident events carrydata.incident(id, number, title, severity, state, linked alert count, resolution);alert.digestcarriesdata.digest(count, lines, how many more).Names and titles come from your own apps — treat them as untrusted text when you display them.
The body never contains ping URLs, DSN keys, request bodies or stack traces. Open
data.urlfor the details.
Answer, retries and replays#
| Your answer | What Belvador does |
|---|---|
2xx | Delivered |
408, 429, 5xx, timeout, connection error | Retries after about 1, 5 and 15 minutes (with a little random spread) — 4 attempts in total. 429 waits for your Retry-After |
3xx | Not followed. Counts as a configuration error: no more retries |
Other 4xx | Configuration error: no more retries |
Belvador reads at most 4 KB of your reply and stores only a short, scrubbed excerpt. Answer fast and do slow work in the background.
After three failures in a row the destination shows Failing. Open Attempts on the destination to see each try — HTTP status, time taken and the reply excerpt. A delivery that gave up shows Failed in Delivery history; owners and admins can choose Send again on it within 7 days — each click is one more attempt, written to the audit log.
Change the URL or rotate the secret#
Choose the key icon on the destination:
New endpoint URL keeps the signing secret. Belvador sends one signed test to the new URL before saving.
Rotate signing secret creates a new secret and shows it once. The previous secret keeps signing alongside the new one for 24 hours, so
webhook-signaturecarries two values and your endpoint accepts either while you switch. Tick Stop the current secret immediately if it leaked.
Both ask for your authenticator code.
Security#
Before every attempt, Belvador resolves your host name and refuses to send if any address is private, loopback, link-local or otherwise reserved — IPv4 and IPv6, including IPv4-mapped IPv6. A DNS answer that changes to a private address later is caught on the next attempt.
Redirects are never followed, and no custom headers are forwarded.
Errors stored in Belvador have URLs and secret-looking strings removed.
Posts on this topic
All posts- Observability MCP gives an agent access, not a diagnosisWhat AI agents do with error-tracker MCP tools: 60 runs, three models. All found the issue, fewer the cause, and most resolved a live bug when they could.
- No MCP server: three ways to hand Belvador errors to your agentBelvador has no MCP server. Three ways to work with coding agents anyway: paste issue evidence, Markdown docs and llms.txt, and Standard Webhooks alerts.
Spotted something wrong or missing? Tell us: hello@belvador.dev