Configuration

All properties bind under the notify4j prefix (NotificationProperties).

1. Properties

Property Default Description

notify4j.urls

(empty)

Apprise/shoutrrr-style channel URLs (see Channel URLs). Blank entries are skipped.

notify4j.ignore-changes

:PENDING, :RUNNING, *:ASSIGNED

Status transitions to suppress on every channel (see Transition filtering).

notify4j.log

true

Add an always-on logging sink. Handy as a no-config default.

notify4j.global

true

Build the single app-wide facade. Set false in multi-tenant apps that build a facade per tenant from the injected NotificationsFactory instead.

notify4j.email.to

(empty)

Email recipients. The email channel is inactive until this is set.

notify4j.email.from

(spring.mail default)

From address.

notify4j.email.subject-prefix

[notify4j]

Prepended to the subject line.

notify4j.http.connect-timeout

10s

Connection timeout for the webhook-style channels.

notify4j.http.read-timeout

10s

Read (per-request) timeout for the webhook-style channels.

notify4j.http.max-attempts

3

Delivery attempts per channel on transient failures (HTTP 5xx/429, IOException). 1 disables retry. Other 4xx fail fast (a retry won’t help).

notify4j.http.retry-backoff

500ms

Base backoff between retries; doubled each attempt, capped at 30s.

notify4j.async.enabled

true

Deliver off the caller thread on a shared pool, so a slow channel can’t block the caller or its siblings. Set false for synchronous delivery.

notify4j.async.pool-size

4

Size of the shared async delivery thread pool.

notify4j.async.queue-capacity

1000

Bound on the delivery queue, so a burst or a persistently-slow channel can’t grow it without limit and OOM the host.

notify4j.async.rejection-policy

DROP

What to do when the pool and queue are both full. DROP (default) drops the delivery — never blocks the caller; the drop is logged and counted (outcome=dropped). CALLER_RUNS runs it on the caller’s thread instead (back-pressure).

notify4j.reminders.enabled

false

Re-notify every channel for an entity that stays in a reminder status until it resolves.

notify4j.reminders.statuses

(empty)

Statuses that arm a reminder (e.g. FAILED, DOWN). Required when reminders are enabled.

notify4j.reminders.period

1h

How long an entity must stay in a reminder status before it is re-notified.

notify4j.reminders.check-interval

1m

How often the scheduler checks for due reminders.

A single HttpClient is shared across all webhook-style channels (Slack, Teams, Discord, webhook, Telegram, ntfy, PagerDuty, OpsGenie); the timeouts and retry policy above apply to it.

By default the starter delivers asynchronously: send returns immediately and each channel fires concurrently on the shared pool, retrying transient HTTP failures. The pool is shut down with the application context.

Email is the one channel that is not a URL: it is Spring-mail-coupled and its SMTP transport comes from the standard spring.mail.* properties. It activates only when a JavaMailSender is present and notify4j.email.to is set.

2. Channel URLs

A channel URL is scheme[+transport]://endpoint[?tags=a,b]:

  • scheme selects the channel (and therefore the payload shape).

  • transport (+http / +https) selects the wire protocol; default https. http is mainly for tests.

  • ?tags= restricts which events reach the channel (see Tag routing); it is stripped before the endpoint is built.

Scheme Example

slack

slack://hooks.slack.com/services/T000/B000/XXXX

teams

teams://<power-automate-workflows-webhook> (posts a MessageCard — see note below)

discord

discord://discord.com/api/webhooks/ID/TOKEN

mattermost

mattermost://my-host/hooks/<key> (incoming webhook)

rocketchat

rocketchat://my-host/hooks/<id>/<token> (incoming webhook)

googlechat

googlechat://chat.googleapis.com/v1/spaces/<space>/messages?key=<k>&token=<t>

gotify

gotify://my-host/<app-token> (self-hosted push)

pushover

pushover://<app-token>/<user-key> (hosted mobile/desktop push)

twilio

twilio://<account-sid>:<auth-token>@<from>/<to> (SMS)

signal

signal://<bridge-host>/<from>/<to> (via a signal-cli REST bridge; use +http locally)

whatsapp

whatsapp://<token>@<phone-number-id>/<to> (Meta WhatsApp Cloud API)

zulip

zulip://<bot-email>:<api-key>@<host>/<stream>/<topic>

matrix

matrix://<access-token>@<homeserver>/<room-id> (Client-Server API)

mastodon

mastodon://<access-token>@<instance-host> (posts a status)

bluesky

bluesky://<identifier>:<app-password>[@<pds-host>] (AT Protocol; default host bsky.social)

pushbullet

pushbullet://<access-token>

telegram

telegram://api.telegram.org/<bot-token>/<chat-id>

ntfy

ntfy://ntfy.sh/<topic>

webhook

webhook://my-host/notify (posts {"id":…,"status":…,"message":…})

pagerduty

pagerduty://<routing-key> (Events API v2; the event id becomes the dedup_key)

opsgenie

opsgenie://<api-key> (Alert API; the event id becomes the alias)

Teams: Microsoft retired the legacy Office 365 connector incoming webhooks in May 2026. Create a Power Automate "Workflows" webhook (e.g. the "Post to a channel when a webhook request is received" / "Send webhook alert to a channel" template) and use that URL. notify4j posts a MessageCard, which the Workflows endpoint accepts.

3. Tag routing

A channel with no tags fires for every event. A tagged channel fires only when the event is sent with at least one overlapping tag:

notify4j:
  urls:
    - slack://...                       # untagged -> every event
    - pagerduty://<key>?tags=failed     # only events routed with the "failed" tag
notifications.send(event);                    // Slack only
notifications.send(event, List.of("failed")); // Slack + PagerDuty

4. Transition filtering

notify4j tracks the last status per event id and delivers only on a change. Patterns in ignore-changes suppress specific transitions, written OLD:NEW with * wildcards:

  • *:RUNNING — never notify when something enters RUNNING.

  • SUCCESS:FAILED — suppress exactly this transition.

The defaults (:PENDING, :RUNNING, *:ASSIGNED) mean channels fire on terminal SUCCESS/FAILED rather than on intermediate states.

5. Runtime mute

Beyond static ignore-changes, you can mute at runtime via the facade — e.g. silence one noisy subject for an hour — and list or remove active mutes:

notifications.addFilter(filter);   // a NotificationFilter (e.g. ExpiringFilter)
notifications.filters();           // active, non-expired mutes
notifications.removeFilter(id);

6. Metrics

When micrometer-core is on the classpath and a MeterRegistry bean exists (e.g. with Spring Boot Actuator), the starter records a counter per channel and outcome — no configuration needed:

Meter Tags

notify4j.notifications

channel (e.g. SlackNotifier), outcome (sent / failed / suppressed)

sent = delivered, failed = gave up after retries, suppressed = filtered out (e.g. not a real transition). The core itself has no metrics dependency; it records against a NotificationMetrics SPI that defaults to no-op when no registry is present.

7. Multi-tenant: async, retry & metrics without the starter

The notify4j. properties above only configure the starter’s single, app-wide facade. A *multi-tenant app instead injects the NotificationsFactory (and sets notify4j.global=false) to build one facade per tenant from that tenant’s URLs. The auto-configured factory already carries the configured HTTP/async/metrics settings, so factory.create(tenantUrls) inherits them — prefer that when you can.

When you build the factory programmatically (no starter), async, retry, and metrics are set on a NotificationsConfig (builder), not properties:

// Shared once, reused across tenants.
Executor pool = Executors.newFixedThreadPool(4);                 // async delivery (caller-owned)
var config = NotificationsConfig.builder()
        .ignoreChanges(List.of("*:PENDING", "*:RUNNING"))
        .includeLog(false)
        .http(HttpClientConfig.of(Duration.ofSeconds(5), Duration.ofSeconds(10),  // connect, read
                3, Duration.ofMillis(500)))                                       // maxAttempts, backoff
        .executor(pool)
        .metrics(NotificationMetrics.NOOP)                       // or a Micrometer-backed impl
        .build();

var factory = new NotificationsFactory<>(adapter, config);

// per tenant, at request time:
factory.create(tenant.channelUrls()).send(event, tenant.tags());
  • HttpClientConfig.of(connect, read, maxAttempts, retryBackoff) is the programmatic equivalent of notify4j.http. — *retry lives in core. maxAttempts=1 disables it.

  • .executor(pool) is the equivalent of notify4j.async.; omit it and the facade is synchronous — a slow channel blocks the caller. The executor is *caller-owned: reuse one pool across tenants and shut it down on app stop (Notifications.close() does not).

  • Implement NotificationMetrics (or reuse the starter’s Micrometer pattern) and set .metrics(…​); the starter only meters its facade, not a hand-built one.

  • NotificationsConfig.defaults() (or new NotificationsFactory<>(adapter)) silently gives 10s timeouts, single attempt, synchronous, and no metrics — fine for a quick start, but call it out so multi-tenant apps don’t ship that unintentionally.

8. Channel discovery (catalog)

To build a "pick a channel → fill the fields → save" UI without hardcoding the scheme list or URL assembly, query the read-only ChannelCatalog (since 1.1.0):

ChannelCatalog catalog = ChannelCatalog.standard();

// render a picker + form, data-driven (every channel notify4j adds shows up automatically)
for (ChannelDescriptor d : catalog.catalog()) {
    for (ChannelField f : d.fields()) {
        render(f.key(), f.type(), f.required(), f.secret());   // mask secret fields
    }
}

// notify4j owns URL assembly (separators, default hosts, +http, ?tags) — never concatenate
var errors = catalog.validate("twilio", values);               // field-keyed; empty == valid
String url = catalog.buildUrl("twilio", values, Set.of("failed"), false);

// edit screen: decompose a stored URL back into fields (secrets come back masked)
ParsedChannel parsed = catalog.parse(url);                     // values, tags, transport
String safe = catalog.redact(url);                             // scheme://host/… for display

buildUrl output is guaranteed to parse (a per-scheme round-trip test pins the catalog to the URL parser). Secret fields are masked by parse (as ChannelCatalog.MASKED_SECRET); leave a masked field untouched on save to keep the existing secret.