# Signed webhook

> Send incident events as signed JSON to any HTTPS receiver you control.

Web page: https://infrainbox.app/docs/notifications/webhook/

A webhook destination POSTs signed JSON to a URL you control, one request per notice. Use it to wire InfraInbox into something with no adapter of its own — Home Assistant, n8n, a status page, your own service.

## Add the destination

1. Go to **Add destination → Webhook** (dashboard screen `/destinations/new/webhook`).
2. Name it and enter your receiver's URL (`https`, or `http` only to a private address).
3. InfraInbox generates a signing secret in your browser and shows it once, shaped `whsec_…`. Copy it — your receiver needs it to verify what it gets, and InfraInbox can't show it to you again.
4. Save, then click **Send test**. Your receiver should get one request with `"type": "test"`.

## The request

```http
POST /your/path HTTP/1.1
Content-Type: application/json
webhook-id: dlv_01m28xg2pdexgbwj94sksn3kc2
webhook-timestamp: 1758268800
webhook-signature: v1,K5g0N2vqYFvXe3wq7z6z9J3m8s2q1p9r0c4h1x8v0m0=

{"type":"incident.opened","timestamp":"2026-09-19T08:00:00Z","data":{"v":1,"title":"Backup failed","severity":"CRITICAL","state":"open","sourceName":"Proxmox Home","resource":"job backup-daily","workspaceName":"Home lab","incidentId":"inc_2f9","firstSeenAt":"2026-09-12T21:31:04Z","url":"https://infrainbox.example.com/incidents/inc_2f9","reason":"opened"}}
```

`webhook-id` is the delivery's own ID and stays the same across retries of the same attempt, so your receiver can drop duplicates by it. `type` is `incident.<reason>` for a lifecycle notice (`incident.opened`, `incident.reoccurred`, `incident.escalated`, `incident.reopened`, `incident.resolved`, `incident.renotify`, `incident.fallback`, `incident.flapping`, `incident.silence_expired`, `incident.snooze_expired`, `incident.informational`), `incident.acknowledged`, `incident.snoozed` or `incident.updated` for a state change made elsewhere, and `test` for a manual test send.

## Verifying the signature

`webhook-signature` follows the [Standard Webhooks](https://www.standardwebhooks.com/) scheme: `v1,` followed by the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the bytes your secret decodes to.

1. Strip the `whsec_` prefix from your secret and base64-decode the rest — that's the raw HMAC key.
2. Build the signed content: the `webhook-id` header value, a literal `.`, the `webhook-timestamp` header value, another literal `.`, then the exact raw request body bytes (don't re-serialize the JSON — use the bytes as received).
3. Compute HMAC-SHA256 of that content with the key from step 1, base64-encode the result, and prefix it with `v1,`.
4. Compare that to the `webhook-signature` header using a constant-time comparison.

```python
import hashlib
import hmac
import base64

def verify(secret: str, webhook_id: str, timestamp: str, body: bytes, signature_header: str) -> bool:
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{webhook_id}.{timestamp}.".encode() + body
    expected = "v1," + base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(expected, signature_header)
```

```javascript
const crypto = require("crypto");

function verify(secret, webhookId, timestamp, body, signatureHeader) {
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const signedContent = Buffer.concat([Buffer.from(`${webhookId}.${timestamp}.`), body]);
  const expected = "v1," + crypto.createHmac("sha256", key).update(signedContent).digest("base64");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
```

> **Warning:** Verify against the raw request body bytes your server received, before any JSON parsing or re-serialization reformats them — even whitespace differences change the signature.

## How retries and failures are treated

| Your receiver answers | InfraInbox does |
|---|---|
| 2xx | Marks it sent |
| 401, 403, 404, 410 | Stops sending here until you edit or test the destination again — your receiver is telling it to stop |
| 408, 429, 5xx | Retries with backoff |
| Any other 4xx | Gives up on this one event, but keeps sending future ones. Five in a row with nothing sent in between, and the destination stops being tried, exactly as for a 401 — a receiver that refuses every notice is a configuration to fix, not a queue to burn. One notice that goes through clears the count |
| A redirect | Never followed; treated as a failure |

## What edits and state changes look like

A webhook destination is one of the channels that can represent a state change: an acknowledgement, a snooze or a manual resolve made elsewhere arrives as its own `incident.acknowledged`, `incident.snoozed` or `incident.updated` request, rather than being skipped — there's no message to edit, so InfraInbox just tells you what changed.

## Options

- **Send recovery and state updates** — off, and this receiver is only told when something breaks.
- **Enabled** — off holds the destination without deleting it.
- **Quiet-hours threshold** — the severity floor this destination still hears during [quiet hours](https://infrainbox.app/docs/notifications/quiet-hours.md).

## Rotating the signing secret

Click **Generate a new secret** on the destination's edit form, save, and update your receiver with the new value at the same time — the old secret stops verifying immediately once you save.

## Next

- [Verify a signed webhook](https://infrainbox.app/docs/cookbook/verify-webhook.md) — a worked example receiver
- [Routing rules](https://infrainbox.app/docs/notifications/routing.md) — send specific incidents here
