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 Sender interface. 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 ErrSubscriptionGone sentinel on 410 responses for token-purge logic.
  • Mobile push — FCM HTTP v1 hand-rolled (~150 LOC), with ErrUnregisteredToken sentinel for the standard UNREGISTERED / NOT_FOUND cases.
  • Pluggable store — memory, Postgres, EntDB. One Store contract; all drivers run the same 24-subtest conformance suite.
  • Idempotency built in — every Notify call is keyed by (tenant, user, notification_id). Retries never duplicate deliveries.
  • Per-recipient lifecyclependingdeliveredackedread (or failed) tracked per row.
  • Observability — slog JSON logs, /healthz, /metrics on a separate port.
  • Standalone containercmd/notifyd wires 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
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:latest

The container exposes three ports:

  • 8080NotificationClientService (browser / mobile, Connect HTTP/2)
  • 8081NotificationInternalService (backend producers, gRPC)
  • 9090/healthz and /metrics (Prometheus exposition)

Two services, two audiences

The proto contract carries two services so the auth + transport story cleanly splits the two callers:

ServiceCallerAuth
NotificationInternalServiceBackend producers (your services that send notifications)X-Notify-Internal-Token shared secret
NotificationClientServiceBrowser + 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.

TypeWhere (memory/postgres)Where (entdb)
Notificationnotify_notifications tableUserNotification node, type_id = 1
Devicenotify_devices tableDeviceRegistration 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