Are you the author? Sign in to claim
Self-hosted multi-channel notification gateway (Docker + Redis queue): token-auth API fans one message out to ntfy, Tele
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.
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.
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.
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)
/notify hang./notify call already uses it (hot-reload, no
docker compose restart).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.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"]
}
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:
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.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.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.
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 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).
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).
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).
A local, dark-themed dashboard for managing everything above directly in
SQLite -- no .env hand-editing, no restart:
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./notify call, no docker compose
apply/restart step.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".
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.
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
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:
docker compose ps shows all 4 services up, api
healthcheck healthy; curl http://localhost:8080/health returns
200 {"status":"ok","redis":true}.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./notify call with no docker compose restart -- verified by adding a
channel instance live and sending immediately after.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.
Contributions are welcome — the most useful one is a new channel adapter. Each channel is a small self-contained file implementing one interface:
src/channels/adapters/<name>-channel.ts implementing
NotificationChannel (a single send(notification) method) plus its
ChannelRegistryEntry (factory + required env keys).src/channels/channel-registry.ts.test/helpers/fakes.ts — no real network in tests).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.
MIT © Richard Campos
Run Claude Code as an MCP server so any agent can delegate coding tasks to it
Browser automation using accessibility snapshots instead of screenshots
Google's universal MCP server supporting PostgreSQL, MySQL, MongoDB, Redis, and 10+ databases
Official GitHub integration for repos, issues, PRs, and CI/CD workflows