Skip to content

Core (any JVM app) — ntfy-core

Maven Central Javadoc

The framework-neutral ntfy engine and its NtfyClient — no logging-framework dependency. It ships GraalVM native-image metadata for hand-rolled native builds.

Use this when you want to send ntfy notifications programmatically from any JVM app, or you're building your own adapter. Every other module in this family depends on ntfy-core.

Runnable example: examples/core — a plain-JVM app driving NtfyClient and AlertEngine, whose CI-run integration tests cover the base publish path, sync-vs-async delivery, and the clean-shutdown digest flush.

Install

<dependency>
  <groupId>io.github.pimak</groupId>
  <artifactId>ntfy-core</artifactId>
  <version>2.1.0</version>
</dependency>

Base configuration

There is no auto-install here — you build a NtfyConfig and drive the client yourself:

NtfyConfig config = NtfyConfig.builder()
    .url("https://ntfy.example.com")
    .topic("my-app-alerts")
    .token("tk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx")
    .build();

try (NtfyClient client = new NtfyClient(config)) {   // close() releases the HTTP client
  client.notify("Batch complete", "12,304 rows processed");
}

NtfyClient.notify(...) never throws — every outcome is returned as a PublishResult.

The engine can also resolve config from the environment (sysprop > env > classpath ntfy.properties) via ConfigLoader — see the configuration reference.

When you drive an AlertEngine directly (the log-adapter path, not the synchronous NtfyClient), error-alert delivery is offloaded by default (since 2.0) to a bounded queue drained by a daemon worker, so a slow ntfy server never blocks the submitting thread; tune it with NtfyConfig.builder().asyncQueueCapacity(1024), or set asyncEnabled(false) for pre-2.0 synchronous, inline delivery. NtfyClient itself stays synchronous by contract (it returns a PublishResult). See alert-behavior.md for the queue and overflow semantics.

MDC context in alert bodies

To have per-event context rendered into alert bodies, name the MDC keys on the builder. Two forms are available — a typed list, and the CSV form the string-based surfaces use:

NtfyConfig config = NtfyConfig.builder()
    .url("https://ntfy.example.com")
    .topic("my-app-alerts")
    .includeMdcKeys(List.of("correlation-id", "tenant"))   // typed list
    // .includeMdcKeysCsv("correlation-id,tenant")         // ...or the CSV form
    .build();

Both spell the same setting; the CSV form exists so programmatic callers can pass through a value they read from their own configuration without splitting it themselves. Prefer the List form when you have the keys in hand — it is also the only way to name an MDC key containing a comma, which is unreachable from every CSV surface.

Core itself defines the allow-list and the rendering; it is the adapter that supplies the values. AlertEvent carries them in its mdcValues component, populated by the Logback adapter from ILoggingEvent.getMDCPropertyMap(), by the Log4j2 appender from LogEvent.getContextData(), and by the JUL handler from ExtLogRecord under JBoss LogManager.

If you build AlertEvents yourself and drive AlertEngine.submit directly, you own the allow-list step. The engine renders whatever mdcValues holds; it does not intersect that map with include-mdc-keys. What you still get for free is the bounding: AlertEvent's constructor re-applies the scrubbing, the per-value truncation and the key/total caps to whatever you hand it, so a hand-built event can never forge a body line or blow the payload budget. But selection is yours — hand a raw MDC map to the constructor and every entry in it is published.

So project it explicitly, the same way the adapters do:

Map<String, String> mdc =
    MdcProjector.project(config.getIncludeMdcKeys(), MDC::get);   // or any key -> value lookup
engine.submit(new AlertEvent(logger, message, millis, causes, frames, markers, mdc));

The six-argument constructor, which omits mdcValues entirely, remains the right choice when you have no context to attach. See mdc-context.md for the guards themselves.

Startup self-test

Alerting only speaks when something breaks, so a revoked token or a wrong topic stays invisible until the first real error alert fails to deliver. The opt-in self-test makes one round-trip at boot and reports a clear diagnostic instead:

NtfyConfig config = NtfyConfig.builder()
    .url("https://ntfy.example.com")
    .topic("my-app-alerts")
    .startupPing(StartupPingMode.PROBE)
    .build();

probe is read-only and publishes nothing; publish sends one low-priority test notification through the production path and is the only mode that proves alerts are genuinely deliverable (ntfy grants read and write separately). It runs in the background and never delays startup. Add .startupPingFailFast(true) to turn a failure into a failed startup in CI or staging. Full details, including the failure diagnostics, in configuration.md.

startup-ping covers the primary topic only. The optional WARN route has its own independent flag — ntfy grants permissions per topic, so a healthy ERROR route proves nothing about the WARN one:

    .warnTopic("my-app-warnings")
    .startupPingWarn(StartupPingMode.PROBE)

When one route fails and another passed, the diagnosis is published as a notification on the passing route (startup-ping-notify-failures, on by default), so a broken route reaches you rather than only a status log.

The example, in full

The main method of the runnable examples/core app, in full — CI compiles it and its integration tests run against it, so nothing shown here can drift from what is actually tested.

examples/core/src/main/java/com/example/ntfycore/CoreExampleApp.java
  public static void main(String[] args) {
    String url = System.getenv("NTFY_URL");
    String topic = System.getenv("NTFY_TOPIC");
    if (url == null || topic == null) {
      System.err.println("Set NTFY_URL and NTFY_TOPIC to run this example, e.g.");
      System.err.println("  NTFY_URL=https://ntfy.sh NTFY_TOPIC=my-topic java ... CoreExampleApp");
      return;
    }

    // 1. A manual notification through NtfyClient. notify(..) never throws — inspect the result.
    PublishResult result = sendManualNotification(baseConfig(url, topic));
    System.out.println("manual notification: " + (result.success() ? "delivered" : "failed"));

    // 2. Error alerting through AlertEngine, with async (non-blocking) delivery enabled.
    AlertEngine engine = new AlertEngine(asyncConfig(url, topic), new StderrDiagnostics());
    engine.start();
    reportCheckoutFailure(engine);
    // A clean stop drains queued-but-unsent alerts into the storm digest before releasing the
    // HTTP resources, so nothing submitted above can be lost silently.
    engine.stop();
    System.out.println(
        "engine counters: published=" + engine.counters().published()
            + " suppressed=" + engine.counters().suppressed()
            + " failed=" + engine.counters().failed());
  }

Going further

The base config above covers the common case. Everything else is shared across all adapters and lives in the cross-cutting reference pages:

  • Configuration reference — every NtfyConfig / builder setting (types, defaults), ConfigLoader resolution, and duration syntax.
  • Authentication — token vs username/password and the token-wins rule.
  • Alert behavior — immediate alerts, storm suppression, and digests.
  • MDC context — the include-mdc-keys allow-list, its guards, and what a hand-built AlertEvent is and is not filtered by.
  • Level routing — the warn-topic opt-in: which levels alert and where.
  • Filtering — excluded-loggers and the always-on self-exclusion.
  • Troubleshooting — the diagnostics the engine emits.
  • Compatibility — tested JDK / GraalVM versions and the ntfy server API surface.