Agent-first notification service. One Rust binary, Postgres only. Email, SMS, WhatsApp, push, in-app inbox, MCP server built in.
notifyd is deployed once per company, always from this repository, always
from main, through a Dokploy Docker Compose service that points at
./docker-compose.yml. Everything company-specific lives in the Dokploy
environment of that service; nothing company-specific lives in this repo.
No secret value is ever written here, only variable names.
Keys since migration 020. The database stores only SHA-256 hashes of project keys. A lost key cannot be read back from the database: issue a new one with
POST /v1/admin/projects/:id/rotate-key, update the application, thenrevoke-secondary.
main rebuilds every instance from the same commit, and the
pinned Dockerfile makes the resulting binaries identical. /v1/health
exposes commit and built_at_epoch so drift between instances is visible.| Company | Server (Dokploy alias) | Dokploy service | Public URL | Notes |
|---|---|---|---|---|
| CRAIE | CRAIE’s own server (own Dokploy) | compose from this repo, autodeploy main |
notifyd.craie.ctrlnz.com |
Dedicated instance, redeploys within ~5 min of a push to main. |
| sqare + helmai | square | compose novu/notifyd-stack |
notifyd.ctrlnz.com |
Shared by two companies. Global sender is Helmai; the sqare project has its own from_email. Contains a stale craie project (created 2026-06, no key hash, zero jobs): delete it. An orphan Application notifyd/notifyd (2026-03) exists on the same server; its autodeploy is now off, delete it. |
| Philoé | philoe | compose notifyd (git source, autodeploy main via a GitHub webhook on this repo) |
internal http://notifyd:3400; public https://api-os.philoeparis.com/notifyd (path route on the API host, stripPath) for the admin in-app inbox |
Created 2026-09-05. Path route instead of a dedicated hostname because no Cloudflare DNS token was available; switch to notifyd-os.philoeparis.com when one exists. Project philoe, from_email in philoeparis.fr. |
All instances use the same Resend team today (every company domain is
verified there). A per-company Resend team would need a per-instance
RESEND_API_KEY, which this layout already allows.
Keep this table current: it is the only place that says which instance serves which company.
rmzlb/notifyd, branch main, compose path ./docker-compose.yml,
Autodeploy on.openssl rand -hex 32):
POSTGRES_PASSWORD, JWT_SECRET, ADMIN_API_KEY, RESEND_API_KEY (the
company’s Resend team), EMAIL_FROM, EMAIL_FROM_NAME, CORS_ORIGINS
(the company’s back-office origin), PORT=3400, RUST_LOG=notifyd=info.
Optional: RESEND_WEBHOOK_SECRET (only if this instance, and not the
application, is the consumer of the Resend webhooks), push/SMS connector
variables.docker compose attaches the container to dokploy-network under
the alias notifyd, so applications on the same server reach it at
http://notifyd:3400 without leaving the host. Add a public domain in
Dokploy only if browsers must reach it (in-app inbox over SSE).Create the company project, from the server (docker exec into the
container, or any host that can reach the instance):
curl -s -X POST "$NOTIFYD_URL/v1/admin/projects" \
-H "x-api-key: $ADMIN_API_KEY" -H 'content-type: application/json' \
-d '{"id":"<company>","name":"<Company>","channels":["email","in_app"],
"from_email":"<sender@company-domain>","from_name":"<Company>"}'
The response carries the project API key: it goes into the application’s
NOTIFYD_API_KEY, nowhere else.
GET /v1/health returns status: ok, the current commit, and a
fresh built_at_epoch; GET /v1/admin/projects lists the project with its
from_email.craie project. Migration 005_craie_project.sql inserts a
craie project (random plaintext key, no hash, so it cannot authenticate) and
CRAIE templates into every fresh database. On any instance that is not CRAIE’s,
DELETE /v1/admin/projects/craie right after the first boot. The craie
project seen on the square instance is this seed, not a CRAIE integration.git source needs a
push webhook on this repository pointing at
<dokploy>/api/deploy/compose/<refreshToken> (the token is on the compose
record). Dokploy only deploys when the pushed branch matches the configured one.GET /v1/metrics/prometheus (admin key as Authorization: Bearer) exposes
outcomes, provider errors by kind, lane pauses, provider latency, queue depth
and the age of the oldest waiting job per priority band. Point the Dokploy
monitoring stack (or any Prometheus) at each instance. /v1/health carries
commit and built_at_epoch for drift checks.
sqlx::migrate! at
boot on the same push; a destructive migration breaks every company at the
same minute. Add columns and tables, never drop or rename in the same
release as the code that stops using them.from_email sends as the instance default. Always
set from_email and from_name when creating a project.Cargo.lock, never implicitly.NOTIFY and stream tickets live in sse_tickets. Outbound pacing is per
replica: EMAIL_RATE_PER_SEC × replicas must stay under the provider limit
(Resend: 10 req/s per team).