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

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](https://www.standardwebhooks.com/), 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:

| 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

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

### Without a library (Web Crypto)

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

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

## 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](https://belvador.dev/en/docs/uptime/quickstart) 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](https://belvador.dev/en/docs/slos/alerts) 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](https://belvador.dev/en/docs/incidents) |
| `incident.severity_changed`, `incident.escalated` | An incident's severity rose, or nobody took it in time ([escalation](https://belvador.dev/en/docs/alerts/policies)) |
| `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:

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