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