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
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:latest

Confirm 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>.

browser.ts
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/metrics

The 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