Notifications¶
Lens can tell a chat room or another app when something happens in a namespace: a run finishes or fails, a batch run finishes, something is added, a routine's run fails or leaves graph changes to review. Each namespace's owners choose where its notifications go (its targets) and which events each target gets, on the namespace's page under Notifications.
Targets¶
| Kind | Sends | For |
|---|---|---|
| Matterbridge | a message to a gateway's API (POST /api/message) |
Slack, Discord, Matrix, Telegram, IRC, Mattermost, Teams and the other chats Matterbridge relays to |
| Webhook | Lens's own JSON, signed | scripts, n8n, Node-RED, Home Assistant, anything that takes an HTTP POST |
| Slack-style | {"text": …} |
Slack, Mattermost and Rocket.Chat incoming webhooks |
| Discord | {"content": …} |
a Discord channel's webhook |
Send test sends a target a message at once and says how it went; Sent lists what a target was sent lately (the latest 50, kept for 30 days), with each one's answer or error.
A target's address is sealed in the database once saved (Slack and Discord webhook URLs are credentials in themselves): the page shows its scheme and host only, and changing it means giving it in full again.
Matterbridge¶
Turn on Matterbridge's API and put it in the gateways that should get Lens's messages:
[api.lens]
BindAddress="0.0.0.0:4242"
Token="a-long-random-token" # optional; give the same to Lens
Buffer=1000
[[gateway]]
name="lens"
enable=true
[[gateway.inout]]
account="api.lens"
channel="api"
[[gateway.inout]]
account="slack.myteam"
channel="general"
Then add a Matterbridge target in Lens with the API's address (http://matterbridge:4242; /api/message is added),
the gateway's name (lens), the token if you set one, and the name messages are sent under (Lens by default).
A Matterbridge next to Lens is on a private address, so an admin has to allow its network first (below).
Webhooks¶
Each event is a POST of JSON:
{
"id": "job-41-failed-2026-10-02T12:00:05+00:00",
"type": "job.failed",
"timestamp": "2026-10-02T12:00:09+00:00",
"namespace": "pods",
"text": "“Episode 12” failed at transcribe in pods: RuntimeError: no model",
"url": "https://lens.example.org/activity/41",
"data": {"job": 41, "recording": 12, "title": "Episode 12", "status": "failed", "steps": ["transcribe", "analyze"], "error": "…"}
}
Deliveries are signed the Standard Webhooks way. A new webhook target comes
with its secret (whsec_…), shown once; New signing secret under Edit replaces it. Each request carries
webhook-id (the same on every retry of a message), webhook-timestamp (Unix seconds) and webhook-signature
(v1, and the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, keyed with the secret's bytes after
whsec_), plus X-Lens-Event with the event's type. To check one in Python:
import base64, hashlib, hmac
def verified(secret, headers, body: bytes) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + body
want = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(s.split(",", 1)[1], want) for s in headers["webhook-signature"].split())
The Standard Webhooks libraries (standardwebhooks for Python, JavaScript, Go and more) verify them as they are;
check that the timestamp is recent too, to refuse replays.
Events¶
| Event | When | data |
|---|---|---|
job.succeeded |
a run finished | job, recording, title, status, steps, finished_at, recording_url |
job.failed |
a run failed (the step it failed at is in text) |
the same, with error |
job.cancelled |
a run was cancelled | the same |
batch.finished |
a batch run finished, or its sample did; each namespace it ran in gets its own counts | batch, label, status, counts, sample |
recording.added |
an asset (a recording, document, image or web page) was added, however it came | recording, title, source, created_at |
lens.release |
a new Lens release is out, once per release (Updates) | tag, name, current, published_at, notes_url |
approval.waiting |
an automation's action waits for its person's pick (Approvals); one decided before the notifier saw it isn't sent | approval, automation, automation_name, summary, action, effect, expires_at, created_at |
routine.failed |
a routine's run failed: all its actions, some of them (text says how many), or it stopped without finishing; each namespace the routine runs over gets it |
routine, routine_name, run, trigger, status (error or partly), failed (each failed action's action number, type and error), actions, error, started_at, finished_at |
routine.proposed |
a routine's run left graph changes to accept or dismiss, counted in each namespace they touch; one whose changes were all decided before the notifier saw it isn't sent | routine, routine_name, run, trigger, status, proposed, kinds (merge, link), started_at, finished_at |
New targets get job.failed and batch.finished. A batch run's own runs aren't sent one by one: the batch run is,
when it finishes. More than five things added to a namespace at once (a watched folder's new files, an import) are one
message (data.recordings, data.count).
text is the message chat targets get, and url links to the run, batch run, asset, approval, routine or a routine
run's graph changes in the web app. What an approval would do, and a routine's errors, can hold text read from outside
Lens, so web addresses in their text are broken up (hxxps://evil[.]example), except the web app's own.
How it's sent¶
A notifier runs in every process that does background work: the API server with inline workers, and each
lens worker. Every few seconds it reads what changed (runs that ended, assets added) and queues it for the
targets that want it. Each event is claimed once across all of them, so several workers don't send it twice, and a
message that doesn't get through is tried again: after 30 seconds, then four times longer each time, up to six
hours, until notifications.max_attempts. A target that refuses a message (a 4xx other than 408, 425 and 429) isn't
tried again. A send gets 30 seconds in all; a target that answers slower than that counts as
failed. Turning a target off drops what it still had waiting.
Nothing that happened before notifications were set up, or while they were off, is sent.
Settings (admins)¶
Under Settings → Notifications (the notifications section, see Configuration):
| Setting | Default | |
|---|---|---|
enabled |
true |
Off: nothing is sent, and what happens meanwhile isn't sent later |
networks |
[] |
Private networks targets may be in, like 192.168.1.0/24 or 172.16.0.0/12 (Docker); automations calling a web address (http_request) may reach them too |
app_url |
null |
Where links in messages point; null uses the server's FRONTEND_URL |
poll_seconds |
5 |
How often the notifier looks |
max_attempts |
6 |
How many times a message is tried |
Targets reach public addresses only unless their network is listed under networks. The notifier resolves the
target's host itself, refuses it when any of its addresses isn't allowed, and connects to the address it checked, so
a name can't be pointed at the server's own services in between; redirects aren't followed. Workers send the
messages, so a target has to be reachable from where the workers run.