All docs
On this page

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_typeConfigure withDelivery format
slackurl (hooks.slack.com/services/…)Slack blocks
discordurl (discord.com/api/webhooks/…)Embeds
telegramconfig.chat_id + secrets.bot_tokenBot API sendMessage
feishu (Feishu)url (open.feishu.cn/open-apis/bot/v2/hook/…) + optional config.keyword / secrets.sign_secretInteractive card
wecom (WeCom)url (qyapi.weixin.qq.com/cgi-bin/webhook/send…)Markdown
dingtalk (DingTalk)url (oapi.dingtalk.com/robot/send…) + optional secrets.sign_secretMarkdown
emailconfig.recipients (team member addresses)Email via Resend
customurl + config.auth_type + credentialsJSON 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_typeRequired secretsSent as
none (default)—no Authorization header
bearertokenAuthorization: Bearer <token>
basicusername + passwordAuthorization: Basic <base64>

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

Events

EventFires when
crawl.starteda crawl job is accepted and queued
crawl.completed / crawl.faileda crawl job reaches a terminal state
monitor.pagea monitor check observes new / changed / removed
monitor.check.completedevery 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:

FieldNotes
urlmonitored page URL
statusnew / changed / removed
check_idfetch the full diff via GET /v1/monitor/{id}/checks/{check_id}/diff
snapshot_id / prev_snapshot_idsnapshot 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
excerptfirst 500 chars of the new content (null for removed)
diff_excerptbounded 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:

RouteNotes
GET /v1/notifications/channelsList channels — secrets are never returned
POST /v1/notifications/channelsCreate; 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}/rotateRotate 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}/deliveriesMost recent 50 deliveries (no pagination)
POST /v1/notifications/channels/{id}/deliveries/{deliveryId}/resendRetry a failed/dead delivery now
POST /v1/notifications/channels/{id}/testSend 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.