Alphorn: self-hosted notification router for webhooks

Who isn’t tired of notifications that are spread over a dozen places? The backup script sends an email, the uptime monitor pings Telegram, the CI pipeline posts to Slack, and the contact form on the website sends… well, nothing, because nobody configured it. That’s why I built Alphorn – a self-hosted notification router that receives a webhook and sends the message to the channels where you actually want it.

In this blog post, I’ll explain what Alphorn is, how it works, and how to send your first notification in a few minutes – hosted on alphorn.dev or self-hosted on your own server. I’ll also show how I use it for backups, scripts and AI agents. Let’s get started.

What is Alphorn?

Alphorn is an open source notification router. You get a webhook URL, every service that can send an HTTP POST request calls that URL, and Alphorn delivers the message to one or multiple channels: Slack, Discord, Microsoft Teams, Telegram, Matrix, Mattermost, email (SMTP, SendGrid, Mailgun), SMS (Twilio, Vonage), push (ntfy, Gotify, Pushover), PagerDuty, Opsgenie, a generic webhook and many more – in total more than 20 channels.

Between the webhook and the channels there are filter rules. They decide which message goes where, e.g. “everything to Slack, but only priority 4 and 5 to Telegram and email”. If a channel is down, then Alphorn retries the delivery automatically, and you can see the full delivery history in the dashboard.

Some facts at a glance:

  • License: AGPL-3.0-or-later (open source), with a commercial license available
  • Hosted version: app.alphorn.dev, with a free tier (3,000 messages per month, 3 webhooks, 3 channels)
  • Self-hosted: Docker Compose, Podman or from source – the only dependency is PostgreSQL 18
  • Stack: Next.js, PostgreSQL and pg-boss as queue (no Redis, no separate message broker)
  • Reliability: up to 5 delivery attempts with exponential backoff
  • No SDK needed: it’s a plain HTTP POST, so curl, Powershell, Python, Go or any monitoring tool works

Why I built a notification router

In my case, the problem was not one big system, but a lot of small ones. Backup jobs, cron jobs, uptime checks, deployments, contact forms, and lately also AI coding agents that run for 20 minutes and then wait for my decision. Every tool has its own way to notify you, and every time I wanted to change something (e.g. move alerts from Telegram to Matrix), I had to touch the tool, the script, or the server again.

So the idea of Alphorn is simple: the sender only knows one URL. Where the message ends up is configured in one place – and if I want to change it, then I change it in Alphorn and not in 15 scripts. Simple and clean!

How does Alphorn work?

The flow has three parts:

  • Webhooks: the entry point. Each webhook has a unique URL like https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT and accepts JSON, plain text, or Slack-compatible payloads.
  • Channels: the destinations – a Slack workspace, a Telegram bot, an SMTP server, a phone number for SMS, and so on.
  • Filters: rules on the link between a webhook and a channel. You can filter by priority, tags, title, message, or any field of the JSON payload (also with regex).

When a request arrives, Alphorn stores it, answers with “201 Created” immediately, and puts the delivery into a queue. A worker picks up the job and sends it to every channel whose filter matches. That’s why the sender doesn’t have to wait for Slack or Telegram, and a slow channel doesn’t block your script.

Step 1: Choose hosted or self-hosted

You can use Alphorn in two ways:

  • Hosted: sign up at app.alphorn.dev and you can send your first notification right away. The free tier is enough for most personal setups.
  • Self-hosted: run it on your own server. All features are available, there are no message limits, and your data stays on your infrastructure.

If you want to self-host it, then the following docker-compose.yml is everything you need:

services:
  alphorn:
    image: ghcr.io/alphorn-dev/alphorn:latest
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://alphorn:secret@db:5432/alphorn
      # generate with: openssl rand -base64 32
      BETTER_AUTH_SECRET: your-secret-key
      # public url of your instance - https in production!
      BETTER_AUTH_URL: http://localhost:3000
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: alphorn
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: alphorn
    volumes:
      # postgres 18 stores data under /var/lib/postgresql (not /data anymore)
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U alphorn"]
      interval: 5s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:

Start it with the following command and open http://localhost:3000 to create your first account:

docker compose up -d

Note: Alphorn needs HTTPS for logins (except on localhost). For production, put it behind a reverse proxy like Caddy, Nginx or Traefik and set BETTER_AUTH_URL to the public https URL.

Attention! PostgreSQL 18 is required – Alphorn uses the built-in uuidv7() function, which doesn’t exist in older versions.

Step 2: Add a channel

After the login, the first thing to configure is a channel:

  • Navigate to “Channels” and click “Add channel”
  • Select the type, e.g. “Telegram” or “Slack”
  • Give it a speaking name, e.g. “telegram-armin” or “slack-alerts”
  • Enter the credentials of the channel (bot token, webhook URL, SMTP server, …) and save it

I recommend to send a test message directly from the channel page, so you know that the channel works before you connect anything to it.

Step 3: Create a webhook

Next step is to create the webhook:

  • Navigate to “Webhooks” and click “Create webhook”
  • Give it a name, e.g. “Server Alerts”
  • Select the channels it should deliver to
  • Switch on “API key required” if the sender can send custom headers
  • Create it and copy the API key – it is shown only once!

Note: by default the webhook URL is the only secret. Services like GitHub, Stripe or many monitoring tools can’t send custom headers, so for them you leave the API key off. For everything else (your own scripts, CI pipelines), I suggest to switch it on.

Step 4: Send your first notification

Now let’s send a message. The easiest way is curl:

curl -X POST https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT \
  -H "Authorization: Bearer $ALPHORN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Server Alert",
    "message": "CPU usage above 90% on web-01",
    "priority": 4,
    "tags": ["infrastructure", "critical"]
  }'

Alphorn answers with the message id:

{
  "messageId": "0199b2f4-3c1e-7a8d-9f20-5d4c3b2a1e0f"
}

All fields are optional. The priority goes from 1 (lowest) to 5 (urgent), tags are used for filtering, and you can attach any JSON data you want – Alphorn keeps the whole body and you can filter on it later. If your tool sends different field names (e.g. “subject” or “text”), then Alphorn tries to guess them, or you map them with webhook templates.

The same request in Powershell, because I use it a lot on Windows servers:

# send a notification to alphorn from powershell
$body = @{
    title    = "Disk space low on $env:COMPUTERNAME"
    message  = "Drive C: has less than 10% free space"
    priority = 4
    tags     = @("windows", "disk")
} | ConvertTo-Json

Invoke-RestMethod -Method Post -Uri $env:ALPHORN_WEBHOOK_URL `
    -Headers @{ Authorization = "Bearer $env:ALPHORN_API_KEY" } `
    -ContentType "application/json" -Body $body

Step 5: Route notifications with filters

This is the part I personally like most. Filters are set per channel on a webhook, so one webhook can deliver to multiple channels with different rules. A typical setup looks like this:

  • Slack (#alerts): no filter – receives everything, so the team has the full picture
  • Email: priority greater than 3 – only priority 4 and 5
  • Telegram: priority greater than 3 – only priority 4 and 5, so I get it on my phone

A message with priority 2 goes to Slack only. A message with priority 5 goes to Slack, email, and Telegram. You can combine conditions with AND (within a group) and OR (multiple groups), e.g. “priority greater than 4 AND tag production” OR “tag security”.

You can also filter on the payload. If your JSON contains {“payload”: {“environment”: “production”}}, then the path “payload.environment” equals “production” can route production deployments to PagerDuty, while staging only goes to Slack.

Note: each filtered channel has a “Test filter” button. Use it! You can enter a sample message and see which conditions match – before the real alert at 3 am shows you that the filter was wrong.

How I use Alphorn – a few real examples

The following examples are the setups I use or see most often.

Backup failed – send a notification only if the backup script returns an error. Plain text is enough here, and the headers set title, priority and tags:

#!/usr/bin/env bash
# nightly backup - notify alphorn only if it fails
if ! /usr/local/bin/backup.sh; then
  curl -fsS -X POST "$ALPHORN_WEBHOOK_URL" \
    -H "Authorization: Bearer $ALPHORN_API_KEY" \
    -H "Content-Type: text/plain" \
    -H "X-Title: Backup failed on $(hostname)" \
    -H "X-Priority: 4" \
    -H "X-Tags: backup,failed" \
    -d "Nightly backup failed - please check the logs"
fi

Other use cases where it works really well:

  • Uptime monitoring: the monitor calls the webhook, and the alert goes to Teams and Telegram at the same time
  • GitHub webhooks: new release or failed workflow – Alphorn has presets for common sources, so you don’t have to map the fields yourself
  • Contact forms: the website calls the webhook, the message arrives via email and in a Slack channel
  • Cron jobs and CI/CD pipelines: one curl line at the end of the job

Get notified by AI agents (Claude Code, Codex, Cursor)

A use case I didn’t expect at the beginning, but use every day now: AI coding agents. They work on a task for a while and then wait for a permission or a decision – and if you don’t look at the terminal, then they wait forever.

For that, I built an MCP server for Alphorn. It gives the agent a “send_notification” tool, so it can tell you when it’s finished, failed, or needs your input. For Claude Code, it’s one command:

claude mcp add alphorn -s user \
  -e ALPHORN_WEBHOOK_URL=https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT \
  -e ALPHORN_API_KEY=alp_... \
  -- npx -y @alphorn/mcp

It also works with Claude Desktop, Cursor, OpenAI Codex and any other MCP client. If you prefer no MCP server at all, then the docs also have ready-to-use Claude Code hooks (Stop and Notification events) that call the webhook with curl. In combination with a filter like “priority greater than 3 to SMS”, I only get a text message when the agent is really blocked. That’s awesome.

How is Alphorn different from ntfy, Apprise or Gotify?

I get this question a lot, and it’s a fair one. ntfy, Gotify and Apprise are great tools, and Alphorn even supports ntfy and Gotify as channels. But they focus on sending a message to a destination. Alphorn is a routing layer on top: one incoming webhook, filter rules, and fan-out to multiple channels – plus a web dashboard, delivery history, retries, and organizations with roles for teams.

On the other side, Alphorn is not a product notification platform like Novu, Knock or Courier. It’s not built to send onboarding emails to millions of end users. It’s built for developers and ops teams who want their technical notifications in the right place. Is it the right tool for every case? No, it is not – but for “my systems need to tell me something”, it’s exactly what I wanted.

Is Alphorn free?

Yes. The source code is on GitHub under AGPL-3.0-or-later, and you can self-host it for free without feature restrictions – personal, internal, or commercial. If you run a modified version as a public network service, then you have to publish your changes (that’s how AGPL works). For companies that can’t comply with the AGPL, there is a commercial license.

If you don’t want to run it yourself, then the hosted version at app.alphorn.dev has a free tier with 3,000 messages per month. Each accepted webhook request counts as one message, no matter to how many channels it is delivered.

What happens when a channel is down?

Alphorn makes up to 5 delivery attempts with exponential backoff, starting at around 30 seconds – all attempts are done within roughly 15 minutes. Permanent errors (e.g. a wrong token) are not retried, because retrying wouldn’t help anyway. If all attempts fail, then the delivery is marked as failed, and you can configure a failure notification via a backup channel. So if Slack is down, then you still get the message on Telegram.

I’m working on more channels and integrations – so stay tuned!

Further Information

Alphorn website: https://alphorn.dev
Alphorn hosted app: https://app.alphorn.dev
Documentation: https://docs.alphorn.dev
Self-hosting guide: https://docs.alphorn.dev/self-hosting/
Filtering rules: https://docs.alphorn.dev/filtering/
API reference: https://docs.alphorn.dev/api-reference/
Claude Code notifications: https://docs.alphorn.dev/ai-agents/claude-code/
Source code (GitHub): https://github.com/alphorn-dev/alphorn
MCP server (GitHub): https://github.com/alphorn-dev/mcp

Categories:

Comments are closed

About
about armin

Armin Reiter
Web3 & Blockchain
Cybersecurity | Open-Source

Vienna, Austria

a31 Labs Logo

Legal information