Coding Projects

Send Push Notifications From Java

Java has had everything you need since 11. One HttpClient call sends a push notification to your phone, with no dependency added to the build.

Red and black halftone illustration: a stovetop coffee pot on a dark burner, steam rising in a thin column (TheNotificationApp)

The batch job runs at 02:00. It reads a few hundred thousand rows, does something expensive to each one, and writes the results somewhere. When it works, nobody notices. When it fails, nobody notices either, until a report is wrong on Thursday and someone traces it back to Tuesday night.

Java has had everything needed to fix that since version 11, in the standard library, with no dependency added to your build.

No dependencies required

java.net.http.HttpClient arrived in Java 11 and does everything here. That matters more in Java than in most languages, because adding a dependency to an enterprise build is a conversation, and this is a feature that should not require one.

You do want a JSON library for building the body. Most projects already have Jackson or Gson on the classpath. If yours genuinely has neither, there is a hand-rolled escape at the end.

Step 1: Get your app key

Install the app, sign in with Apple, create an application called Batch, and copy its app_key. Put it in the environment rather than in a properties file that ends up in the repository:

export TNA_APP_KEY="your_app_key_here"

Step 2: The notifier

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public final class PhoneNotifier {

    private static final String URL = "https://thenotification.app/api/sendNotification";
    private static final ObjectMapper MAPPER = new ObjectMapper();

    private final HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    private final String appKey;

    public PhoneNotifier(String appKey) {
        this.appKey = appKey;
    }

    /** Sends a notification. Never throws: a failed alert must not fail the job. */
    public void notify(String title, String body, String link) {
        try {
            ObjectNode payload = MAPPER.createObjectNode();
            payload.put("title", title);
            payload.put("body", truncate(body, 180));
            if (link != null && !link.isBlank()) {
                payload.put("link", link);
            }

            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(URL))
                    .header("app_key", appKey)
                    .header("Content-Type", "application/json")
                    .timeout(Duration.ofSeconds(10))
                    .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(payload)))
                    .build();

            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString());

            if (response.statusCode() >= 400) {
                System.err.println("[notify] " + response.statusCode() + " " + response.body());
            }
        } catch (Exception e) {
            System.err.println("[notify] failed: " + e.getMessage());
            Thread.currentThread().interrupt();
        }
    }

    private static String truncate(String s, int max) {
        return s == null ? "" : s.length() <= max ? s : s.substring(0, max);
    }
}

Two details worth the extra lines. The method never throws, because this runs at the end of a job that may have taken an hour and an alerting failure must not become the job's failure. And the timeouts are explicit: HttpClient waits indefinitely by default, so a hung connection would hang your batch job at the very last step.

The Thread.currentThread().interrupt() is there because client.send throws InterruptedException, and swallowing an interrupt without restoring the flag is the sort of thing that causes a strange bug three months later.

Step 3: Wrap the job

public static void main(String[] args) {
    PhoneNotifier notifier = new PhoneNotifier(System.getenv("TNA_APP_KEY"));
    long started = System.currentTimeMillis();

    try {
        int rows = new NightlyImport().run();
        long seconds = (System.currentTimeMillis() - started) / 1000;
        notifier.notify(
                "Nightly import finished",
                rows + " rows in " + seconds + "s",
                "https://dashboard.example.com/imports");
    } catch (Exception e) {
        notifier.notify(
                "Nightly import FAILED",
                e.getClass().getSimpleName() + ": " + e.getMessage(),
                null);
        throw e;
    }
}

Rethrow after notifying. You want the alert and the stack trace and the non-zero exit code, not the alert instead of them.

The exception class name in the body is what makes the notification worth reading. "Import failed" sends you to a terminal. "SQLTransientConnectionException: connection is not available" tells you the pool is exhausted and you can decide from the lock screen whether it needs you now.

Spring Boot

In a Spring application, make it a bean and let the framework supply the key:

@Component
public class NotificationService {

    private final RestClient client;

    public NotificationService(@Value("${tna.app-key}") String appKey) {
        this.client = RestClient.builder()
                .baseUrl("https://thenotification.app/api")
                .defaultHeader("app_key", appKey)
                .build();
    }

    public void send(String title, String body) {
        try {
            client.post()
                  .uri("/sendNotification")
                  .contentType(MediaType.APPLICATION_JSON)
                  .body(Map.of("title", title, "body", body))
                  .retrieve()
                  .toBodilessEntity();
        } catch (Exception e) {
            LoggerFactory.getLogger(getClass()).warn("notification failed", e);
        }
    }
}

With tna.app-key in your configuration, sourced from an environment variable. Then inject it wherever a scheduled task or a queue listener finishes something worth knowing about.

Firing it from a scheduled task

Most Java jobs that deserve a notification are on a schedule, and Spring's @Scheduled is where they usually live. The pattern is the same, with one addition worth making: report the job that did not run at all.

@Component
public class ReconciliationJob {

    private final NotificationService notifications;
    private final ReconciliationService reconciliation;

    ReconciliationJob(NotificationService notifications, ReconciliationService reconciliation) {
        this.notifications = notifications;
        this.reconciliation = reconciliation;
    }

    @Scheduled(cron = "0 0 2 * * *")
    public void runNightly() {
        Instant started = Instant.now();
        try {
            Result result = reconciliation.run();
            if (result.mismatches() > 0) {
                notifications.send(
                        "Reconciliation found " + result.mismatches() + " mismatches",
                        "Checked " + result.checked() + " records in "
                                + Duration.between(started, Instant.now()).toSeconds() + "s");
            }
        } catch (Exception e) {
            notifications.send("Reconciliation FAILED",
                    e.getClass().getSimpleName() + ": " + e.getMessage());
            throw e;
        }
    }
}

Note the condition on the success path. A clean reconciliation sends nothing, because "everything was fine" is not news and a nightly notification saying so is the fastest way to train yourself to ignore the channel. Only the mismatch and the failure are worth a buzz.

That leaves one hole: if the scheduler itself stops, you get silence, which looks exactly like a clean run. A heartbeat check covers it, and the general shape of that is in getting notified when a cron job fails.

If you really have no JSON library

Do not concatenate strings. An exception message containing a double quote produces a malformed body and a 400, and it will happen on the run that mattered:

private static String jsonEscape(String s) {
    StringBuilder out = new StringBuilder(s.length() + 16);
    for (char c : s.toCharArray()) {
        switch (c) {
            case '"'  -> out.append("\\\"");
            case '\\' -> out.append("\\\\");
            case '\n' -> out.append("\\n");
            case '\r' -> out.append("\\r");
            case '\t' -> out.append("\\t");
            default   -> out.append(c < 0x20 ? String.format("\\u%04x", (int) c) : c);
        }
    }
    return out.toString();
}

The honest part

The free tier is 100 notifications for the lifetime of the account, not per month. One per batch run is comfortable for a long time. One per processed record is not, so notify on the job rather than inside the loop.

Pro is $2.99 a month for 1,000 notifications. And this reaches iPhone only, because it rides Apple's push service.

Where this fits

The request is identical in every language: two headers, four fields, one endpoint. The same call from Python is in sending a push notification from Python, and the field list is in the API reference.

Grab a free key at thenotification.app and let the 2am job tell you how it went.

New to this? Start with what a push notification API actually is.