If you self-host, you probably already have a push notification server. ntfy and Gotify both do the same job in different accents: you publish a message to a topic or application, and every phone subscribed to it buzzes. They run on a Raspberry Pi, need no vendor account, and are the first thing many homelabs wire their backups, Home Assistant, and Uptime Kuma into. Uptime alerts belong there too, and CronAlert gets them there through its webhook channel, with one small piece in between.
That piece is needed because neither app speaks CronAlert's webhook format. This post gives you the relay (a free Cloudflare Worker, about twenty lines for each app), the priority mapping that makes a down alert wake you and a recovery not, the authentication details for self-hosted servers, and the one thing most self-hosted alert pipelines forget: monitoring the notification server so the alert path can't fail silently.
Why a relay is needed
When a monitor goes down or recovers, CronAlert's webhook channel POSTs a JSON document to the URL you configure. It carries an event (monitor.down or monitor.recovered), the monitor's name and url, the incident's start and resolution times, the triggering check's status code, response time, error, and region, and a timestamp, with an optional HMAC-SHA256 signature in the X-CronAlert-Signature header. ntfy wants the notification text as the request body (with title, priority, and tags in headers) or as JSON with top-level topic and message fields. Gotify wants title, message, and priority fields and an application token. Post CronAlert's document straight to either and you get a 400 or a notification whose body is a wall of JSON.
A relay fixes that in one hop: it receives CronAlert's webhook, checks the signature, decides what the notification should say and how loud it should be, and forwards it. A Cloudflare Worker is the leanest host for this because it's free at alert volumes, has no server to maintain, and is reachable from CronAlert's dispatcher without you opening anything.
The Worker
Create a Worker, set two secrets with wrangler secret put (the signing secret you'll enter in CronAlert, and your ntfy or Gotify details), and deploy this. It handles both apps; delete the branch you don't use.
// CronAlert webhook → ntfy or Gotify relay
// Secrets: CRONALERT_WEBHOOK_SECRET, NTFY_URL (e.g. https://ntfy.example.com/uptime),
// NTFY_TOKEN (optional, tk_...), GOTIFY_URL (e.g. https://gotify.example.com), GOTIFY_TOKEN (app token)
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("ok");
const raw = await request.text();
if (!(await verify(raw, request.headers.get("X-CronAlert-Signature"), env.CRONALERT_WEBHOOK_SECRET))) {
return new Response("bad signature", { status: 401 });
}
const a = JSON.parse(raw);
const down = a.event === "monitor.down";
const title = down ? `${a.monitor.name} is DOWN` : `${a.monitor.name} recovered`;
const body = down
? `${a.check.statusCode ?? a.check.errorMessage ?? "no response"}${a.check.region ? ` from ${a.check.region}` : ""}\n${a.monitor.url}`
: `Down for ${minutes(a.incident)} min\n${a.monitor.url}`;
if (env.NTFY_URL) {
await fetch(env.NTFY_URL, {
method: "POST",
headers: {
"Title": title,
"Priority": down ? "5" : "3",
"Tags": down ? "rotating_light" : "white_check_mark",
"Click": a.monitor.url,
...(env.NTFY_TOKEN ? { "Authorization": `Bearer ${env.NTFY_TOKEN}` } : {}),
},
body,
});
}
if (env.GOTIFY_URL) {
await fetch(`${env.GOTIFY_URL}/message`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Gotify-Key": env.GOTIFY_TOKEN },
body: JSON.stringify({ title, message: body, priority: down ? 8 : 4 }),
});
}
return new Response(null, { status: 204 });
},
};
const minutes = (i) => Math.max(1, Math.round((new Date(i.resolvedAt) - new Date(i.startedAt)) / 60000));
async function verify(raw, header, secret) {
if (!header || !secret) return false;
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(raw));
const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
return hex.length === header.length && crypto.subtle.timingSafeEqual(new TextEncoder().encode(hex), new TextEncoder().encode(header));
} Then, in CronAlert, create an alert channel of type Webhook, paste the Worker's URL, enter the same signing secret, save, and use Send test alert. A notification should land on your phone within a second or two. Test the recovery path as well (pause and resume a monitor, or stop a test service for a minute), because recoveries have a resolvedAt and no status code, and relays built against only the down payload tend to break on them.
Priorities: loud for down, quiet for recovered
The relay's main job beyond reshaping is deciding how loud each event should be, and the answer is asymmetric: a down alert should wake you, a recovery should not. Both apps give you a priority field to express that; what each phone does with it is a setting on the phone.
- ntfy uses priorities 1 to 5 (also spelled
min,low,default,high,urgent). The Worker sends down at 5 and recovered at 3. In the ntfy Android app, open the subscription's notification settings and let the urgent priority's channel override Do Not Disturb; on iOS, add the ntfy app to the Allowed Apps of your Sleep Focus. TheClickheader makes tapping the notification open the monitored URL, which is usually what you want to check first. - Gotify uses an integer priority where the Android app's behavior steps up with the number: low values show only an icon, mid values add sound, and 8 and above add vibration. The Worker sends down at 8 and recovered at 4. Allow the Gotify app in Android's Do Not Disturb exceptions so the loud ones get through. Gotify has no official iOS app; if your household is mixed, ntfy covers both.
The same face-down test from the SMS and phone-call alerts guide applies: turn Do Not Disturb on, send a test alert, and confirm the phone actually makes a sound. A push you can't hear at 3 AM is the same as no push.
Self-hosted servers: authentication and reachability
On ntfy.sh a topic is public by default, so pick a long random topic name and treat it like a password. On a self-hosted ntfy with access control turned on, create a user or an access token (ntfy token add) with publish rights on the topic and set it as NTFY_TOKEN; the Worker sends it as a Bearer header. Gotify always needs a token: in its web UI create an Application named "CronAlert", copy its token into GOTIFY_TOKEN, and the Worker sends it as X-Gotify-Key.
Whichever server you run, the Worker has to reach it from Cloudflare's network, so it needs a public HTTPS hostname with a certificate a browser would trust. If the server is internal-only, publish just its publish endpoint through your reverse proxy or a Cloudflare Tunnel, the same compromise as for a self-hosted chat server. Phones subscribe outbound, so they're unaffected; on Android without Google services, ntfy's instant-delivery mode keeps a connection open to your server directly.
Monitor the notification server
A self-hosted push server is a single point of failure for every alert that passes through it, and it often lives on the same host, network, or reverse proxy as the things you're monitoring. Two cheap protections. First, monitor it: ntfy answers on /v1/health with a small JSON body ("healthy":true), and Gotify answers on /health with "health":"green"; an HTTP monitor on each, with a keyword check on Pro, tells you the alert path is broken before you need it. Second, keep a channel that doesn't depend on your infrastructure. CronAlert's alert channels are team-wide, so adding email or its built-in browser push alongside the webhook means every incident reaches both, at no extra cost.
When to use built-in push instead
CronAlert has native push notifications on every plan, including free, with no server to run and nothing to relay. If you don't already run ntfy or Gotify, that's the shorter path, and the iOS and Android guides cover making it override Do Not Disturb. ntfy and Gotify win when your phone already lives in them: one app for backups, Home Assistant, and uptime, with per-topic priorities you control and a server whose uptime is your own responsibility, which, if you've read this far, is a responsibility you've already accepted.
Frequently asked questions
Can I paste the ntfy or Gotify URL into CronAlert directly?
No; neither accepts CronAlert's payload shape. The Worker above reshapes it in one hop.
How do I make down alerts override Do Not Disturb?
Send them at the top priority (ntfy 5, Gotify 8 or higher) and allow that priority or app through DND on the phone. Recoveries at normal priority stay quiet.
What if the notification server is down during an outage?
The alert is lost unless a second channel carries it. Monitor the server's health endpoint and keep email or built-in push enabled too.
Is any of this on a paid plan?
No. The webhook channel is free, and the Worker fits in Cloudflare's free tier.
Can one Worker feed both apps?
Yes; set both sets of secrets and it posts to both.
One Worker, your own push server
Twenty lines of relay turn CronAlert's webhook into the push notifications you already trust, with the loudness you choose. Deploy the Worker, create the webhook channel, send a test with Do Not Disturb on, and add an HTTP monitor on the server that delivers it. Create a free account and start with the test. Related reading: webhook alert integrations for the other relay recipes, Mattermost and Rocket.Chat alerts, monitoring Home Assistant, and testing your alerts.