NotiFlow App

A production-ready standalone notification service. Deploy it, call the REST API from your backend, and let users connect via WebSocket — no library integration required.

Overview

notiflow-app is a self-contained notification delivery service built on top of notiflow-spring-boot-starter. It exposes a REST API for managing users, topics, API keys, and dispatching notifications, and delivers those notifications to end-user clients over persistent WebSocket connections.

Spring Boot 3.2 + Java 21
Framework
Standard Spring Boot fat JAR, deployable anywhere a JVM runs
PostgreSQL + Liquibase
Persistence
JPA-backed queue, topics, audit log, and user registry — no data lost on restart
JWT + API Keys
Auth
Admin UI via JWT with rotating refresh tokens; machine-to-machine via X-API-Key
Swagger UI
API Explorer
Interactive API documentation at /swagger-ui/index.html
Admin GUI
Built-in + Vue Dashboard
Built-in panel at /admin.html for quick ops; full Vue 3 SPA for complete management

Getting Started

Prerequisites

Build & run

shell
# Start PostgreSQL
docker-compose up -d

# Build
mvn clean package

# Run
java -jar target/notiflow-app-1.0.0-SNAPSHOT.jar

Default endpoints

Resource Default
HTTP port 8080
WebSocket ws://host:8080/ws/notifications
Swagger UI http://localhost:8080/swagger-ui/index.html
OpenAPI JSON http://localhost:8080/v3/api-docs

First-run setup

Liquibase seeds an admin account with setup_complete = false on first boot. You must set the admin password before any authenticated call:

curl
# Check setup status
curl http://localhost:8080/api/auth/status

# Set admin password (min 6 characters)
curl -X POST http://localhost:8080/api/auth/setup \
    -H "Content-Type: application/json" \
    -d '{"password":"changeme"}'

Architecture

component-diagram.txt
External caller (backend service / admin UI)
|
| REST (JWT or X-API-Key)
v
+---------------------------------------+
| Spring Boot REST API |
| AuthController /api/auth/** |
| UserController /api/users/** |
| TopicController /api/topics/** |
| NotifController /api/notif/** |
| ApiKeyController /api/admin/** |
| StatsController /api/stats/** |
+----------------+----------------------+
|
+--------+--------+
| |
v v
AppUserService NotificationService (notiflow-core)
ApiKeyService DefaultNotificationService
JpaTopicService |
| +----+----+
v | |
PostgreSQL JpaNotificationQueue WebSocket sessions
|
+--------+
|
v
AuditingDeliveryTracker (wraps InMemoryDeliveryTracker)
|
v
WebSocket Client ws://host:8080/ws/notifications?token=nfu_<token>

JPA-backed implementations

Interface Implementation Table(s)
NotificationQueue JpaNotificationQueue queued_notifications
TopicService JpaTopicService topic_subscriptions
DeliveryTracker AuditingDeliveryTracker wrapping InMemoryDeliveryTracker notification_audit

Admin Login (JWT + refresh tokens)

Short-lived JWTs for API access paired with long-lived rotating refresh tokens stored as HttpOnly cookies.

curl
# Login — returns access token + sets refresh_token cookie
curl -c cookies.txt -X POST http://localhost:8080/api/auth/login \
    -H "Content-Type: application/json" \
    -d '{"username":"admin","password":"changeme"}'

# Use token on subsequent calls
curl -H "Authorization: Bearer <accessToken>" http://localhost:8080/api/users

# Rotate refresh token
curl -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/auth/refresh

# Logout
curl -b cookies.txt -X POST http://localhost:8080/api/auth/logout
Token Default lifetime Property
Access (JWT) 3600 s (1 h) app.jwt.access-token-expiration
Refresh 2 592 000 s (30 days) app.jwt.refresh-token-expiration

API Key Authentication

Issue keys from the admin panel and pass them via the X-API-Key header. Only the SHA-256 hash is stored — the raw key is shown once at creation.

curl
# Create a key (JWT required)
curl -X POST http://localhost:8080/api/admin/api-keys \
    -H "Authorization: Bearer <jwt>" \
    -H "Content-Type: application/json" \
    -d '{"name":"order-service"}'

# Use it on notification / user / topic / stats endpoints
curl -H "X-API-Key: nf_<key>" http://localhost:8080/api/stats

WebSocket Token Authentication

Each user gets a bearer token (prefixed nfu_) when registered via POST /api/users. Pass it in the WebSocket URL query string. Tokens can be regenerated via POST /api/users/{id}/regenerate-token.

client.js
const ws = new WebSocket('ws://host:8080/ws/notifications?token=nfu_<token>');

On connection WebSocketAuthenticator hashes the token with SHA-256, looks it up in app_users, and stores the user's externalId in the session. Inactive users are rejected with close code 1008 VIOLATED_POLICY.


REST API — Auth /api/auth

Method Path Auth Description
GET /api/auth/status Public Check whether admin setup is complete
POST /api/auth/setup Public Set admin password — one-time, min 6 chars
POST /api/auth/login Public Authenticate; returns accessToken + expiresIn
POST /api/auth/refresh Cookie Rotate refresh token, get new access token
POST /api/auth/logout Cookie Revoke refresh token and clear cookie

REST API — Users /api/users

Requires ROLE_ADMIN (JWT) or ROLE_API_CLIENT (API Key).

Method Path Description
POST /api/users Register user — body: externalId, name. Returns record + raw WebSocket token (shown once)
GET /api/users?page&size Paginated list with online and connectedAt fields
GET /api/users/{id} Single user by internal UUID
PATCH /api/users/{id} Update name or active flag
DELETE /api/users/{id} Delete user permanently
POST /api/users/{id}/regenerate-token Invalidate existing token and issue a new one

REST API — Topics /api/topics

Requires ROLE_ADMIN or ROLE_API_CLIENT. Topics are created implicitly on subscribe.

Method Path Description
GET /api/topics?page&size Paginated list with subscriber counts
GET /api/topics/{topic} Subscriber count for a single topic
GET /api/topics/{topic}/subscribers Paginated subscriber list (with online status)
GET /api/topics/{topic}/subscribers/search?q Search subscribers by query string
POST /api/topics/{topic}/subscribe/{userId} Subscribe user (externalId) to topic — idempotent
DELETE /api/topics/{topic}/subscribe/{userId} Unsubscribe user from topic
DELETE /api/topics/{topic} Remove topic and all its subscriptions
GET /api/topics/user/{userId} All topic names a user is subscribed to

REST API — Notifications /api/notifications

Requires ROLE_ADMIN or ROLE_API_CLIENT. All three endpoints share the same request body.

Request body (NotificationRequest)

Field Type Required Description
title String Yes Notification title
message String Yes Notification body
type String No Application-defined category (e.g. "alert", "info")
priority String No LOW, NORMAL, HIGH, or URGENT. Defaults to NORMAL
Method Path Description
POST /api/notifications/user/{userId} Send to user by externalId. Queued to PostgreSQL if offline.
POST /api/notifications/topic/{topic} Send to all subscribers. Online → WebSocket; offline → queued.
POST /api/notifications/broadcast Send to all currently connected sessions. Offline users do not receive it.
curl
curl -X POST http://localhost:8080/api/notifications/user/user-42 \
    -H "X-API-Key: nf_<key>" \
    -H "Content-Type: application/json" \
    -d '{"type":"order","title":"Order shipped","message":"Your order is on its way","priority":"HIGH"}'

REST API — API Keys /api/admin/api-keys

Requires ROLE_ADMIN (JWT only).

Method Path Description
GET /api/admin/api-keys List all keys. rawKey is always null in list responses.
POST /api/admin/api-keys Create key — body: {"name":"..."}. Response includes rawKey once.
PATCH /api/admin/api-keys/{id} Enable or disable — body: {"active": false}
DELETE /api/admin/api-keys/{id} Delete permanently
POST /api/admin/api-keys/{id}/regenerate-key Issue new key value — old key immediately invalidated

REST API — Stats /api/stats

Requires ROLE_ADMIN or ROLE_API_CLIENT.

Method Path Response
GET /api/stats onlineCount, queueTotal, topicCount
GET /api/stats/sessions?page&size Paginated active sessions: userId, sessionId, connectedAt, subscribedTopics
GET /api/stats/queue?page&size&userId Paginated persisted queue entries
GET /api/stats/queue/{userId} All queued notifications for a user (non-destructive peek)

Notification Flow (End-to-End)

Direct user notification

  1. Caller sends POST /api/notifications/user/{userId}
  2. NotificationController converts the request to a Notification and calls NotificationService.sendToUser(userId, notification)
  3. User online: DefaultNotificationService finds the session in InMemorySessionRegistry, serializes the notification into an Envelope of type NOTIFICATION, and sends it via Session.getBasicRemote().sendText(...)
  4. User offline: JpaNotificationQueue.enqueue(userId, notification) inserts a row into queued_notifications
  5. If ackEnabled = true, AuditingDeliveryTracker.track(...) writes a notification_audit row with sentAt = now()
  6. On reconnect, queued notifications are flushed as QUEUED-type envelopes
  7. Client sends back ACKAuditingDeliveryTracker.acknowledge(...) stamps ackedAt

Topic notification

  1. Caller sends POST /api/notifications/topic/{topic}
  2. JpaTopicService.getSubscribers(topic) returns the set of externalId values
  3. sendToUser is called for each subscriber — online delivers live, offline queues

Broadcast

  1. Caller sends POST /api/notifications/broadcast
  2. SessionRegistry.getAll() is iterated and each live session receives the notification
  3. Offline users do not receive broadcast notifications — no queue entry is created

WebSocket Client Guide

Connecting

client.js
const ws = new WebSocket('ws://host:8080/ws/notifications?token=nfu_<token>');

ws.onmessage = (event) => {
    const { type, id, payload } = JSON.parse(event.data);

    if (type === 'NOTIFICATION' || type === 'QUEUED') {
        console.log(payload.title, payload.message, payload.priority);
        // ACK to stop retries
        ws.send(JSON.stringify({ type: 'ACK', payload: { notificationId: id } }));
    } else if (type === 'PING') {
        ws.send(JSON.stringify({ type: 'PONG' }));
    }
};

// Reconnect with exponential back-off on close / error
ws.onclose = () => setTimeout(connect, backoff());

Message types

Type Direction Description
NOTIFICATION Server → Client Live notification (user was online when sent)
QUEUED Server → Client Notification flushed from offline queue on reconnect
ACK Client → Server Payload: {"notificationId":"<id>"}
PING Server → Client Heartbeat probe (every notiflow.heartbeat.ping-interval)
PONG Client → Server Heartbeat response — must be sent to stay connected
Offline queue TTL: Notifications are retained for notiflow.queue.ttl (default 10m). Reconnecting within that window guarantees delivery. The per-user queue is capped at notiflow.queue.max-per-user (default 50) — oldest entries are dropped when the cap is reached.

Built-in Admin Panel

NotiFlow App ships a lightweight admin panel as a static resource served directly by the application — no separate deployment needed. Open it in a browser once the app is running:

browser
http://localhost:8080/admin.html

The panel is a single vanilla-JS page that communicates with the REST API using the admin JWT cookie from POST /api/auth/login. It provides:

Auth: The built-in panel reads the refresh_token cookie set by POST /api/auth/login. Complete the first-run setup and log in via POST /api/auth/setup + POST /api/auth/login before opening the panel, or it will show an unauthenticated state.

Vue Admin Dashboard

notiflow-admin-dashboard is a standalone Vue 3 + Vite SPA with a full admin interface. It connects to the notiflow-app REST API and provides a richer management experience than the built-in panel.

Features

Overview
OverviewView.vue
Live dashboard: online sessions, queue depth, topic count, recent activity
Users
UsersView.vue
Register, update, delete users; regenerate WebSocket tokens; see online status
Topics
TopicsView.vue
Browse topics, view subscribers, subscribe and unsubscribe users
API Keys
ApiKeysView.vue
Create, enable/disable, and delete API keys for machine-to-machine access
Test Client
TestClientView.vue
In-browser WebSocket client — connect with a user token, receive notifications live, send ACK and PONG

Development

shell
# Install dependencies
cd notiflow-admin-dashboard
npm install

# Start dev server (proxies API calls to notiflow-app on :8080)
npm run dev

# Build for production
npm run build
# Output: dist/ — serve with any static host or nginx

Database Schema

All tables are created and versioned by Liquibase (classpath:db/changelog/db.changelog-master.yaml).

Table Key columns Purpose
admin_account id, username, password_hash, setup_complete Single admin credential record seeded by Liquibase
api_keys id, name, key_hash, key_prefix, active, created_at SHA-256-hashed API keys for machine auth
refresh_tokens id, token_hash, admin_id, expires_at Rotating refresh tokens linked to admin account
app_users id, external_id, name, active, token_hash End-user registry; token_hash authenticates WebSocket connections
topic_subscriptions id, topic, user_id; unique(topic, user_id) Persistent topic subscriptions by externalId
queued_notifications id, user_id, title, message, priority, metadata (jsonb), created_at Durable store for offline users
notification_audit id, user_id, notification_id, sent_at, acked_at, failed_at Delivery audit trail written by AuditingDeliveryTracker

Configuration Reference

application.yaml
server:
  port: 8080

spring:
  datasource:
    url: ${DB_URL:jdbc:postgresql://localhost:5432/notiflow}
    username: ${DB_USERNAME:notiflow}
    password: ${DB_PASSWORD:notiflow}

notiflow:
  web-socket:
    path: '/ws/notifications'
  queue:
    ttl: 10m # offline notification retention
    max-per-user: 50 # oldest dropped when exceeded
  delivery:
    ack-enabled: true
  heartbeat:
    enabled: true
    ping-interval: 30s

app:
  jwt:
    secret: ${JWT_SECRET:change-me-in-production-at-least-32-bytes!!}
    access-token-expiration: 3600 # seconds
    refresh-token-expiration: 2592000 # 30 days
  cors:
    allowed-origins: ${CORS_ORIGINS:http://localhost:5173}

Environment variables

Variable Default Required in production
DB_URL jdbc:postgresql://localhost:5432/notiflow Yes
DB_USERNAME notiflow Yes
DB_PASSWORD notiflow Yes
JWT_SECRET change-me-in-production-at-least-32-bytes!! Yes — min 32 bytes
CORS_ORIGINS http://localhost:5173 Yes (browser clients)

Docker Deployment

Included docker-compose.yaml

The repository includes a compose file that starts both PostgreSQL and the application:

docker-compose.yaml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: notiflow
      POSTGRES_USER: notiflow_user
      POSTGRES_PASSWORD: changeme
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - notiflow-net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U notiflow_user -d notiflow"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true

  app:
    image: dpustula/notiflow-app:latest
    ports:
      - "8080:8080"
    environment:
      DB_URL: jdbc:postgresql://postgres:5432/notiflow
      DB_USERNAME: notiflow_user
      DB_PASSWORD: changeme
      JWT_SECRET: your-secret-key-here
      CORS_ORIGINS: http://localhost:3080
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - notiflow-net
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    # Uncomment to set resource limits:
    # deploy:
    #   resources:
    #     limits:
    #       cpus: '1'
    #       memory: 512M

  dashboard:
    image: dpustula/notiflow-admin-dashboard:latest
    ports:
      - "3080:80"
    depends_on:
      - app
    networks:
      - notiflow-net
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true

networks:
  notiflow-net:
    driver: bridge

volumes:
  postgres-data:
Production checklist: Generate JWT_SECRET with openssl rand -base64 32 — never commit it to source control. Use real database credentials. Terminate TLS at a reverse proxy (nginx / ALB) — the refresh_token cookie has Secure=true and will not transmit over plain HTTP. WebSocket clients must connect via wss://. Restrict CORS_ORIGINS to your actual frontend origin(s).

NotiFlow · Open Source · Built for Java

Work in progress: This documentation is under active development and may change. Some sections might be incomplete or out of date.