Last updated 2026-05-28
notify
Multi-channel notification platform as a single, deployable container. Pull a pinned image, point it at a durable store (memory · Postgres · EntDB), configure one or more channel providers, and you have in-app real-time SSE, email, web push, mobile push, SMS and WhatsApp delivery — all over Connect-RPC (HTTP/JSON) and gRPC.
What you get
- In-app real-time — SSE/Connect stream of new notifications, data-change hints and heartbeats over one connection. Optional subsystem; turn it off and history still works.
- Email — pluggable provider via a narrow
Senderinterface. Ships with the elloloop EmailService backend; slots for SES, ACS, SMTP, SendGrid. - SMS + WhatsApp — Twilio, with the WhatsApp channel automatically applying the
whatsapp:prefix. - Web Push — VAPID (RFC 8292) with
ErrSubscriptionGonesentinel on 410 responses for token-purge logic. - Mobile push — FCM HTTP v1 hand-rolled (~150 LOC), with
ErrUnregisteredTokensentinel for the standard UNREGISTERED / NOT_FOUND cases. - Pluggable store — memory, Postgres, EntDB. One
Storecontract; all drivers run the same 24-subtest conformance suite. - Idempotency built in — every
Notifycall is keyed by(tenant, user, notification_id). Retries never duplicate deliveries. - Per-recipient lifecycle —
pending→delivered→acked→read(orfailed) tracked per row. - Observability — slog JSON logs,
/healthz,/metricson a separate port. - Standalone container —
cmd/notifydwires it all from environment variables; library mode is the same code, minus the wiring.
How it deploys
One container, three listeners. The deployment shape is the same as any versioned service image: pull a pinned tag, run one replica per environment, point it at a durable store. The container is stateless above the store, so rolling restarts are safe and zero-downtime upgrades are a single image swap. Auth is JWT (HS256) against a shared secret — the container validates tokens locally and never calls back into the consumer that produced them.
docker run --rm \ -p 8080:8080 -p 8081:8081 -p 9090:9090 \ -e NOTIFY_STORE_DRIVER=memory \ -e NOTIFY_AUTH_JWT_SECRET=$(openssl rand -hex 32) \ -e NOTIFY_INTERNAL_TOKEN=$(openssl rand -hex 32) \ -e NOTIFY_EMAIL_PROVIDER=none \ ghcr.io/elloloop/notify:latestThe container exposes three ports:
8080—NotificationClientService(browser / mobile, Connect HTTP/2)8081—NotificationInternalService(backend producers, gRPC)9090—/healthzand/metrics(Prometheus exposition)
Two services, two audiences
The proto contract carries two services so the auth + transport story cleanly splits the two callers:
| Service | Caller | Auth |
|---|---|---|
NotificationInternalService | Backend producers (your services that send notifications) | X-Notify-Internal-Token shared secret |
NotificationClientService | Browser + mobile clients (recipients reading + streaming) | Authorization: Bearer <JWT> |
Storage layout
All durable state — notification rows and device registrations —
lives in the configured store. Memory is the in-process reference
used for tests and dev; Postgres uses pgx; EntDB
targets the v2 schema-aware mode (ADR-031) and rides server-enforced
composite-key uniqueness.
| Type | Where (memory/postgres) | Where (entdb) |
|---|---|---|
| Notification | notify_notifications table | UserNotification node, type_id = 1 |
| Device | notify_devices table | DeviceRegistration node, type_id = 2 |
notify runs its own EntDB instance — the type_id values
above are local to that instance, so they co-exist cleanly with any
other tenants sharing the EntDB server.
Where to next
- Quick Start — run the container and send your first notification end-to-end
- Architecture — how the pieces fit together
- Configuration — every
NOTIFY_*variable - API Reference — every RPC, request, response, status code
- Send a Notification — Go, Python, cURL recipes