Last updated 2026-05-28
Quick Start
Five-minute "Hello, notify": run the container, send a notification from a backend, receive it over SSE in a browser.
1. Run notify locally
Use the memory driver and dev-mode auth — no Postgres, no EntDB, no JWT secret. Never enable dev mode in production.
docker run -d --name notify \ -p 8080:8080 -p 8081:8081 -p 9090:9090 \ -e NOTIFY_AUTH_DEV_MODE=true \ -e NOTIFY_STORE_DRIVER=memory \ ghcr.io/elloloop/notify:latestConfirm it's healthy:
curl -s http://localhost:9090/healthz# {"status":"ok"}2. Send a notification from a backend
Backend producers call the internal service on port
8081. In dev mode the
X-Notify-Internal-Token check is skipped. The request is
plain Connect-JSON over HTTP, so cURL works.
curl -sX POST http://localhost:8081/elloloop.notify.v1.NotificationInternalService/Notify \ -H 'Content-Type: application/json' \ -d '{ "tenantId": "acme", "notificationId": "n-1", "userIds": ["user-alice"], "channels": ["DELIVERY_CHANNEL_IN_APP"], "subjectRef": "task:42", "subjectType": "task", "title": "New comment", "body": "Bob commented on Task 42" }'Response:
{"delivered": 0, "pending": 1, "failed": 0} pending=1 is correct: no client is connected yet, so the
in-app provider has nowhere to deliver. The row is stored and will
surface via GetNotifications or the live stream as soon
as a client subscribes.
3. Subscribe over SSE in the browser
Client connections go to the client service on port
8080. The dev-mode token shape is
Bearer dev:<userid>:<tenant>.
import { createConnectTransport } from "@connectrpc/connect-web";import { createClient } from "@connectrpc/connect";import { NotificationClientService } from "./gen/notify/v1/notify_pb";
const transport = createConnectTransport({ baseUrl: "http://localhost:8080", interceptors: [ (next) => async (req) => { req.header.set("Authorization", "Bearer dev:user-alice:acme"); return next(req); }, ],});const client = createClient(NotificationClientService, transport);
for await (const ev of client.streamEvents({ deviceType: "DEVICE_TYPE_BROWSER" })) { switch (ev.event.case) { case "notification": console.log("notification:", ev.event.value.notification?.title); break; case "heartbeat": console.log("heartbeat @", ev.event.value.timestampMs); break; case "dataChange": console.log("data change:", ev.event.value.subjectRef); break; }}
Now repeat step 2 with a different notificationId: the
open stream prints the title within milliseconds.
4. Page the inbox history
Anything sent while no client was connected is still available via
GetNotifications.
curl -sX POST http://localhost:8080/elloloop.notify.v1.NotificationClientService/GetNotifications \ -H 'Authorization: Bearer dev:user-alice:acme' \ -H 'Content-Type: application/json' \ -d '{"limit": 20}'{ "notifications": [ { "id": "...", "notificationId": "n-1", "subjectRef": "task:42", "subjectType": "task", "title": "New comment", "body": "Bob commented on Task 42", "channel": "DELIVERY_CHANNEL_IN_APP", "status": "DELIVERY_STATUS_PENDING", "createdAtMs": "1748390400000" } ], "nextCursor": "", "unreadCount": 1}5. Mark a notification as read
curl -sX POST http://localhost:8080/elloloop.notify.v1.NotificationClientService/AckNotification \ -H 'Authorization: Bearer dev:user-alice:acme' \ -H 'Content-Type: application/json' \ -d '{"id": "<store-id-from-step-4>"}'
A repeat GetNotifications shows
status: DELIVERY_STATUS_READ,
readAtMs stamped, and unreadCount: 0.
6. Inspect /metrics
curl -s http://localhost:9090/metricsThe endpoint exposes a parseable Prometheus exposition; v0.1 ships a placeholder so scrapers don't 404. Real metric series land in a follow-up wave without changing the route.
Next steps
- Configuration — every environment variable, defaults, validation rules
- JWT Keys — production-grade HS256 secret
- Store Setup — Postgres or EntDB driver
- Send a Notification — Go + Python producer code
- Subscribe over SSE — full browser/mobile flow