Skip to main content
Glama
richardfcampos

notify-hub

notify-hub

License: MIT Node.js >= 20 TypeScript Docker Compose MCP PRs Welcome

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 hook can push you "task started" / "task finished" / "Claude needs you" notifications across every project, without ever blocking Claude.

notify-hub admin panel 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.

Related MCP server: Trello Universal MCP Server

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

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

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, not by re-editing .env.

Either way, confirm the gateway is up:

curl http://localhost:8080/health
# => {"status":"ok","redis":true}

Send a notification:

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:

{
  "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 (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) 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 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; 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 -- message their bot to activate, rate-limited

voicemonkey

VOICEMONKEY_TOKEN, VOICEMONKEY_DEVICE

Makes an Echo device speak the notification out loud via Voice Monkey -- 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; 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 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 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 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:

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

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:

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
<!-- ~/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>
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's own launchd setup hits the identical restriction -- see clients/local-tts-player/install.md for the same fix applied there.

Development

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.

License

MIT © Richard Campos

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • A Model Context Protocol server for Wix AI tools

  • A comprehensive Model Context Protocol (MCP) server that enables AI assistants to interact with yo…

  • Connect AI agents to Replynodes over the Model Context Protocol.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/richardfcampos/notify-hub'

If you have feedback or need assistance with the MCP directory API, please join our Discord server