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

ResourceDefault
HTTP port8080
WebSocketws://host:8080/ws/notifications
Swagger UIhttp://localhost:8080/swagger-ui/index.html
OpenAPI JSONhttp://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

InterfaceImplementationTable(s)
NotificationQueueJpaNotificationQueuequeued_notifications
TopicServiceJpaTopicServicetopic_subscriptions
DeliveryTrackerAuditingDeliveryTracker wrapping InMemoryDeliveryTrackernotification_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
TokenDefault lifetimeProperty
Access (JWT)3600 s (1 h)app.jwt.access-token-expiration
Refresh2 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

MethodPathAuthDescription
GET/api/auth/statusPublicCheck whether admin setup is complete
POST/api/auth/setupPublicSet admin password — one-time, min 6 chars
POST/api/auth/loginPublicAuthenticate; returns accessToken + expiresIn
POST/api/auth/refreshCookieRotate refresh token, get new access token
POST/api/auth/logoutCookieRevoke refresh token and clear cookie

REST API — Users /api/users

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

MethodPathDescription
POST/api/usersRegister user — body: externalId, name. Returns record + raw WebSocket token (shown once)
GET/api/users?page&sizePaginated 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-tokenInvalidate 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.

MethodPathDescription
GET/api/topics?page&sizePaginated list with subscriber counts
GET/api/topics/{topic}Subscriber count for a single topic
GET/api/topics/{topic}/subscribersPaginated subscriber list (with online status)
GET/api/topics/{topic}/subscribers/search?qSearch 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)

FieldTypeRequiredDescription
titleStringYesNotification title
messageStringYesNotification body
typeStringNoApplication-defined category (e.g. "alert", "info")
priorityStringNoLOW, NORMAL, HIGH, or URGENT. Defaults to NORMAL
MethodPathDescription
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/broadcastSend 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).

MethodPathDescription
GET/api/admin/api-keysList all keys. rawKey is always null in list responses.
POST/api/admin/api-keysCreate 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-keyIssue new key value — old key immediately invalidated

REST API — Stats /api/stats

Requires ROLE_ADMIN or ROLE_API_CLIENT.

MethodPathResponse
GET/api/statsonlineCount, queueTotal, topicCount
GET/api/stats/sessions?page&sizePaginated active sessions: userId, sessionId, connectedAt, subscribedTopics
GET/api/stats/queue?page&size&userIdPaginated 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

TypeDirectionDescription
NOTIFICATIONServer → ClientLive notification (user was online when sent)
QUEUEDServer → ClientNotification flushed from offline queue on reconnect
ACKClient → ServerPayload: {"notificationId":"<id>"}
PINGServer → ClientHeartbeat probe (every notiflow.heartbeat.ping-interval)
PONGClient → ServerHeartbeat 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).

TableKey columnsPurpose
admin_accountid, username, password_hash, setup_completeSingle admin credential record seeded by Liquibase
api_keysid, name, key_hash, key_prefix, active, created_atSHA-256-hashed API keys for machine auth
refresh_tokensid, token_hash, admin_id, expires_atRotating refresh tokens linked to admin account
app_usersid, external_id, name, active, token_hashEnd-user registry; token_hash authenticates WebSocket connections
topic_subscriptionsid, topic, user_id; unique(topic, user_id)Persistent topic subscriptions by externalId
queued_notificationsid, user_id, title, message, priority, metadata (jsonb), created_atDurable store for offline users
notification_auditid, user_id, notification_id, sent_at, acked_at, failed_atDelivery 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

VariableDefaultRequired in production
DB_URLjdbc:postgresql://localhost:5432/notiflowYes
DB_USERNAMEnotiflowYes
DB_PASSWORDnotiflowYes
JWT_SECRETchange-me-in-production-at-least-32-bytes!!Yes — min 32 bytes
CORS_ORIGINShttp://localhost:5173Yes (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: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- notiflow-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
security_opt:
- no-new-privileges:true
app:
image: notiflow-app:latest
ports:
- "${APP_PORT:-8080}:8080"
environment:
DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
DB_USERNAME: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
CORS_ORIGINS: ${CORS_ORIGINS}
depends_on:
postgres:
condition: service_healthy
networks:
- notiflow-net
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
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