> ## Documentation Index
> Fetch the complete documentation index at: https://warden.buildlab.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Notifications

> Send what is waiting for a human to somewhere a human looks — signed, and never in the way.

A change that needs approval is only as useful as the reviewer knowing about it. Warden delivers pause and resolve events to any HTTPS endpoint you nominate.

```yaml warden.yaml theme={null}
notifications:
  console_url: https://warden.internal:9470
  webhooks:
    - name: reviews
      url: https://hooks.example.com/warden
      secret: whsec_a_long_random_value
      events: [proposed, resolved]   # omit for both
      timeout: 5s
```

> [!NOTE]
>
> **Nothing waits on delivery.** Notifications are a subscriber to the same event stream the operator view uses, not a step in the pipeline. An endpoint that is slow, down, or hostile delays no approval and blocks no agent — a reviewer who never got the message can still open the operator view, which remains the authority.

## What is delivered

```json theme={null}
{
  "event": "proposed",
  "sent_at": "2026-09-26T00:14:53Z",
  "operation": {
    "id": "op_01M3CY…", "connection": "shop", "agent": "agent:support",
    "class": "write", "verb": "update",
    "statement": "UPDATE shop.orders SET status = 'refunded' WHERE id = 'ord_1' AND orders.tenant_id = $1",
    "resources": ["table:shop.orders"],
    "bounded": true,
    "reason": "customer asked for a refund",
    "paused_at": "2026-09-26T00:14:53Z"
  },
  "preview": {
    "fidelity": "exact", "records_affected": 1,
    "changes": [{ "key": "id=ord_1", "kind": "update",
                  "fields": [{ "field": "status", "before": "paid", "after": "refunded" }] }]
  },
  "fence": "xmin",
  "deadline": "2026-09-26T00:45:53Z",
  "review": {
    "console": "https://warden.internal:9470/",
    "approve": "https://warden.internal:9470/api/v1/pending/op_01M3CY…/approve",
    "reject":  "https://warden.internal:9470/api/v1/pending/op_01M3CY…/reject"
  }
}
```

A `resolved` event carries the same operation plus how it ended:

```json theme={null}
{ "event": "resolved",
  "resolution": { "verdict": "approved", "by": "alice@example.com",
                  "reason": "verified with the customer", "waited_ms": 4093 } }
```

Two things about the payload are deliberate:

* **The statement keeps its parameters unbound** (`$1`), exactly as the operator view shows it. You see the shape of the operation, not the customer data it touches.
* **The preview is already masked and column-restricted**, by the gate, before it ever reaches the pending queue. A notification cannot leak a value the agent itself was not allowed to read.

Approve and reject links still require an operator token — the URL is a destination, not an authorisation.

## Verifying a delivery

Every request carries `X-Warden-Event`, `X-Warden-Delivery`, and:

```
X-Warden-Signature: t=1790000000,v1=9f86d081…
```

`v1` is `HMAC-SHA256(secret, "<t>.<raw body>")`, hex-encoded. **Verify it before acting on anything**, and reject deliveries whose timestamp is outside a few minutes — that is what stops an old, valid message being replayed at you.

```python Python theme={null}
import hmac, hashlib, time

def verify(secret: bytes, header: str, body: bytes, tolerance=300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, provided = parts.get("t", ""), parts.get("v1", "")
    if not t or not provided or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided)
```

```typescript TypeScript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, header: string, body: Buffer, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  const expected = createHmac("sha256", secret)
    .update(`${t}.`).update(body).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}
```

Sign over the **raw body**, before any JSON parsing and re-serialising, or the bytes will not match.

## Delivery behaviour

| | |
| - | - |
| Attempts | 3 — immediately, then after 2s, then after 10s |
| Timeout | `timeout` per attempt, default 5s |
| Success | Any 2xx |
| Giving up | Logged as a warning and counted; the operation is unaffected |
| Ordering | Not guaranteed. Use `operation.id` to correlate, and treat `resolved` as final |

Retries are deliberately few. A notification is a convenience, so a queue that grows while an endpoint is down would cost more than the messages are worth.

Watch [`warden_webhook_deliveries_total`](https://warden.buildlab.in/operate/metrics) by `result`:

```promql theme={null}
sum by (hook) (rate(warden_webhook_deliveries_total{result="dropped"}[1h]))
```

Anything above zero means reviewers are not being told, which is the only failure mode this feature really has.

## Requirements

**notifications.console\_url** `string`

Where reviewers reach this gateway. Used to build the links in a payload; omitted entirely if unset, rather than guessing an address that may not resolve from wherever the message is read.

**notifications.webhooks\[].url** `string` *required*

Must be `https`. A plaintext `http` endpoint is refused unless `allow_insecure: true` is set, and warns at startup every time: the payload carries the statement and the preview, and a signature proves origin without hiding content.

**notifications.webhooks\[].secret** `string` *required*

At least 16 characters. Required, because an unsigned webhook is one anyone who learns the URL can forge — and these carry approval requests.

**notifications.webhooks\[].events** `array`

`proposed`, `resolved`, or both. Empty sends both.

> [!NOTE]
>
> **Slack is next, and builds on this.** Approve and reject buttons in a Slack message, verified with Slack's signing secret, with each Slack user mapped to a named operator so the audit chain records who decided. Until then, a small relay that receives these webhooks and posts to Slack gets you most of the way — the payload already carries the diff.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.