notify-hub
README.md
# notify-hub
[](./LICENSE)
[](https://nodejs.org)
[](https://www.typescriptlang.org)
[](./docker-compose.yml)
[](./clients/mcp/install.md)
[](#contributing)
Self-hosted, free, multi-channel notification gateway. `POST` one message
with a token and it fans out -- asynchronously, via a durable queue -- to
every channel you've enabled: ntfy, Telegram, Email, Slack, Discord,
WhatsApp, or a generic webhook. Built so a [Claude Code](#claude-code-hook)
hook can push you "task started" / "task finished" / "Claude needs you"
notifications across every project, without ever blocking Claude.

*The admin panel -- named channel instances (ntfy, email, slack, telegram,
discord), token profiles with their default channels, and live gateway
status, all editable with zero restart.*
## Why
Claude Code (or any long-running script) leaves you watching a terminal for
minutes with no way to know when it's done or needs input. notify-hub is a
tiny, 100% free, self-hosted service you run once (`docker compose up`) that
turns "send me a push" into a one-line HTTP call, decoupled from delivery by
a Redis-backed queue with retries and per-channel dead-lettering.
## Architecture
```
client (curl / hook script)
-> POST /notify (Bearer token) [Fastify API]
-> enqueue dispatch job [Redis / BullMQ]
-> dispatch worker resolves the profile's default (or requested)
channel instance ids against SQLite [config store]
-> one delivery job per resolved instance [Redis / BullMQ]
-> delivery worker loads that instance's type + config from SQLite
at send time and builds/sends via the type's adapter
(ntfy / telegram / email / slack / discord / whatsapp / webhook)
```
- **API** only validates + enqueues; it never sends inline, so a slow/broken
channel can't make `/notify` hang.
- **Worker** does the actual fan-out and delivery; each channel *instance*
gets its own job so retries/failures are isolated per instance (one
company's Slack going down doesn't block another's).
- **Config lives in SQLite, read fresh at request/delivery time** -- there is
no in-memory snapshot to restart. Add a channel, flip enabled, edit a
webhook URL, or repoint a profile's defaults in the admin panel and the
very next `/notify` call already uses it (hot-reload, no
`docker compose restart`).
- **Channels are typed, instances are named**: the six adapter *types*
(ntfy/telegram/email/slack/discord/webhook) are the pluggable interface
(`send(notification)`); on top of a type you can create any number of
named *instances* -- `acme-slack`, `globex-slack`, `pessoal-discord` --
each with its own id, label and credentials. Profiles pick which instance
ids they route to by default; `POST /notify`'s `channels` field targets
instance ids directly. Adding a brand-new *type* is still a small adapter
file (see the generic `webhook` adapter as the reference example); adding
a new *instance* of an existing type is just a panel click, no code.
## Quickstart
Two ways to get going -- pick whichever fits how you like to configure things.
**Option A -- configure everything in the panel (recommended, no `.env`
needed at all):**
```bash
git clone https://github.com/richardfcampos/notify-hub.git && cd notify-hub
docker compose up -d --build
```
Open `http://localhost:8081`, add a profile (pick a token) and add your
channels, then hit save -- it's live immediately, no restart. `.env` is
entirely optional; an absent one boots fine.
**Option B -- seed from `.env` on first boot** (faster if you already have
credentials handy and want the initial channels/token pre-populated):
```bash
git clone https://github.com/richardfcampos/notify-hub.git && cd notify-hub
./scripts/setup-env.sh # guided setup: prompts for each channel's credentials
# (hidden input), generates your gateway token, writes
# .env with chmod 600. Or do it by hand:
# cp .env.example .env && $EDITOR .env
docker compose up -d --build
```
This only seeds the database on its very first boot (while it's still
empty) -- from then on, `.env` is ignored and every change happens in the
[admin panel](#admin-panel), not by re-editing `.env`.
Either way, confirm the gateway is up:
```bash
curl http://localhost:8080/health
# => {"status":"ok","redis":true}
```
Send a notification:
```bash
curl -X POST http://localhost:8080/notify \
-H "Authorization: Bearer <your-token-from-TOKENS>" \
-H "Content-Type: application/json" \
-d '{"title":"notify-hub","message":"hello from notify-hub"}'
# => 202 {"jobId":"..."}
```
`POST /notify` accepts:
| Field | Required | Notes |
| ----- | -------- | ----- |
| `message` | yes | non-empty string |
| `title` | no | defaults to `"Notification"` |
| `priority` | no | one of `low`, `default`, `high`, `urgent` |
| `tags` | no | string array, passed through to channels that support it (e.g. ntfy) |
| `channels` | no | subset of channel **instance ids** (from `GET /channels`) to target this send to; omit to use the token's profile default instances |
| `metadata` | no | free-form object, passed through to channel adapters (e.g. the `webhook` channel) |
Responses: `202 {jobId}` (enqueued) · `400` (invalid body / unknown channel
instance id) · `401` (missing/unknown token) · `503` (queue unreachable, so
the caller never hangs).
`GET /channels` (same Bearer auth) lists every configured instance and the
calling token's profile defaults:
```json
{
"channels": [
{ "id": "acme-slack", "label": "Acme Slack", "type": "slack", "enabled": true },
{ "id": "ntfy", "label": "Ntfy", "type": "ntfy", "enabled": true }
],
"defaultChannels": ["ntfy"]
}
```
## Configuration
**Channels and token profiles live in SQLite and are managed live in the
[admin panel](#admin-panel)** (`http://127.0.0.1:8081`) -- add, edit, enable/
disable, or delete a channel instance, or change a profile's default
instances, and it takes effect on the very next `/notify` call. There is no
"apply" or restart step.
`.env` (see [`.env.example`](./.env.example)) only carries two kinds of
vars now:
- **Infra**: `PORT` (API listen port), `REDIS_URL`, `DB_PATH` (SQLite file
location; compose points this at a named volume so it survives container
recreates), `RETRY_ATTEMPTS`/`RETRY_BACKOFF_MS`, `ADMIN_PORT`/`ADMIN_BIND`.
- **First-boot seed**: `TOKENS` and `CHANNELS_ENABLED` plus each channel's
credential vars (`NTFY_URL`, `SLACK_WEBHOOK_URL`, etc.) are read **once**,
only when the service starts against an **empty** database -- each
enabled channel becomes a named instance (id = its type, e.g. `slack`),
and `TOKENS` entries become profiles. Once the database has any channel
in it, these vars are ignored entirely; from then on, manage everything
in the panel. Missing credentials for an otherwise-enabled seed channel
seed it as a *disabled* instance rather than blocking boot -- fix its
config in the panel and flip it on.
## Telemetry
notify-hub includes optional, anonymous, usage telemetry that is **off by
default** on every install -- it only turns on if you explicitly opt in via
`./scripts/setup-env.sh` or by hand-setting `TELEMETRY_ENABLED=true`. See
[TELEMETRY.md](./TELEMETRY.md) for the exact field list, why it exists, and
both ways to opt in or out.
## Channels
Each row is the config keys a channel **instance** of that type needs (set
per-instance in the admin panel; the same keys the seed reads from `.env`
on first boot).
| Type | Config keys | Setup notes |
| ---- | ----------- | ----------- |
| `ntfy` | `NTFY_URL`, `NTFY_TOPIC` | Use `https://ntfy.sh` (public) or your own self-hosted ntfy server; subscribe to the topic in the ntfy app |
| `telegram` | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | Create a bot via [@BotFather](https://t.me/BotFather); get your chat id by messaging the bot then hitting `getUpdates` |
| `email` | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_TO` | Any SMTP provider (Gmail app password, SendGrid, etc.) |
| `slack` | `SLACK_WEBHOOK_URL` | Slack app -> Incoming Webhooks -> add to a channel |
| `discord` | `DISCORD_WEBHOOK_URL` | Server channel settings -> Integrations -> Webhooks |
| `whatsapp` | `WHATSAPP_PHONE`, `WHATSAPP_APIKEY` | Free personal API via [CallMeBot](https://www.callmebot.com/blog/free-api-whatsapp-messages/) -- message their bot to activate, rate-limited |
| `voicemonkey` | `VOICEMONKEY_TOKEN`, `VOICEMONKEY_DEVICE` | Makes an Echo device speak the notification out loud via [Voice Monkey](https://voicemonkey.io) -- sign up, link your Echo as a device in their console, create an Announcement-capable "monkey" for it, and copy its API token |
| `local-tts` | `LOCAL_TTS_URL`, `LOCAL_TTS_VOICE` | Speaks the notification out loud through a speaker on a host machine (e.g. this Mac) via macOS `say` -- run the standalone player first, see [`clients/local-tts-player/install.md`](./clients/local-tts-player/install.md); `LOCAL_TTS_VOICE` renders as a live dropdown of installed voices in the panel once `LOCAL_TTS_URL` is filled in |
| `webhook` | `WEBHOOK_URL` | Reference extensibility adapter: POSTs the full notification JSON to any URL you control (Gotify, a custom listener, etc.) |
`voicemonkey` is the practical way to get spoken Alexa notifications: the
official Amazon Proactive Events API was evaluated separately and only
supports 8 fixed light/banner schemas -- no arbitrary spoken text -- so
this third-party webhook is the recommended integration for "Alexa says
it out loud" use cases.
You can create **any number of named instances per type** -- e.g. an
`acme-slack` and a `globex-slack`, each with its own webhook URL -- from the
"Add channel" flow in the admin panel: pick the type, give it an id (slug)
and a label, fill in that type's config keys, save. Each profile then picks
which instance ids it defaults to.
Adding a brand-new channel **type**: implement the `NotificationChannel`
interface (one `send()` method) in `src/channels/adapters/`, export a
`ChannelRegistryEntry` (factory + required config keys), and add one line to
`src/channels/channel-registry.ts`. No other core changes needed -- the
admin panel picks up new types automatically via `GET /api/channel-types`.
## Local TTS (your own speaker)
`local-tts` speaks a notification out loud through a speaker attached to a
host machine you own (e.g. this Mac), using macOS's built-in `say` command
-- zero cost, zero request quota, zero Amazon/third-party account
dependency (contrast with the `voicemonkey` row above, which needs an
account, a linked Echo device, and is rate-limited).
The catch: Docker Desktop for Mac gives containers no access to the host's
CoreAudio subsystem, so `say` can't run inside the `worker` container. A
small standalone player (`clients/local-tts-player/`) runs directly on the
host instead, outside Docker -- either as a plain foreground process
(fast path, no persistence) or via a `launchd` agent for auto-start on
login/reboot; see
[`clients/local-tts-player/install.md`](./clients/local-tts-player/install.md)
for both paths, plus a **known, unresolved troubleshooting caveat**: the
`launchd` agent has failed to actually start (`EX_CONFIG`, exit 78) on at
least one real machine in this project's own history, for a cause not yet
confirmed. notify-hub's worker reaches the player over
`http://host.docker.internal:8082` -- the standard Docker Desktop for Mac
mechanism for routing a container back to a host-bound loopback service
(this only works when the player and the `docker compose` stack are on
the *same* machine -- see the install guide for why a remote speaker isn't
supported out of the box).
## Claude Code hook
A zero-dependency hook script pushes rich "end" (project, times, duration,
status, headline) and "needs-input" events to notify-hub globally, across
every Claude Code project -- zero-setup via a `~/.config/notify-hub/hook.env`
config file (env vars still supported and take precedence). See
[`clients/claude-code/install.md`](./clients/claude-code/install.md) for the
full setup (settings.json snippet + config options).
## MCP server
notify-hub ships as an MCP (Model Context Protocol) server over two
transports: **stdio** (three send tools -- `send_notification`,
`list_channels`, `check_gateway_health` -- for Claude Code, Claude Desktop,
or any MCP client that spawns a process) and **Streamable HTTP at `/mcp` on
the admin service** (the same three tools plus seven config management
tools -- create/edit/delete channels and profiles, test-send, status -- for
MCP gateways like mcp-manager that register servers by URL). Both are thin
clients of the already-running gateway/admin service (no direct Redis/DB
access from the stdio transport). See
[`clients/mcp/install.md`](./clients/mcp/install.md) for the full setup
(`claude mcp add` command, generic `mcpServers` JSON config, and gateway
registration URL).
## Admin panel
A local, dark-themed dashboard for managing everything above directly in
SQLite -- no `.env` hand-editing, no restart:
- **Named channel instances**: add any number of instances of a type (e.g.
`acme-slack` + `globex-slack`, both type `slack`, each its own webhook) --
pick a type from the live `GET /api/channel-types` list, give it an id
(slug) + label, fill in that type's credentials (masked by default,
revealed with one click), toggle enabled, or delete it.
- Manage token profiles: add/remove, edit the token, and pick each
profile's default channel **instances** by label (chips).
- **Save** validates (dup id, unknown type, an enabled instance missing a
required key, a profile referencing a disabled/missing instance) and
writes straight to the DB as an upsert+delete diff against the current
state -- **live for the very next `/notify` call, no `docker compose`
apply/restart step.**
- **Send test** per instance posts a real notification and shows the actual
delivery outcome (✅ sent, or the real failure reason ❌), not just
"enqueued".
- Live gateway status (health, redis, configured instances) and a tail of
recent worker deliveries.
Comes up automatically as part of the stack -- no extra step:
```bash
docker compose up -d
# => http://127.0.0.1:8081
```
`npm run admin` still works as a host-side dev alternative (no Docker
rebuild needed while iterating on the panel itself):
```bash
npm run admin
# => admin panel: http://127.0.0.1:8081
```
**Reachability:** in compose the host-side bind defaults to `0.0.0.0`
(like the other services on a typical homelab host), so the panel is
reachable from your other devices -- e.g. over a Tailscale tailnet as
`http://<machine>:8081`. **The panel has no auth and displays secrets**,
so anyone who can reach the port can read and rewrite your config: keep
that surface to networks you trust (a WireGuard/Tailscale tailnet is a
good fit; an untrusted LAN is not). Set `ADMIN_BIND=127.0.0.1` in `.env`
to make it localhost-only. The explicit bind template is asserted by
`src/admin/compose-invariants.test.ts`. The host-side dev mode
(`npm run admin`) binds `127.0.0.1` by default regardless.
**Docker-socket trade-off:** config CRUD never touches Docker -- a Save
writes straight to the shared SQLite volume and is live immediately. The
`admin` service still mounts `/var/run/docker.sock` (plus a read-only copy
of `docker-compose.yml`) for one narrower purpose: the status page and
"Send test" outcome both tail `docker compose logs worker` to show real
delivery results instead of just "enqueued". This gives the admin
container control of the host's Docker daemon, the same pattern used by
tools like Portainer, scoped in code to log reads only (no compose
up/down/restart is ever invoked). Accepted because the panel is a personal
tool on a trusted network -- combined with the reachability note above,
treat "who can open the panel" as "who can administer this Docker host".
## Surviving a reboot (macOS)
Every container in `docker-compose.yml` already sets `restart:
unless-stopped`, but that alone isn't enough on macOS: it only reactivates
containers once the Docker daemon comes back, and Docker Desktop itself
doesn't always relaunch on login even when its own "start on login"
preference is checked (its internal `settings-store.json` can say
`AutoStart: true` while the system-level login-item registration is
silently missing -- check with `sfltool dumpbtm | grep -i docker`). A
forced Docker Desktop restart was also observed leaving containers in an
`Exited (0)` state instead of auto-recovering, purely from the VM
Desktop runs its daemon inside.
`scripts/docker-autostart.sh` is the deterministic fix: it opens Docker
Desktop, polls until the daemon actually responds, then explicitly runs
`docker compose up -d`. Wire it to a `launchd` user agent so it runs on
every login:
```bash
mkdir -p ~/.local-scripts
cp scripts/docker-autostart.sh ~/.local-scripts/notify-hub-docker-autostart.sh
chmod +x ~/.local-scripts/notify-hub-docker-autostart.sh
```
```xml
<!-- ~/Library/LaunchAgents/com.notify-hub.docker-autostart.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.notify-hub.docker-autostart</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/YOU/.local-scripts/notify-hub-docker-autostart.sh</string>
</array>
<key>RunAtLoad</key><true/>
</dict></plist>
```
```bash
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.notify-hub.docker-autostart.plist
```
**If this repo lives on an external/Thunderbolt volume** (not the boot
disk), copy `docker-autostart.sh` itself to `~/.local-scripts/` too (as
shown above) rather than pointing the plist at the repo's own copy --
`launchd`-spawned processes were found to get `Operation not permitted`
reading a script located on a non-boot volume, a macOS TCC restriction
that doesn't affect the same command run from an interactive Terminal.
The script's own `cd` into the repo at runtime is unaffected; only the
script's *own* file location has to be on the boot disk. The
[local TTS player](#local-tts-your-own-speaker)'s own `launchd` setup
hits the identical restriction -- see
[`clients/local-tts-player/install.md`](./clients/local-tts-player/install.md#troubleshooting)
for the same fix applied there.
## Development
```bash
npm install
npm run build # tsc -> dist/bin/{api,worker,admin}.js, copies
# src/admin/ui -> dist/admin/ui (static UI assets)
npm run test # full suite -- REQUIRES Docker (spins up redis:7-alpine
# via testcontainers for the BullMQ retry/dead-letter
# integration test); set REDIS_TEST_URL to reuse a
# running Redis instead of a container
npm run test:unit # no-Docker fast subset (src unit tests only)
npm run test:integration # just the Redis-backed queue integration test
npm run dev:api # tsx, no build step
npm run dev:worker
```
## Verified
The full 4-service stack (`redis`, `api`, `worker`, `admin`) has been
smoke-tested end-to-end via `docker compose up -d --build` plus `curl`/the
admin panel and worker log inspection, covering:
- **Boot & health**: `docker compose ps` shows all 4 services up, `api`
healthcheck `healthy`; `curl http://localhost:8080/health` returns
`200 {"status":"ok","redis":true}`.
- **Delivery**: `POST /notify` against a real channel (ntfy.sh) returns
`202 {jobId}`, and the worker log shows the send/success pair
(`"sending notification"` -> `"notification sent"`) with the message
confirmed as received on the channel side.
- **SQLite-backed hot-reload**: adding or editing a channel through the
admin panel (or the MCP config tools) takes effect on the very next
`/notify` call with no `docker compose restart` -- verified by adding a
channel instance live and sending immediately after.
- **Claude Code hook**: the zero-dependency hook script's rich "end"/
"needs-input" payloads (project, duration, status, headline) arrive
correctly, and the idle debounce suppresses duplicate notifications
during a burst of tool calls.
If your environment can't reach the public internet (ntfy.sh), point
`NTFY_URL` at a self-hosted ntfy instance instead and repeat the same smoke
steps.
## Contributing
Contributions are welcome — the most useful one is a new channel adapter.
Each channel is a small self-contained file implementing one interface:
1. Add `src/channels/adapters/<name>-channel.ts` implementing
`NotificationChannel` (a single `send(notification)` method) plus its
`ChannelRegistryEntry` (factory + required env keys).
2. Register it with one line in `src/channels/channel-registry.ts`.
3. Add unit tests next to it (happy path + error path, using the fakes in
`test/helpers/fakes.ts` — no real network in tests).
4. `npm run test:unit` must pass; open a PR.
Ideas: Gotify, Matrix, Pushover, Signal, Mattermost, Rocket.Chat, SMS
gateways. Bug reports and docs fixes are equally appreciated —
[open an issue](https://github.com/richardfcampos/notify-hub/issues).
## License
[MIT](./LICENSE) © Richard Campos
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues