<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <updated>2026-10-16T11:33:30Z</updated>
  <generator>https://njump.me</generator>

  <title>Nostr notes by Armin Reiter (Unofficial)</title>
  <author>
    <name>Armin Reiter (Unofficial)</name>
  </author>
  <link rel="self" type="application/atom+xml" href="https://njump.me/npub1cudzru7v7jquh2asz064gpd2jl6rew6hffwfm7wwzf3uy8zdza3sfccp0d.rss" />
  <link href="https://njump.me/npub1cudzru7v7jquh2asz064gpd2jl6rew6hffwfm7wwzf3uy8zdza3sfccp0d" />
  <id>https://njump.me/npub1cudzru7v7jquh2asz064gpd2jl6rew6hffwfm7wwzf3uy8zdza3sfccp0d</id>
  <icon>https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f994.png</icon>
  <logo>https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/1f994.png</logo>




  <entry>
    <id>https://njump.me/nevent1qqsxtqanzzw53x4hznnh6dzyu0z5hw7sj3y9vnzmh82k66u976f6zjszyrr35g0nen6grjatkqfl24q942tlg09m2a99e80eecfx8ssuf5tkxs375yl</id>
    
      <title type="html">Test emails locally, mock Resend, SendGrid and SMTP with ...</title>
    
    <link rel="alternate" href="https://njump.me/nevent1qqsxtqanzzw53x4hznnh6dzyu0z5hw7sj3y9vnzmh82k66u976f6zjszyrr35g0nen6grjatkqfl24q942tlg09m2a99e80eecfx8ssuf5tkxs375yl" />
    <content type="html">
      Test emails locally, mock Resend, SendGrid and SMTP with MailDotNull&lt;br/&gt;&lt;br/&gt;Who hasn’t sent a test email to a real customer by accident? Or burned through the free tier of an email provider while testing the signup flow for the 50th time? If you test emails during local development, then you need something that accepts the mails, shows them to you, and delivers nothing. That’s why I built MailDotNull – a local mail catcher and mock email API for Resend, SendGrid, Mailjet, Mailgun, Postmark, Brevo, Amazon SES and plain SMTP.&lt;br/&gt;In this blog post, I’ll show how to run MailDotNull with Docker, how to point the official SDKs at it, and how to use it for error testing and in integration tests. Let’s get started.&lt;br/&gt;What is MailDotNull?&lt;br/&gt;MailDotNull is an open source mail catcher like Mailpit – but it also mocks the HTTP send APIs of the popular email providers. You don’t change your code and you don’t write test doubles. You just change the base URL of the SDK, and every email lands in a live web inbox instead of a real mailbox.&lt;br/&gt;Some facts at a glance:&lt;br/&gt;Mocked providers: Resend, SendGrid, Mailjet, Mailgun, Postmark, Brevo, Amazon SES v2 – and SMTP (plain, STARTTLS and SMTPS)&lt;br/&gt;Provider-faithful responses: each endpoint returns the real success shape (message ids, HTTP 202 &#43; X-Message-Id, …), so the official SDKs treat the send as successful&lt;br/&gt;Any API key works, any SMTP credentials work&lt;br/&gt;In-memory only: no database, no files – a restart clears the inbox (that’s a feature!)&lt;br/&gt;Single Go binary with the web UI embedded, available as Docker image for amd64 and arm64&lt;br/&gt;License: MIT&lt;br/&gt;Why not just use Mailpit?&lt;br/&gt;Mailpit is great, and I used it for years. But it only speaks SMTP. Most of my projects don’t use SMTP anymore – they use the SDK of Resend or SendGrid, which calls an HTTP API. So for local development, I had two options: send real emails to the provider (with a test domain and a test key), or mock the SDK in my code. I didn’t like both.&lt;br/&gt;With MailDotNull, the app uses exactly the same code path as in production – the same SDK, the same request, the same response handling. Only the URL is different. In my opinion, that’s the best way to test emails locally, because you also find bugs in the parts around the send (templates, error handling, retries).&lt;br/&gt;Step 1: Start MailDotNull with Docker&lt;br/&gt;The easiest way is Docker. Use the following command:&lt;br/&gt;docker run -d --name maildotnull \&lt;br/&gt;-p 8025:8025 -p 1025:1025 -p 1587:1587 -p 1465:1465 \&lt;br/&gt;maildotnull/maildotnull&lt;br/&gt;After that, the following ports are available:&lt;br/&gt;&lt;a href=&#34;http://localhost:8025&#34;&gt;http://localhost:8025&lt;/a&gt; – web UI and all provider APIs&lt;br/&gt;localhost:1025 – SMTP (plaintext, no STARTTLS, auth optional)&lt;br/&gt;localhost:1587 – SMTP with STARTTLS&lt;br/&gt;localhost:1465 – SMTPS (implicit TLS)&lt;br/&gt;If you use Podman, then add -e HOSTNAME=0.0.0.0 for the 0.1.0 image – Podman sets HOSTNAME to the container hostname, which the server would otherwise use as bind address:&lt;br/&gt;podman run -d --name maildotnull \&lt;br/&gt;-e HOSTNAME=0.0.0.0 \&lt;br/&gt;-p 8025:8025 -p 1025:1025 -p 1587:1587 -p 1465:1465 \&lt;br/&gt;docker.io/maildotnull/maildotnull&lt;br/&gt;I personally prefer to add it to the docker-compose.yml of the project, so it starts together with the database:&lt;br/&gt;services:&lt;br/&gt;# local mail catcher - nothing is delivered&lt;br/&gt;maildotnull:&lt;br/&gt;image: maildotnull/maildotnull:latest&lt;br/&gt;ports:&lt;br/&gt;- &amp;#34;8025:8025&amp;#34; # web ui &#43; provider apis&lt;br/&gt;- &amp;#34;1025:1025&amp;#34; # smtp&lt;br/&gt;Step 2: Send a test email&lt;br/&gt;Let’s check if it works. The following request uses the Resend API format – the API key can be anything:&lt;br/&gt;curl &lt;a href=&#34;http://localhost:8025/emails&#34;&gt;http://localhost:8025/emails&lt;/a&gt; \&lt;br/&gt;-H &amp;#34;Authorization: Bearer re_anything&amp;#34; \&lt;br/&gt;-H &amp;#34;Content-Type: application/json&amp;#34; \&lt;br/&gt;-d &amp;#39;{&amp;#34;from&amp;#34;: &amp;#34;Test &amp;lt;test@example.dev&amp;gt;&amp;#34;, &amp;#34;to&amp;#34;: [&amp;#34;you@example.com&amp;#34;], &amp;#34;subject&amp;#34;: &amp;#34;Hello from MailDotNull&amp;#34;, &amp;#34;text&amp;#34;: &amp;#34;It works!&amp;#34;}&amp;#39;&lt;br/&gt;Open &lt;a href=&#34;http://localhost:8025&#34;&gt;http://localhost:8025&lt;/a&gt; and the mail is already there. The inbox uses server-sent events, so you don’t have to refresh the page.&lt;br/&gt;Step 3: Point your SDK at MailDotNull&lt;br/&gt;Each provider is mocked at the exact path that its SDK calls, so switching is one config line. For Resend, it looks like this:&lt;br/&gt;import { Resend } from &amp;#34;resend&amp;#34;;&lt;br/&gt;// in development, send everything to maildotnull instead of resend&lt;br/&gt;const resend = process.env.NODE_ENV === &amp;#34;production&amp;#34;&lt;br/&gt;? new Resend(process.env.RESEND_API_KEY)&lt;br/&gt;: new Resend(&amp;#34;re_mock&amp;#34;, { baseUrl: &amp;#34;&lt;a href=&#34;http://localhost:8025&amp;#34&#34;&gt;http://localhost:8025&amp;#34&lt;/a&gt;; });&lt;br/&gt;await resend.emails.send({&lt;br/&gt;from: &amp;#34;Armin &amp;lt;hello@example.dev&amp;gt;&amp;#34;,&lt;br/&gt;to: [&amp;#34;customer@example.com&amp;#34;],&lt;br/&gt;subject: &amp;#34;Welcome!&amp;#34;,&lt;br/&gt;html: &amp;#34;&amp;lt;p&amp;gt;Thanks for signing up.&amp;lt;/p&amp;gt;&amp;#34;,&lt;br/&gt;});&lt;br/&gt;For the other providers, you have to set the following options:&lt;br/&gt;SendGrid: client.setDefaultRequest(“baseUrl”, “&lt;a href=&#34;http://localhost:8025“&#34;&gt;http://localhost:8025“&lt;/a&gt;)&lt;br/&gt;Mailjet: new Mailjet({ apiKey, apiSecret, config: { host: “localhost:8025” } })&lt;br/&gt;Mailgun: mailgun.client({ username: “api”, key: “mock”, url: “&lt;a href=&#34;http://localhost:8025”&#34;&gt;http://localhost:8025”&lt;/a&gt; })&lt;br/&gt;Postmark: new ServerClient(“mock”, { requestHost: “localhost:8025”, useHttps: false })&lt;br/&gt;Brevo: set the base path of the SDK to &lt;a href=&#34;http://localhost:8025/v3&#34;&gt;http://localhost:8025/v3&lt;/a&gt;&lt;br/&gt;Amazon SES v2: new SESv2Client({ endpoint: “&lt;a href=&#34;http://localhost:8025“&#34;&gt;http://localhost:8025“&lt;/a&gt;, credentials: { accessKeyId: “mock”, secretAccessKey: “mock” } })&lt;br/&gt;If your app uses SMTP (e.g. with nodemailer), then use host localhost and port 1025:&lt;br/&gt;import nodemailer from &amp;#34;nodemailer&amp;#34;;&lt;br/&gt;// port 1025 never advertises STARTTLS, so no tls config is needed&lt;br/&gt;const transport = nodemailer.createTransport({&lt;br/&gt;host: &amp;#34;localhost&amp;#34;,&lt;br/&gt;port: 1025,&lt;br/&gt;});&lt;br/&gt;Note: the certificate on port 1587 and 1465 is self-signed and regenerated on every start. If you want to test TLS, then you have to accept it in your client (e.g. tls: { rejectUnauthorized: false } in nodemailer). Attention! Only do this for MailDotNull, never for a real mail server!&lt;br/&gt;Step 4: Check how the email looks on mobile and in dark mode&lt;br/&gt;This is the part where I spent most of the time, and I really like the result. Every HTML mail renders in a sandboxed iframe, and you have the following tools:&lt;br/&gt;Device widths: desktop, tablet (768px) and mobile (375px)&lt;br/&gt;Dark mode simulation: how Gmail Android, Gmail iOS, Apple Mail, Outlook.com and Yahoo Mail transform your mail in dark mode&lt;br/&gt;HTML Check: flags HTML/CSS features that are known to break in mail clients&lt;br/&gt;Link Check: checks the HTTP status of every link in the mail&lt;br/&gt;Source, Text, Headers and Raw tabs – Raw shows the payload exactly as received&lt;br/&gt;Note: the mail clients don’t publish their dark mode algorithms, so the simulation is an approximation. It’s a very good preview, but not pixel truth.&lt;br/&gt;Step 5: Test error handling – rate limits, server errors, timeouts&lt;br/&gt;What happens in your app if SendGrid returns a 429? Or if the SMTP server doesn’t answer? In my experience, this code path is never tested, because it’s hard to reproduce with a real provider. With MailDotNull, you can make any provider misbehave – via the flask button in the header of the web UI, or via the API:&lt;br/&gt;# sendgrid returns 429, all other providers work normally&lt;br/&gt;curl -X PUT &lt;a href=&#34;http://localhost:8025/api/error-testing&#34;&gt;http://localhost:8025/api/error-testing&lt;/a&gt; \&lt;br/&gt;-H &amp;#34;Content-Type: application/json&amp;#34; \&lt;br/&gt;-d &amp;#39;{&amp;#34;providers&amp;#34;: {&amp;#34;sendgrid&amp;#34;: &amp;#34;rate-limit&amp;#34;}}&amp;#39;&lt;br/&gt;The following modes are available:&lt;br/&gt;HTTP providers: off, delay, rate-limit (429), server-error (500), timeout – for all providers at once or per provider&lt;br/&gt;SMTP: off, delay, timeout, reject-temporary (451), reject-permanent (550)&lt;br/&gt;SMTP auth: optional or required – when required, unauthenticated senders get “530 Authentication required”&lt;br/&gt;delaySeconds: how long the delay waits (1-300 seconds, default 10)&lt;br/&gt;The error responses use the real error format of each provider, so the official SDKs throw their typed errors – exactly like in production. To reset everything, use the following command (or just restart the container):&lt;br/&gt;curl -X PUT &lt;a href=&#34;http://localhost:8025/api/error-testing&#34;&gt;http://localhost:8025/api/error-testing&lt;/a&gt; \&lt;br/&gt;-H &amp;#34;Content-Type: application/json&amp;#34; \&lt;br/&gt;-d &amp;#39;{&amp;#34;http&amp;#34;: &amp;#34;off&amp;#34;, &amp;#34;providers&amp;#34;: {&amp;#34;sendgrid&amp;#34;: &amp;#34;inherit&amp;#34;}, &amp;#34;smtp&amp;#34;: &amp;#34;off&amp;#34;}&amp;#39;&lt;br/&gt;Use MailDotNull in integration tests and CI&lt;br/&gt;The web UI uses a small API, and you can use it in your tests as well. So you can trigger e.g. a password reset in your app and then check if the mail was sent:&lt;br/&gt;// clear the inbox before the test&lt;br/&gt;await fetch(&amp;#34;&lt;a href=&#34;http://localhost:8025/api/messages&amp;#34&#34;&gt;http://localhost:8025/api/messages&amp;#34&lt;/a&gt;;, { method: &amp;#34;DELETE&amp;#34; });&lt;br/&gt;// ... trigger the password reset in your app ...&lt;br/&gt;// check that exactly one mail was sent to the user&lt;br/&gt;const messages = await (await fetch(&amp;#34;&lt;a href=&#34;http://localhost:8025/api/messages&amp;#34&#34;&gt;http://localhost:8025/api/messages&amp;#34&lt;/a&gt;;)).json();&lt;br/&gt;const mail = messages.find((m) =&amp;gt; m.to.includes(&amp;#34;user@example.com&amp;#34;));&lt;br/&gt;expect(mail.subject).toBe(&amp;#34;Reset your password&amp;#34;);&lt;br/&gt;The list contains a summary of each mail (id, source, from, to, subject, snippet, …). For the full mail incl. HTML body, use GET /api/messages/:id. In a CI pipeline, you start MailDotNull as a service container, and that’s it – no API keys, no test domain, no secrets in the pipeline.&lt;br/&gt;Note: MailDotNull keeps the most recent 1000 messages in memory. For tests, I recommend to clear the inbox before each test, so the tests don’t depend on each other.&lt;br/&gt;Is MailDotNull safe to use?&lt;br/&gt;Nothing is delivered and nothing is persisted – that’s the whole idea. HTML bodies always render in a fully sandboxed iframe, so a caught mail can’t run scripts or touch the app.&lt;br/&gt;But be careful: MailDotNull accepts any API key and any credentials. It’s a tool for local development and CI, so don’t expose it to the internet!&lt;br/&gt;Further Information&lt;br/&gt;MailDotNull on GitHub: &lt;a href=&#34;https://github.com/maildotnull/maildotnull&#34;&gt;https://github.com/maildotnull/maildotnull&lt;/a&gt;&lt;br/&gt;MailDotNull on Docker Hub: &lt;a href=&#34;https://hub.docker.com/r/maildotnull/maildotnull&#34;&gt;https://hub.docker.com/r/maildotnull/maildotnull&lt;/a&gt;&lt;br/&gt;Mailpit: &lt;a href=&#34;https://mailpit.axllent.org/&#34;&gt;https://mailpit.axllent.org/&lt;/a&gt;&lt;br/&gt;Resend Node.js SDK: &lt;a href=&#34;https://github.com/resend/resend-node&#34;&gt;https://github.com/resend/resend-node&lt;/a&gt;&lt;br/&gt;Nodemailer: &lt;a href=&#34;https://nodemailer.com/&#34;&gt;https://nodemailer.com/&lt;/a&gt;&lt;br/&gt;The post Test emails locally, mock Resend, SendGrid and SMTP with MailDotNull appeared first on arminreiter.com.&lt;br/&gt;&lt;br/&gt;&lt;a href=&#34;https://arminreiter.com/2026/10/test-emails-locally-mock-resend-sendgrid-and-smtp-with-maildotnull/&#34;&gt;https://arminreiter.com/2026/10/test-emails-locally-mock-resend-sendgrid-and-smtp-with-maildotnull/&lt;/a&gt;&lt;br/&gt; &lt;img src=&#34;https://arminreiter.com/wp-content/uploads/2026/10/image-1024x544.png&#34;&gt; &lt;br/&gt;&lt;br/&gt;#smtp #maildotnull #testemails #emailslocally #resend #docker #amazonses #use #sdk #sendgrid&lt;br/&gt;&lt;br/&gt;#rss #feeds #linksharing #qwibbit
    </content>
    <updated>2026-10-21T03:32:14Z</updated>
  </entry>

  <entry>
    <id>https://njump.me/nevent1qqs875f0nvc6yyj0wlm7hckvux5aca896upjwwh2xpykwfq3c3wm6hszyrr35g0nen6grjatkqfl24q942tlg09m2a99e80eecfx8ssuf5tkxkzs0dv</id>
    
      <title type="html">Alphorn: self-hosted notification router for webhooks Who isn’t ...</title>
    
    <link rel="alternate" href="https://njump.me/nevent1qqs875f0nvc6yyj0wlm7hckvux5aca896upjwwh2xpykwfq3c3wm6hszyrr35g0nen6grjatkqfl24q942tlg09m2a99e80eecfx8ssuf5tkxkzs0dv" />
    <content type="html">
      Alphorn: self-hosted notification router for webhooks&lt;br/&gt;&lt;br/&gt;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.&lt;br/&gt;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.&lt;br/&gt;What is Alphorn?&lt;br/&gt;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.&lt;br/&gt;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.&lt;br/&gt;Some facts at a glance:&lt;br/&gt;License: AGPL-3.0-or-later (open source), with a commercial license available&lt;br/&gt;Hosted version: app.alphorn.dev, with a free tier (3,000 messages per month, 3 webhooks, 3 channels)&lt;br/&gt;Self-hosted: Docker Compose, Podman or from source – the only dependency is PostgreSQL 18&lt;br/&gt;Stack: Next.js, PostgreSQL and pg-boss as queue (no Redis, no separate message broker)&lt;br/&gt;Reliability: up to 5 delivery attempts with exponential backoff&lt;br/&gt;No SDK needed: it’s a plain HTTP POST, so curl, Powershell, Python, Go or any monitoring tool works&lt;br/&gt;Why I built a notification router&lt;br/&gt;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.&lt;br/&gt;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!&lt;br/&gt;How does Alphorn work?&lt;br/&gt;The flow has three parts:&lt;br/&gt;Webhooks: the entry point. Each webhook has a unique URL like &lt;a href=&#34;https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT&#34;&gt;https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT&lt;/a&gt; and accepts JSON, plain text, or Slack-compatible payloads.&lt;br/&gt;Channels: the destinations – a Slack workspace, a Telegram bot, an SMTP server, a phone number for SMS, and so on.&lt;br/&gt;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).&lt;br/&gt;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.&lt;br/&gt;Step 1: Choose hosted or self-hosted&lt;br/&gt;You can use Alphorn in two ways:&lt;br/&gt;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.&lt;br/&gt;Self-hosted: run it on your own server. All features are available, there are no message limits, and your data stays on your infrastructure.&lt;br/&gt;If you want to self-host it, then the following docker-compose.yml is everything you need:&lt;br/&gt;services:&lt;br/&gt;alphorn:&lt;br/&gt;image: ghcr.io/alphorn-dev/alphorn:latest&lt;br/&gt;ports:&lt;br/&gt;- &amp;#34;3000:3000&amp;#34;&lt;br/&gt;environment:&lt;br/&gt;DATABASE_URL: postgresql://alphorn:secret@db:5432/alphorn&lt;br/&gt;# generate with: openssl rand -base64 32&lt;br/&gt;BETTER_AUTH_SECRET: your-secret-key&lt;br/&gt;# public url of your instance - https in production!&lt;br/&gt;BETTER_AUTH_URL: &lt;a href=&#34;http://localhost:3000&#34;&gt;http://localhost:3000&lt;/a&gt;&lt;br/&gt;depends_on:&lt;br/&gt;db:&lt;br/&gt;condition: service_healthy&lt;br/&gt;restart: unless-stopped&lt;br/&gt;db:&lt;br/&gt;image: postgres:18-alpine&lt;br/&gt;environment:&lt;br/&gt;POSTGRES_USER: alphorn&lt;br/&gt;POSTGRES_PASSWORD: secret&lt;br/&gt;POSTGRES_DB: alphorn&lt;br/&gt;volumes:&lt;br/&gt;# postgres 18 stores data under /var/lib/postgresql (not /data anymore)&lt;br/&gt;- pgdata:/var/lib/postgresql&lt;br/&gt;healthcheck:&lt;br/&gt;test: [&amp;#34;CMD-SHELL&amp;#34;, &amp;#34;pg_isready -U alphorn&amp;#34;]&lt;br/&gt;interval: 5s&lt;br/&gt;retries: 5&lt;br/&gt;restart: unless-stopped&lt;br/&gt;volumes:&lt;br/&gt;pgdata:&lt;br/&gt;Start it with the following command and open &lt;a href=&#34;http://localhost:3000&#34;&gt;http://localhost:3000&lt;/a&gt; to create your first account:&lt;br/&gt;docker compose up -d&lt;br/&gt;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.&lt;br/&gt;Attention! PostgreSQL 18 is required – Alphorn uses the built-in uuidv7() function, which doesn’t exist in older versions.&lt;br/&gt;Step 2: Add a channel&lt;br/&gt;After the login, the first thing to configure is a channel:&lt;br/&gt;Navigate to “Channels” and click “Add channel”&lt;br/&gt;Select the type, e.g. “Telegram” or “Slack”&lt;br/&gt;Give it a speaking name, e.g. “telegram-armin” or “slack-alerts”&lt;br/&gt;Enter the credentials of the channel (bot token, webhook URL, SMTP server, …) and save it&lt;br/&gt;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.&lt;br/&gt;Step 3: Create a webhook&lt;br/&gt;Next step is to create the webhook:&lt;br/&gt;Navigate to “Webhooks” and click “Create webhook”&lt;br/&gt;Give it a name, e.g. “Server Alerts”&lt;br/&gt;Select the channels it should deliver to&lt;br/&gt;Switch on “API key required” if the sender can send custom headers&lt;br/&gt;Create it and copy the API key – it is shown only once!&lt;br/&gt;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.&lt;br/&gt;Step 4: Send your first notification&lt;br/&gt;Now let’s send a message. The easiest way is curl:&lt;br/&gt;curl -X POST &lt;a href=&#34;https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT&#34;&gt;https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT&lt;/a&gt; \&lt;br/&gt;-H &amp;#34;Authorization: Bearer $ALPHORN_API_KEY&amp;#34; \&lt;br/&gt;-H &amp;#34;Content-Type: application/json&amp;#34; \&lt;br/&gt;-d &amp;#39;{&lt;br/&gt;&amp;#34;title&amp;#34;: &amp;#34;Server Alert&amp;#34;,&lt;br/&gt;&amp;#34;message&amp;#34;: &amp;#34;CPU usage above 90% on web-01&amp;#34;,&lt;br/&gt;&amp;#34;priority&amp;#34;: 4,&lt;br/&gt;&amp;#34;tags&amp;#34;: [&amp;#34;infrastructure&amp;#34;, &amp;#34;critical&amp;#34;]&lt;br/&gt;}&amp;#39;&lt;br/&gt;Alphorn answers with the message id:&lt;br/&gt;{&lt;br/&gt;&amp;#34;messageId&amp;#34;: &amp;#34;0199b2f4-3c1e-7a8d-9f20-5d4c3b2a1e0f&amp;#34;&lt;br/&gt;}&lt;br/&gt;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.&lt;br/&gt;The same request in Powershell, because I use it a lot on Windows servers:&lt;br/&gt;# send a notification to alphorn from powershell&lt;br/&gt;$body = @{&lt;br/&gt;title = &amp;#34;Disk space low on $env:COMPUTERNAME&amp;#34;&lt;br/&gt;message = &amp;#34;Drive C: has less than 10% free space&amp;#34;&lt;br/&gt;priority = 4&lt;br/&gt;tags = @(&amp;#34;windows&amp;#34;, &amp;#34;disk&amp;#34;)&lt;br/&gt;} | ConvertTo-Json&lt;br/&gt;Invoke-RestMethod -Method Post -Uri $env:ALPHORN_WEBHOOK_URL `&lt;br/&gt;-Headers @{ Authorization = &amp;#34;Bearer $env:ALPHORN_API_KEY&amp;#34; } `&lt;br/&gt;-ContentType &amp;#34;application/json&amp;#34; -Body $body&lt;br/&gt;Step 5: Route notifications with filters&lt;br/&gt;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:&lt;br/&gt;Slack (#alerts): no filter – receives everything, so the team has the full picture&lt;br/&gt;Email: priority greater than 3 – only priority 4 and 5&lt;br/&gt;Telegram: priority greater than 3 – only priority 4 and 5, so I get it on my phone&lt;br/&gt;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”.&lt;br/&gt;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.&lt;br/&gt;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.&lt;br/&gt;How I use Alphorn – a few real examples&lt;br/&gt;The following examples are the setups I use or see most often.&lt;br/&gt;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:&lt;br/&gt;#!/usr/bin/env bash&lt;br/&gt;# nightly backup - notify alphorn only if it fails&lt;br/&gt;if ! /usr/local/bin/backup.sh; then&lt;br/&gt;curl -fsS -X POST &amp;#34;$ALPHORN_WEBHOOK_URL&amp;#34; \&lt;br/&gt;-H &amp;#34;Authorization: Bearer $ALPHORN_API_KEY&amp;#34; \&lt;br/&gt;-H &amp;#34;Content-Type: text/plain&amp;#34; \&lt;br/&gt;-H &amp;#34;X-Title: Backup failed on $(hostname)&amp;#34; \&lt;br/&gt;-H &amp;#34;X-Priority: 4&amp;#34; \&lt;br/&gt;-H &amp;#34;X-Tags: backup,failed&amp;#34; \&lt;br/&gt;-d &amp;#34;Nightly backup failed - please check the logs&amp;#34;&lt;br/&gt;fi&lt;br/&gt;Other use cases where it works really well:&lt;br/&gt;Uptime monitoring: the monitor calls the webhook, and the alert goes to Teams and Telegram at the same time&lt;br/&gt;GitHub webhooks: new release or failed workflow – Alphorn has presets for common sources, so you don’t have to map the fields yourself&lt;br/&gt;Contact forms: the website calls the webhook, the message arrives via email and in a Slack channel&lt;br/&gt;Cron jobs and CI/CD pipelines: one curl line at the end of the job&lt;br/&gt;Get notified by AI agents (Claude Code, Codex, Cursor)&lt;br/&gt;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.&lt;br/&gt;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:&lt;br/&gt;claude mcp add alphorn -s user \&lt;br/&gt;-e ALPHORN_WEBHOOK_URL=&lt;a href=&#34;https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT&#34;&gt;https://app.alphorn.dev/n/V1StGXR8_Z5jdHi6B-myT&lt;/a&gt; \&lt;br/&gt;-e ALPHORN_API_KEY=alp_... \&lt;br/&gt;-- npx -y @alphorn/mcp&lt;br/&gt;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.&lt;br/&gt;How is Alphorn different from ntfy, Apprise or Gotify?&lt;br/&gt;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.&lt;br/&gt;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.&lt;br/&gt;Is Alphorn free?&lt;br/&gt;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.&lt;br/&gt;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.&lt;br/&gt;What happens when a channel is down?&lt;br/&gt;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.&lt;br/&gt;I’m working on more channels and integrations – so stay tuned!&lt;br/&gt;Further Information&lt;br/&gt;Alphorn website: &lt;a href=&#34;https://alphorn.dev&#34;&gt;https://alphorn.dev&lt;/a&gt;&lt;br/&gt;Alphorn hosted app: &lt;a href=&#34;https://app.alphorn.dev&#34;&gt;https://app.alphorn.dev&lt;/a&gt;&lt;br/&gt;Documentation: &lt;a href=&#34;https://docs.alphorn.dev&#34;&gt;https://docs.alphorn.dev&lt;/a&gt;&lt;br/&gt;Self-hosting guide: &lt;a href=&#34;https://docs.alphorn.dev/self-hosting/&#34;&gt;https://docs.alphorn.dev/self-hosting/&lt;/a&gt;&lt;br/&gt;Filtering rules: &lt;a href=&#34;https://docs.alphorn.dev/filtering/&#34;&gt;https://docs.alphorn.dev/filtering/&lt;/a&gt;&lt;br/&gt;API reference: &lt;a href=&#34;https://docs.alphorn.dev/api-reference/&#34;&gt;https://docs.alphorn.dev/api-reference/&lt;/a&gt;&lt;br/&gt;Claude Code notifications: &lt;a href=&#34;https://docs.alphorn.dev/ai-agents/claude-code/&#34;&gt;https://docs.alphorn.dev/ai-agents/claude-code/&lt;/a&gt;&lt;br/&gt;Source code (GitHub): &lt;a href=&#34;https://github.com/alphorn-dev/alphorn&#34;&gt;https://github.com/alphorn-dev/alphorn&lt;/a&gt;&lt;br/&gt;MCP server (GitHub): &lt;a href=&#34;https://github.com/alphorn-dev/mcp&#34;&gt;https://github.com/alphorn-dev/mcp&lt;/a&gt;&lt;br/&gt;The post Alphorn: self-hosted notification router for webhooks appeared first on arminreiter.com.&lt;br/&gt;&lt;br/&gt;&lt;a href=&#34;https://arminreiter.com/2026/10/alphorn-self-hosted-notification-router-for-webhooks/&#34;&gt;https://arminreiter.com/2026/10/alphorn-self-hosted-notification-router-for-webhooks/&lt;/a&gt;
    </content>
    <updated>2026-10-16T02:56:52Z</updated>
  </entry>

</feed>