Notifications - ThinkHumanDotDev/marmot GitHub Wiki
A notification channel is one destination for alerts: a Slack webhook, a Telegram chat, an email
address, a PagerDuty integration, a plain webhook. Channels belong to an organization and are managed on
/{org}/notifications; each monitor carries the list of channels that should hear about it, chosen in the
monitor form (default channels start selected on new monitors) or from the channel's Monitors dialog.
When a monitor changes state (UP โ DOWN, DOWN โ UP, PENDING โ DOWN) the worker sends one message per
attached channel; with a resendInterval on the monitor it repeats the DOWN message every N beats while
the outage lasts. Nothing is sent for PENDING (retrying) beats or for beats inside a maintenance window.
Each channel chooses which events it hears about (down, recovery, degraded performance, reminders,
certificate and domain expiry, maintenance windows), so you can page PagerDuty only on DOWN and send
recoveries and slow responses to Slack.
Setting up a channel is a form: pick a provider, fill in the fields the provider needs (the form is generated from the provider's schema, so required fields and secrets are marked), pick the events, press Send test (one sample per selected event), save. Tick Default to preselect the channel on every new monitor and Apply to all existing monitors to attach it to the monitors you already have. Marmot ships 48 providers ported from Uptime Kuma (table below) plus WhatsApp, grouped as Chat, Push, Email and Generic (webhooks, incident management, SMS).
Marmot alerts through notification channels: org-scoped documents in the notifications collection that
name a provider (type) and carry its settings (config). Monitors reference channels through their
notifications relationship; a channel flagged isDefault is attached to every monitor created afterwards
(unless the monitor form or API call lists its channels explicitly), and "Apply to all existing monitors"
attaches it to the organization's current monitors. The Monitors entry of a channel's menu attaches the
channel to, or detaches it from, any monitor of the organization
(GET/PUT /api/orgs/:orgId/notifications/:id/monitors, { "monitors": [id, โฆ] }). Members can see
channels (notification:read, secrets masked); admins and owners create, edit, test and delete them
(notification:create|update|delete).
check worker โโถ heartbeat (notify=true, notificationEvent) โโถ enqueueNotificationsForHeartbeat()
โโถ channelAcceptsEvent(channel, event) keeps the active attached channels that selected the event
โโถ BullMQ queue `marmot:notifications`, one job per remaining channel
job id `notif:<notificationId>:<heartbeatId>` (dedupes repeated events), 3 attempts, exponential backoff 5s
โโถ startNotificationWorker() โโถ processNotificationJob() โโถ sendNotification() โโถ provider.send()
โโถ `notifications.lastSentAt` / `lastError` updated on the channel
The state machine decides when a beat notifies (notify): status transitions
(isImportantForNotification) plus the resendInterval tick while DOWN. The first beat only notifies when
it is DOWN. Everything runs in the worker process (src/worker.ts).
Each notifying beat carries a notification event (notificationEvent on the heartbeat event and the
job data; notificationEventFor() in src/server/engine/beat.ts):
| Event | When |
|---|---|
down |
the monitor went DOWN (from UP, DEGRADED, PENDING or MAINTENANCE, or on its first beat) |
up |
it recovered from DOWN, to UP or to DEGRADED |
degraded |
it became DEGRADED (from UP, after retries or after maintenance) or went back to UP |
reminder |
the resendInterval repeat while still DOWN |
Every channel has an events selection (src/lib/notification-events.ts); a channel is only told about
the events it selected:
| Event | Sent when | Default |
|---|---|---|
down |
a monitor went DOWN | on |
up |
it recovered from DOWN (the message carries the downtime) | on |
degraded |
it became DEGRADED or went back from DEGRADED to UP | off |
reminder |
the resendInterval repeat while still DOWN |
on |
certificate |
a TLS certificate or domain expiry warning, or a new certificate in the CT logs | on |
maintenance |
a maintenance window covering the monitor starts or ends (once per channel and event) | off |
acknowledged |
a member acknowledged the monitor's incident | on |
resolved |
a member resolved the monitor's incident by hand (automatic resolution is up) |
on |
incident |
a declared incident was declared, changed status or commander, or its commander was reminded | off |
The defaults are what every channel received before the filters existed plus the incident events, and a
channel without a selection (one created before the field existed, or saved with an empty list) gets the defaults, so
existing channels behave as before after the upgrade without a data migration. The names are stable
identifiers (stored on channels, exported, sent to templates as {{ event }}); new events are added,
never renamed.
channelAcceptsEvent() in src/server/notifications/dispatch.ts is the single filter point: the
dispatcher applies it before enqueuing, the worker again before sending (a job queued before the channel
dropped the event is skipped as event-filtered), the expiry warnings and maintenance messages before
sending, and the Test button sends one sample per selected event. Location-specific rules for
multi-location monitoring (#92) belong in the same function.
Recovery messages (up) end with the downtime: [API] [โ
Up] 200 - OK (down for 7 minutes 3 seconds).
It is measured from the important DOWN beat right before the recovery; when that beat is gone (retention)
the suffix is left out rather than guessed. Discord's "Downtime Duration" field uses the same value.
Maintenance messages come from the maintenance events (src/server/maintenance/events.ts): when an
occurrence starts or completes, every active channel that selected maintenance and is attached to one of
the window's monitors (children of listed groups included) gets one notify-maintenance job (job id
notif-maint:<channel>:<occurrence>:<started|completed>) and one message,
[Marmot] [๐ง Maintenance] Maintenance "DB upgrade" started for API and Web. Announcements, reminders,
notes and cancellations stay with status page subscribers.
Before enqueuing, the listener asks the notification gates (registerNotificationGate in
src/server/notifications/gates.ts); any gate answering false holds the beat back, and a gate that throws
is ignored. Monitor incidents register one that runs the reminder policy
(src/server/incidents/reminders.ts, replaceable with setReminderPolicy) on resendInterval reminders. The
default, backoffReminderPolicy, stops reminders while the monitor's incident is acknowledged and applies the
monitor's reminder backoff: reminderBackoff (none, linear or exponential) spaces them and maxReminders
caps them (Monitors). Reminders that go out are counted
on the incident (remindersSent, lastReminderAt), which is what the backoff reads; it measures time on the
beats' own clock.
Acknowledging or resolving a monitor incident by hand notifies the monitor's active
channels that selected the event, through the same queue: job incident-notify with id
inc-<channel>-<incident>-<event>, event acknowledged or resolved (filtered by
channelAcceptsEvent() like everything else). The message keeps the usual shape:
[name] [๐ Acknowledged] Acknowledged by Ada. Note: โฆ or [name] [โ
Resolved] Resolved by Ada after 12 minutes., sent with the monitor and no heartbeat (like a test message, so rich providers send the text).
An automatic resolution sends nothing extra: the UP notification already announces the recovery.
Declared incidents belong to the organization rather than to a monitor, so
they go to every active channel of the organization that selected the opt-in incident event: job
declared-incident-notify (one per channel, plus one email to the commander when assigned and for
reminders), message [title] [๐จ Incident declared] Critical incident declared. Commander: Ada. <link>.
While the incident is still open, DOWN messages (first alert and reminders) end with
Acknowledge: <server>/ack/<token>, a signed link to acknowledge from the phone. Providers that build their
own layout from the heartbeat (Discord embeds, Slack blocks, โฆ) show it only with a custom template that
includes {{ msg }}.
The default message is [monitor name] [โ
Up|๐ด Down|โ ๏ธ Pending|๐ง Maintenance|๐ข Degraded] <heartbeat message>.
The status labels, the test message and the certificate/domain expiry warnings are written in the
organization's language (organizations.settings.language, English by default); the bracketed layout
and the {{ status }} template variable follow it, and so do provider-specific titles and field names
(Discord embeds, Slack blocks, Teams cards and the like). Product names, payload keys and identifiers
(source, alias, dedup keys) stay as they are. The channel form shows provider and field labels in
the user's language.
A monitor with an escalation policy also pages people (see Architecture):
when its incident opens, each step of the policy pages the member on call in a schedule, a member (by email)
or a notification channel, then waits for an acknowledgement before the next step. A channel target ignores
the channel's event filter (the policy chose it) and receives
[name] [๐ Page] name is down and you are on call. Cause: โฆ Acknowledge: <server>/ack/<token>; members get
the same text by email. Acknowledging the incident (dashboard, API or the link) stops the escalation. Manage
schedules, overrides and policies on the On-call page (g o).
Monitors with a hostname (HTTP family, TCP port, ping, DNS) can watch the public
certificate transparency logs: Certificate transparency alerts
in the monitor form's Advanced card (ctAlerts), optionally with Include subdomains
(ctIncludeSubdomains, every *.hostname) and Expected issuers (ctExpectedIssuers, e.g.
Let's Encrypt). Each new certificate for the name is announced once, as the certificate event:
[Shop] New certificate for shop.example.com issued by C=US, O=Let's Encrypt, CN=R11. Names: shop.example.com, www.shop.example.com. Valid from Oct 1, 2026, 9:00 AM UTC to Dec 30, 2026, 9:00 AM UTC. https://crt.sh/?id=โฆ
A certificate whose issuer contains none of the expected issuers (case-insensitive) is flagged:
[Shop] โ ๏ธ Unexpected issuer: new certificate for โฆ issued by โฆ, which is not an expected issuer. โฆ
The worker's ct-alerts job (src/server/jobs/ct-alerts.ts, every 5 minutes on its own marmot:ct queue)
looks each watched monitor up every CT_CHECK_INTERVAL_MINUTES (3 h by default) in crt.sh, or in the source
set with CT_SOURCE_URL (Configuration):
- Baseline. The first lookup after the option is turned on (or after the hostname or the subdomain option changed) only records the moment; certificates logged before it are never announced. Turning the option off forgets it, so turning it on again does not announce the time in between.
-
Once. Announced certificates are kept in
notification-sent-history(typect, keyed byfingerprint: issuer and serial number, so a precertificate and its certificate count once). A certificate is claimed there before it is sent; when every channel fails the claim is released and the next lookup retries. Without a channel that acceptscertificatethe certificate is only recorded. At most 10 certificates per monitor are sent per run; the rest follow on the next run. -
Rate limits. Requests are spaced by
CT_REQUESTS_PER_MINUTEand the same lookup is made once per run. A failed lookup (network error,429,5xx, invalid JSON) ends the run: the source pauses for an exponential backoff from 5 minutes up to a day, or itsRetry-Afterwhen longer, and the monitor's next lookup moves back the same way. The last error is shown inctState.
CT alerts are off in demo mode, which never queries third parties.
POST /api/orgs/:orgId/notifications/test with { "notificationId": โฆ } (saved channel),
{ "notificationId": โฆ, "config": { โฆ } } (unsaved edits of a saved channel) or
{ "type": "slack", "config": { โฆ } } (unsaved) sends one sample per selected event
([Marmot] [โ ๏ธ Test] Down: "Ops" is configured correctly., in event order, stopping at the first failure)
and answers { ok: true, result, events } or 400 { ok: false, error }. events in the body tests an
unsaved selection; otherwise the saved channel's (or the defaults) apply. Requires notification:update.
The UI's Send test button uses it; each sample counts against the server SMTP budget. Unsaved
settings follow the same rules as saving them (403 when the caller may not use the server SMTP settings);
429 means the organization used up its hourly budget for the server SMTP settings.
| Method | Path | Permission |
|---|---|---|
| GET | /api/orgs/:orgId/notifications |
notification:read |
| POST | /api/orgs/:orgId/notifications |
notification:create |
| PATCH | /api/orgs/:orgId/notifications/:id |
notification:update |
| DELETE | /api/orgs/:orgId/notifications/:id |
notification:delete |
| POST | /api/orgs/:orgId/notifications/test |
notification:update |
| POST | /api/orgs/:orgId/notifications/preview |
notification:read |
| GET | /api/orgs/:orgId/notifications/providers |
notification:read |
Body fields for POST/PATCH: name, type, config, events (array of event names; unknown names are
dropped, an empty list means the defaults), isDefault, applyExisting (virtual, one-shot), active. config is validated against the provider's schema and its templates are checked; errors come back as config.<key>: message.
Fields marked template below are Liquid templates, rendered with
LiquidJS in a sandbox (src/server/notifications/liquid.ts). Plain
{{ variable }} templates render exactly as before Liquid: unknown variables are empty and objects print
as JSON. Templates written for Uptime Kuma 2.x (which also uses Liquid) work after an import:
monitorJSON and heartbeatJSON are aliases of monitor and heartbeat.
{% if event == "down" %}๐ด {{ name }} is down: {{ heartbeat.msg }}
{% elsif event == "up" %}๐ข {{ name }} is back after {{ downtime }}
{% else %}{{ msg }}{% endif %}
Checked {{ heartbeat.time | date: "%H:%M", "Europe/Berlin" }} ยท {{ monitor.dashboardUrl }}Every value is a string ('' when unknown). The list lives in src/lib/notification-template-variables.ts
and is type-checked against the render context; saving a channel rejects templates that use a variable not
in it (Unknown template variable "monitor.nmae").
| Variable | Example |
|---|---|
{{ msg }} |
[API] [๐ด Down] HTTP 503 (default message) |
{{ status }} |
๐ด Down |
{{ event }} |
down, up, degraded, reminder, certificate, maintenance
|
{{ downtime }} / {{ downtimeSeconds }} (recoveries) |
7 minutes 3 seconds / 423
|
{{ name }} |
API |
{{ hostnameOrURL }} |
https://api.example.com/health |
{{ monitor.id/name/type/url/hostname/port/description }} |
|
{{ monitor.dashboardUrl }} |
https://marmot.example.com/acme/monitors/7 |
{{ heartbeat.status/msg/ping/time/duration/retries/downCount }} |
heartbeat.status โ down
|
{{ heartbeat.localDateTime }} / {{ heartbeat.timezone }}
|
2026-03-10 11:30:00 / Europe/Berlin
|
{{ organization.name/slug/logoUrl }} |
Acme |
{{ monitorJSON.* }} / {{ heartbeatJSON.* }}
|
Uptime Kuma names for monitor / heartbeat
|
monitor and heartbeat are empty for messages without them (maintenance windows; test sends), and
heartbeat is empty for certificate and domain expiry warnings. heartbeat.localDateTime and the date
filter use the organization's time zone and language (organizations.settings).
Tags: if/elsif/else/unless, case/when, for (with break, continue, cycle, tablerow),
assign, capture, increment/decrement, echo, liquid, raw and comments. There is no include,
render or layout: templates cannot load other templates or files.
Filters (anything else is an error): text โ append, prepend, capitalize, downcase, upcase,
strip, lstrip, rstrip, strip_newlines, strip_html, newline_to_br, replace, replace_first,
replace_last, remove, remove_first, remove_last, truncate, truncatewords, split, slice,
size, default, escape, escape_once, url_encode, url_decode, normalize_whitespace, raw;
lists โ join, first, last, map, where, reject, sort, sort_natural, uniq, compact,
reverse, concat, sum; numbers โ abs, at_least, at_most, ceil, floor, round, plus,
minus, times, divided_by, modulo; data โ date (strftime format, optional time zone),
json/jsonify (JSON-encode, for webhook bodies: {"text": {{ msg | json }}}) and Marmot's duration
(seconds to words in the organization's language: {{ downtimeSeconds | duration }}).
- Templates only see the variables above (own properties of plain data): no
process, environment variables, prototypes or files. - Limits: 20 000 characters per template, 200 ms render time, an allocation budget for loops and string building, and 100 000 characters of output.
-
On save (and on Send test with unsaved settings) every template is parsed and its variables are
checked; problems come back as field errors (
config.template: Template error: โฆ). Imports report such channels as skipped. -
On send, a template that still fails (a limit, a filter error, a channel saved before a rule
existed) is logged (
notifications:template) and the default message is sent instead, so an alert is never lost to a template.
The email providers (smtp, sendgrid, resend) send a branded, responsive HTML email with a plain-text
part by default: the organization logo (or name), the status in its colour, the monitor, the message, the
time, the downtime of a recovery, the address and a View monitor button
(monitor.dashboardUrl, built from NEXT_PUBLIC_SERVER_URL).
Custom HTML: SMTP's body with Send the body as HTML, or the HTML template of SendGrid and Resend.
HTML templates escape every value automatically ({{ heartbeat.msg }} cannot inject markup; | raw opts
out, | escape does not escape twice), and the text part is generated from the HTML (links keep their
address). SMTP's body without Send the body as HTML sends a plain-text email only. Subjects are
templates for all three providers.
The channel form's Preview renders the default message, every template and, for email providers, the
email itself (HTML in a sandboxed frame, plus the text part) against sample data of the chosen event, in the
organization's language. Nothing is sent. API: POST /api/orgs/:orgId/notifications/preview with
{ "type", "config", "event" } (or { "notificationId", "event" } for a saved channel; the config does not
have to be complete) answers { event, message, fields: [{ name, mode, output, error }], email }. Requires
notification:read.
Providers live in src/server/notification-providers/<name>.ts, register themselves with
registerNotificationProvider() and export a zod configSchema + fieldMeta that drive both validation and
the dynamic form. Most are ports of Uptime Kuma's providers (see THIRD_PARTY_NOTICES.md).
type |
Group | Config keys (required in bold) |
|---|---|---|
discord |
Chat |
webhookUrl, username, prefixMessage, messageFormat normal|minimalist|custom, messageTemplate (template), channelType channel|createNewForumPost|postToThread, threadId, postName, suppressNotifications, disableUrl |
slack |
Chat | webhookUrl, channel, username, iconEmoji, richMessage (default true), channelNotify, useTemplate, template (template) |
telegram |
Chat |
botToken, chatId, messageThreadId, serverUrl (default https://api.telegram.org), sendSilently, protectContent, useTemplate, template (template), templateParseMode plain|HTML|MarkdownV2
|
teams |
Chat | webhookUrl (Power Automate / incoming webhook; sends an Adaptive Card) |
matrix |
Chat | homeserverUrl, internalRoomId, accessToken, useTemplate, template (template) |
ntfy |
Push | serverUrl (default https://ntfy.sh), topic, priority 1โ5 (default 4; DOWN uses priority+1), priorityDown, authMethod none|usernamePassword|accessToken, username, password, accessToken, icon |
gotify |
Push | serverUrl, appToken, priority 0โ10 (default 8) |
pushover |
Push |
userKey, appToken, device, title, priority -2โฆ2, sound, soundUp, ttl |
smtp |
useServerSmtp (reuse SMTP_* env), host, port (587), secure, ignoreTlsErrors, user, pass, from, to, cc, bcc, subject (template), body (template), htmlBody |
|
webhook |
Generic |
url, method POST|GET|PUT|PATCH, contentType json|form-data|custom, customBody (template), additionalHeaders (JSON object). JSON/form bodies are { heartbeat, monitor, msg }; GET sends them as query parameters |
Second wave (all ports of Uptime Kuma 2.5.5 providers; "Generic" also covers incident-management and SMS services):
type |
Group | Config keys (required in bold) |
|---|---|---|
mattermost |
Chat |
webhookUrl, username, channel, iconUrl, iconEmoji (:up: :down: = one emoji per status) |
rocket-chat |
Chat | webhookUrl, channel, username, iconEmoji |
google-chat |
Chat | webhookUrl, maxRetries 1โ10 (retries on HTTP 429), useTemplate, template (template) |
signal |
Chat |
apiUrl (signal-cli-rest-api /v2/send), number, recipients (comma-separated), useTemplate, template (template)
|
line-messaging |
Chat | channelAccessToken, userId |
pumble |
Chat | webhookUrl |
zoho-cliq |
Chat | webhookUrl |
nextcloud-talk |
Chat | host, conversationToken, botSecret (HMAC-signed bot message), sendSilentUp, sendSilentDown |
onebot |
Chat |
httpAddr, accessToken, msgType group|private, receiverId
|
wecom |
Chat |
botKey, mentionedMobileList (comma-separated, @all) |
dingding |
Chat |
webhookUrl, secretKey (signed requests), mentioning nobody|everyone|specify-mobiles|specify-users, mobileList, userList |
feishu |
Chat | webhookUrl (interactive card) |
bitrix24 |
Chat |
webhookUrl (inbound webhook with im scope), userId
|
bark |
Push |
endpoint (incl. device key), apiVersion v1|v2, group (default Marmot), sound (default telegraph) |
pushdeer |
Push | serverUrl (default https://api2.pushdeer.com), pushKey
|
serverchan |
Push |
sendKey (sctpโฆ keys route through ft07.com) |
pushbullet |
Push | accessToken |
webpush |
Push |
subscription (PushSubscription JSON), vapidPublicKey, vapidPrivateKey, vapidSubject (mailto:), title. Generate keys with npx web-push generate-vapid-keys
|
techulus-push |
Push | apiKey, title, channel, sound, timeSensitive (default true) |
pushy |
Push | apiKey, deviceToken |
home-assistant |
Push |
url, longLivedAccessToken, notificationService (default notify) |
sendgrid |
apiKey, fromEmail, toEmail, ccEmail, bccEmail, subject (template), htmlTemplate (template) | |
resend |
apiKey, fromEmail, fromName, toEmail, subject (template), htmlTemplate (template) | |
pagerduty |
Generic |
integrationKey, integrationUrl (Events API v2), priority info|warning|error|critical, autoResolve none|acknowledge|resolve (UP events); dedup key Marmot/<monitorId>
|
opsgenie |
Generic | region us|eu, apiKey, priority 1โ5. DOWN creates an alert aliased by monitor name; UP closes it |
splunk |
Generic |
restUrl (Splunk On-Call REST endpoint), severity INFO|WARNING|CRITICAL, autoResolve none|ACKNOWLEDGEMENT|RECOVERY
|
squadcast |
Generic |
webhookUrl; sends trigger/resolve with the heartbeat attached |
alerta |
Generic |
apiEndpoint, apiKey, environment, alertState (default critical), recoverState (default cleared) |
grafana-oncall |
Generic |
webhookUrl (formatted webhook); alerting on DOWN, ok on UP |
heii-oncall |
Generic |
apiKey, triggerId; alert on DOWN, resolve on UP |
twilio |
Generic | accountSid, apiKey (SID, optional), authToken, fromNumber, toNumber, messagingServiceSid (SMS) |
clicksend |
Generic | login, password (API key), toNumber, senderName (SMS; non-ASCII characters are stripped) |
apprise |
Generic |
appriseUrl, title. Runs the apprise CLI on the worker host (pip install apprise); fails with a readable error when the binary is missing. Refused while MONITOR_DENY_PRIVATE_ADDRESSES is on |
Nostr is not ported: it needs nostr-tools plus a WebSocket polyfill, which outweighs its use.
whatsapp (Chat) sends through one of two backends, picked with backend:
backend |
Config keys (required in bold) |
|---|---|
cloud (default) |
recipient, accessToken (secret), phoneNumberId, templateName, templateLanguage (default en_US), templateVariables |
twilio |
recipient, accountSid, apiKey (SID, optional), authToken (secret), fromNumber, contentSid, templateVariables |
-
Numbers (
recipient,fromNumber) must be E.164 (+14155550123); spaces, dashes and awhatsapp:prefix are accepted and stripped. Saving a channel rejects anything else. -
Templates. WhatsApp only delivers business-initiated messages that use a template approved in
WhatsApp Manager (Cloud API) or the Twilio Content Template Builder.
templateVariablesis a template field with one Liquid template per line; line 1 fills the template's{{1}}, line 2{{2}}and so on. The default is{{ name }},{{ status }},{{ msg }}, so a matching template body could be{{1}} is {{2}}: {{3}}. Clear the field for a template without variables (such as Meta'shello_world). Rendered values are flattened to one line (WhatsApp rejects newlines and tabs in parameters) and empty values are sent as-. -
Cloud API posts a
type: "template"message tohttps://graph.facebook.com/v21.0/<phoneNumberId>/messageswithAuthorization: Bearer <accessToken>. Use a permanent system-user token with thewhatsapp_business_messagingpermission; temporary tokens from the API setup page expire after 24 hours. -
Twilio posts to the Messages API with
whatsapp:-prefixedTo/From. WithcontentSidit sends that content template and the variables asContentVariables({"1": โฆ, "2": โฆ}); without it the plain message goes out asBody, which WhatsApp only delivers within 24 hours of the recipient's last message. -
Errors from Meta or Twilio are stored on the channel's
lastErrorwith the vendor's message, e.g.WhatsApp Cloud API (HTTP 400): (#132001) Template name does not exist in the translation.
An smtp channel with Use the server SMTP settings (useServerSmtp: true) sends with the instance's
SMTP_* settings and EMAIL_FROM instead of its own server, so it carries the operator's sender domain.
Three limits apply:
-
Who:
NOTIFICATIONS_SERVER_SMTP(defaultsuperadmin). Insuperadminmode only instance superadmins may create a channel with the option or switch a channel to it, and other users cannot change the settings of a channel that uses it (they may still rename, pause, delete it or turn the option off); everyone else gets a403from the REST API, the Local API (withoutoverrideAccess) and the routes above, and the form disables the switch. Existing channels keep sending.offrefuses the option for everyone, and existing channels that use it fail with an error in their Last error instead of falling back to the server.alllets anyone who manages channels use it. -
How much:
NOTIFICATIONS_SERVER_SMTP_RATEmessages per organization per hour (default60,0= unlimited), counted in Redis across every channel of the organization; Send test counts too. Over the limit a delivery is refused and recorded as the channel's Last error (not retried), and the test endpoint answers429withRetry-After. When Redis is unreachable the limit is not enforced and the worker logs one warning. - To whom: at most 10 recipients per message (to, cc and bcc combined), checked on save and on send.
Channels with their own SMTP server are not affected by any of these.
- Create
src/server/notification-providers/<name>.ts: a zodconfigSchema(object of string / number / boolean / enum fields, optionally.optional()or.default()), afieldMetamap (labels, placeholders,secret: truefor tokens,multiline: truefor templates,optionsfor enum labels) andregisterNotificationProvider({ name, label, group, docsUrl, configSchema, fieldMeta, send }). - Use
postJson/httpRequestfrom./httpso network errors carry the HTTP status and body, and so tests can stubglobalThis.fetch. - Add one
import './<name>'line tosrc/server/notification-providers/index.ts. - Port from Uptime Kuma? Add the attribution header and a line in
THIRD_PARTY_NOTICES.md. - Add a payload test to
src/server/notification-providers/providers.test.ts.