Skip to content

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.

Markdown
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 no user: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:

HeaderValue
webhook-idUnique per delivery and the same on every retry of it — use it to skip duplicates
webhook-timestampUnix seconds when this attempt was signed
webhook-signaturev1,<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#

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

Without a library (Web Crypto)#

Works in Cloudflare Workers, Deno, Bun and Node.js 20 or newer.

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);}

Events#

typeSent when
alert.testYou choose Send test, or Belvador checks the endpoint before saving
issue.new, issue.regressed, issue.escalatingAn error rule fires
monitor.missed, monitor.failed, monitor.timeout, monitor.unknownA monitor opens an incident — for ping URLs, monitor.missed means no signal received
monitor.recoveredThat incident closes
integration.problem, integration.recoveredAn SDK connection problem opens or closes
uptime.down, uptime.recoveredAn uptime check opens or closes an incident
uptime.cert_expiringAn HTTPS certificate expires within the check's warning period (at most once a day)
slo.burn, slo.recoveredAn SLO starts burning its error budget, or is back within budget
node.unreachableYour node stopped answering
incident.opened, incident.reopenedRelated alerts opened (or reopened) an incident
incident.severity_changed, incident.escalatedAn incident's severity rose, or nobody took it in time (escalation)
incident.acknowledged, incident.resolvedSomeone took the incident; it closed (verified, or by hand with a reason)
alert.digestHeld messages went out as one digest

The body is fixed and versioned. A missed heartbeat looks like this:

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 equals the delivery behind webhook-id; data.monitor.incident_id links an opening to its monitor.recovered, and data.uptime.incident_id / data.slo.incident_id do the same for uptime and SLO events.

  • Issue events carry data.issue (short id, title, level, culprit, release); connection events carry data.integration (code, count, times); uptime events carry data.uptime (check id and name, target without its query string, code, reason, failing regions, times, certificate days left); SLO events carry data.slo (name, objective, window, severity, burn rates, budget left, times); node.unreachable carries data.node.name; incident events carry data.incident (id, number, title, severity, state, linked alert count, resolution); alert.digest carries data.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.url for the details.

Answer, retries and replays#

Your answerWhat Belvador does
2xxDelivered
408, 429, 5xx, timeout, connection errorRetries after about 1, 5 and 15 minutes (with a little random spread) — 4 attempts in total. 429 waits for your Retry-After
3xxNot followed. Counts as a configuration error: no more retries
Other 4xxConfiguration 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-signature carries 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

Spotted something wrong or missing? Tell us: hello@belvador.dev