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.
X-API-Key
/swagger-ui/index.html/admin.html for quick ops; full Vue 3 SPA for
complete management
Getting Started
Prerequisites
- Java 21 JDK
- Maven (system
mvn) - PostgreSQL 16 — or use the included
docker-compose.yaml - The sibling projects must be installed locally first:
mvn installin bothnotiflow-coreandnotiflow-spring-boot-starter
Build & run
# 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:
# 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
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.
# 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.
# 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.
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 -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
- Caller sends
POST /api/notifications/user/{userId} NotificationControllerconverts the request to aNotificationand callsNotificationService.sendToUser(userId, notification)- User online:
DefaultNotificationServicefinds the session inInMemorySessionRegistry, serializes the notification into anEnvelopeof typeNOTIFICATION, and sends it viaSession.getBasicRemote().sendText(...) - User offline:
JpaNotificationQueue.enqueue(userId, notification)inserts a row intoqueued_notifications - If
ackEnabled = true,AuditingDeliveryTracker.track(...)writes anotification_auditrow withsentAt = now() - On reconnect, queued notifications are flushed as
QUEUED-type envelopes - Client sends back
ACK→AuditingDeliveryTracker.acknowledge(...)stampsackedAt
Topic notification
- Caller sends
POST /api/notifications/topic/{topic} JpaTopicService.getSubscribers(topic)returns the set ofexternalIdvaluessendToUseris called for each subscriber — online delivers live, offline queues
Broadcast
- Caller sends
POST /api/notifications/broadcast SessionRegistry.getAll()is iterated and each live session receives the notification- Offline users do not receive broadcast notifications — no queue entry is created
WebSocket Client Guide
Connecting
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 |
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:
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:
- Live stats — connected user count and queued message total, auto-refreshed every 5 seconds
- Send notifications — broadcast to all, send to a specific user ID, or send to a topic; choose type, title, message, and priority
- Topic management — subscribe a user to a topic, unsubscribe, and browse the current topic list with subscriber counts
- Response log — every API action is logged with its HTTP status and response body for quick debugging
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
Development
# 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
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:
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:
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