Configure channels in dashboard → **Notifications**, or via the API
(`POST /v1/notifications/channels`, Bearer `ac-` key). Every delivery to a
custom channel is signed so receivers can verify authenticity.

## Channel types

| `channel_type` | Configure with | Delivery format |
|---|---|---|
| `slack` | `url` (`hooks.slack.com/services/…`) | Slack blocks |
| `discord` | `url` (`discord.com/api/webhooks/…`) | Embeds |
| `telegram` | `config.chat_id` + `secrets.bot_token` | Bot API `sendMessage` |
| `feishu` (Feishu) | `url` (`open.feishu.cn/open-apis/bot/v2/hook/…`) + optional `config.keyword` / `secrets.sign_secret` | Interactive card |
| `wecom` (WeCom) | `url` (`qyapi.weixin.qq.com/cgi-bin/webhook/send…`) | Markdown |
| `dingtalk` (DingTalk) | `url` (`oapi.dingtalk.com/robot/send…`) + optional `secrets.sign_secret` | Markdown |
| `email` | `config.recipients` (team member addresses) | Email via Resend |
| `custom` | `url` + `config.auth_type` + credentials | JSON envelope + HMAC signature |

Preset channel URLs are validated against a fixed endpoint — an allowlisted
hostname **plus a fixed path prefix** (e.g. Slack must be
`hooks.slack.com/services/…`, Discord `discord.com/api/webhooks/…`), so
arbitrary URLs on those hosts are rejected.

Custom channels authenticate with a preset `config.auth_type` instead of
arbitrary headers:

| `auth_type` | Required `secrets` | Sent as |
|---|---|---|
| `none` (default) | — | no `Authorization` header |
| `bearer` | `token` | `Authorization: Bearer <token>` |
| `basic` | `username` + `password` | `Authorization: Basic <base64>` |

`secrets` are stored encrypted and never returned by any read endpoint. Each
team is limited to 20 channels.

## Events

| Event | Fires when |
|---|---|
| `crawl.started` | a crawl job is accepted and queued |
| `crawl.completed` / `crawl.failed` | a crawl job reaches a terminal state |
| `monitor.page` | a monitor check observes `new` / `changed` / `removed` |
| `monitor.check.completed` | every monitor check settles (incl. `same`/`error`) |

`notification.test` is a dedicated test event emitted only by the Test
action / `channel_test` — it never appears in subscription lists.

`crawl.page` (per-page, high volume) is **not** available on persistent
channels — it is only delivered to per-request crawl webhooks
(`POST /v1/crawl` → `webhook.events`).

## Signature (custom channels)

Header: `X-LeanCrawl-Signature: t=<unix_seconds>,v1=<hmac-sha256-hex>`

The signature is computed over **`{t}.{rawBody}`** — the raw request body
bytes, not a re-serialized JSON document. Re-parsing and re-stringifying the
payload will break verification. Reject timestamps more than **±300 s** old.

### Node (WebCrypto)

```ts
const parts = Object.fromEntries(sig.split(',').map(p => p.split('=', 2)));
const key = await crypto.subtle.importKey(
  'raw', new TextEncoder().encode(secret),
  { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const mac = await crypto.subtle.sign(
  'HMAC', key, new TextEncoder().encode(`${parts.t}.${rawBody}`),
);
const expected = Buffer.from(mac).toString('hex');
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) throw new Error('stale');
if (expected !== parts.v1) throw new Error('bad signature');
```

### Python

```python
import hashlib, hmac, time

parts = dict(p.split('=', 1) for p in sig.split(','))
signed = f"{parts['t']}.{raw_body}".encode()
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
assert abs(time.time() - int(parts['t'])) <= 300
assert hmac.compare_digest(expected, parts['v1'])
```

## Payload

Custom channels receive the envelope `{success, type, id, data, error}` where
`data` is event-specific. Preset channels receive their native message format
rendered from the same event data. Payloads may include content fragments from
the target page — render them as text, never as HTML.

`monitor.page` data:

| Field | Notes |
|---|---|
| `url` | monitored page URL |
| `status` | `new` / `changed` / `removed` |
| `check_id` | fetch the full diff via `GET /v1/monitor/{id}/checks/{check_id}/diff` |
| `snapshot_id` / `prev_snapshot_id` | snapshot pair the check compared (`null` when none); fetch the stored markdown via `GET /v1/monitor/{id}/snapshots/{snapshotId}` — snapshots are retention-limited, fetch soon after the event |
| `excerpt` | first 500 chars of the new content (`null` for `removed`) |
| `diff_excerpt` | bounded change summary `{a, d, l, tr}` — parsed object, `null` for `new` or when the previous snapshot has expired; `l` rows use `t: 'a'/'d'/'c'` = added/removed/context |

Slack-style rendering: `+a −d` headline, then map `t` to `+` / `-` / ` ` in a
fenced block; when `tr` is true, link to the full diff endpoint above.

## Retries

Failed deliveries retry at **60 s / 300 s / 900 s**; after the last attempt the
delivery is dead — resend it manually from the dashboard delivery history or via
`POST /v1/notifications/channels/{channelId}/deliveries/{deliveryId}/resend`.
A `Retry-After` response header is honored. Disabling a channel marks its
in-flight deliveries dead, and deleting a channel keeps deliveries for audit.

### Managed channels vs request-level webhook

`POST /v1/crawl` also accepts a per-request `webhook` (`{url, events, secret?}`)
that pushes `crawl.*` events for that job only — including `crawl.page`, which
persistent channels do not receive. Prefer managed channels for
anything persistent: they expose deliveries + resend + secret rotation, cover
every crawl, and their secret is returned once at creation — while a request
webhook signs with a secret you must supply (an omitted one is generated but
never returned, so receivers cannot verify) and its deliveries have no
list/resend surface. If both target the same URL, that URL receives both sets
of events (managed channels also cover the job).

## Managing channels via API

The management surface accepts the same `Authorization: Bearer ac-…` key:

| Route | Notes |
|---|---|
| `GET /v1/notifications/channels` | List channels — secrets are never returned |
| `POST /v1/notifications/channels` | Create; a custom channel's response includes `signing_secret` **exactly once** |
| `PATCH /v1/notifications/channels/{id}` | Update `name`/`url`/`events`/`status`/`config`/`secrets` — id and `channel_type` never change; `name:null` clears the display name; secrets merge key by key (empty string keeps the stored value, an absent key is deleted) |
| `POST /v1/notifications/channels/{id}/rotate` | Rotate `signing_secret` in place (custom only) — new secret returned **exactly once** |
| `DELETE /v1/notifications/channels/{id}` | Deliveries are kept for audit; attached monitors auto-detach (`detached_monitors` count) |
| `GET /v1/notifications/channels/{id}/deliveries` | Most recent 50 deliveries (no pagination) |
| `POST /v1/notifications/channels/{id}/deliveries/{deliveryId}/resend` | Retry a failed/dead delivery now |
| `POST /v1/notifications/channels/{id}/test` | Send a `notification.test` event — 10/hour per team |

The same surface is reachable from MCP as `channel_list` / `channel_create` /
`channel_update` / `channel_rotate` / `channel_deliveries` / `channel_resend` /
`channel_delete` / `channel_test`.

### Rotating secrets

`POST /v1/notifications/channels/{id}/rotate` replaces the `signing_secret`
while keeping the channel id and all monitor attachments — use it when a
secret leaks.

**The old secret stops working immediately — there is no grace window.**
Deliveries sign with the channel's current secret at send time, so in-flight
retries and resends automatically re-sign with the new secret: update your
receiver's stored secret right after rotating, let failed deliveries retry
(60 s / 300 s / 900 s), and `resend` anything that ends up dead. Each rotate
call generates a brand-new secret — only the secret from the most recent
response is valid.