If you've configured notifications in Uptime Kuma, Home Assistant, Radarr, or most of the self-hosted ecosystem, you've used Apprise, possibly without knowing it. It's the library that reduces a hundred-plus notification services to one URL scheme each: tgram://bottoken/chatid for Telegram, pover://user@token for Pushover, matrix://, discord://, mailto://, ntfy://, signal://, and on. Its companion server, Apprise API, takes one HTTP request and fans the message out to every URL you've configured.
For uptime alerts, that's a single integration that covers everything: one CronAlert webhook channel, one small relay, one Apprise API, and any destination you'll ever want is a one-line addition in Apprise rather than a new channel. This post sets it up, with the relay Worker, tag-based routing to different people, Apprise's message types for down and recovered, authentication, and the monitor you should put on Apprise itself.
Run Apprise API
Apprise API ships as a container (caronc/apprise) listening on port 8000. Run it on your reverse-proxied host, give it a public HTTPS hostname such as apprise.example.com so the relay can reach it from Cloudflare's network, and protect it, because Apprise API has no authentication of its own: put HTTP basic auth on it at the reverse proxy (the relay will send the credentials), or restrict the hostname to a path only the relay knows. Then open its web UI and create a configuration with a key, say cronalert, containing your destination URLs, one per line, each with tags:
# Apprise configuration stored under key "cronalert" (text format: tags=url)
all=mailto://user:[email protected]
all,oncall=pover://pushover-user-key@pushover-app-token
billing=tgram://bot-token/billing-chat-id
platform=discord://webhook-id/webhook-token
platform=matrix://user:[email protected]/#platform-alerts Confirm it works from the command line before involving CronAlert: curl -X POST https://apprise.example.com/notify/cronalert -H "Content-Type: application/json" -d '{"title":"test","body":"hello","type":"info","tag":"all"}' should reach every destination tagged all.
The relay
CronAlert's webhook channel posts a JSON document per event (event, monitor, incident, check) with an HMAC signature; Apprise API wants body, title, type, and tag. A Cloudflare Worker translates. Set secrets with wrangler secret put: CRONALERT_WEBHOOK_SECRET, APPRISE_URL (for example https://apprise.example.com/notify/cronalert), and optionally APPRISE_BASIC_AUTH (user:password for the proxy). Deploy:
// CronAlert webhook → Apprise API relay
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";
// Routing: "[billing] Stripe webhooks" → tag "billing"; everything also goes to "all"
const prefix = a.monitor.name.match(/^\[([a-z0-9-]+)\]/i)?.[1]?.toLowerCase();
const tag = prefix ? `all,${prefix}` : "all";
const headers = { "Content-Type": "application/json" };
if (env.APPRISE_BASIC_AUTH) headers["Authorization"] = `Basic ${btoa(env.APPRISE_BASIC_AUTH)}`;
const res = await fetch(env.APPRISE_URL, {
method: "POST", headers,
body: JSON.stringify({
title: down ? `${a.monitor.name} is DOWN` : `${a.monitor.name} recovered`,
body: down
? `${a.monitor.url}\n${a.check.statusCode ?? a.check.errorMessage ?? "no response"}${a.check.region ? ` from ${a.check.region}` : ""}`
: `${a.monitor.url}\nDown for ${minutes(a.incident)} min`,
type: down ? "failure" : "success",
tag,
}),
});
return new Response(null, { status: res.ok ? 204 : 502 });
},
};
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));
} In CronAlert, add a Webhook alert channel with the Worker's URL and the same signing secret, save, and press Send test alert. Every destination tagged all should light up. Test a recovery too (pause and resume a monitor, or let a test monitor fail and fix it), since recoveries carry resolvedAt and no status code and are where relays usually break.
Types, tags, and routing
Apprise's type field (info, success, warning, failure) is rendered by each service in its own idiom: a red or green embed color in Discord, a matching icon in Pushover and Telegram, a subject prefix in email. Sending failure for down and success for recovered gets you readable alerts everywhere with no per-service formatting. Some services expose priority through their URL (Pushover's ?priority=high, ntfy's ?priority=5); if you want down alerts loud and recoveries quiet, put two tagged URLs for the same service in the configuration, one high-priority tagged down, and have the relay add down to the tag list only on failures.
Tags are also how per-team routing works, because CronAlert's alert channels are team-wide rather than per monitor. The relay reads a [billing] prefix from the monitor's name and adds it to the tag, so Apprise delivers that monitor's alerts to the billing URLs and the all URLs, and nothing else. Routing stays readable in the monitors list. The alternative, if you'd rather not encode it in names, is one CronAlert team per on-call group, each with its own webhook channel pointing at a different Apprise key.
Monitor Apprise itself
You've just made one self-hosted service the path for every alert, which means it needs a monitor of its own that doesn't depend on it. Apprise API exposes a status endpoint on the same host (/status) that returns 200 while the server is healthy; put an HTTP monitor on it, on the free plan, with your basic-auth credentials in an Authorization header if the proxy requires them. And keep one CronAlert channel that doesn't route through Apprise, such as email or built-in push; channels are team-wide, so it fires alongside the webhook for every incident at no extra cost, and it's the alert that reaches you on the day Apprise's host is the thing that's down.
Frequently asked questions
What does Apprise add over native channels?
One integration for a hundred services, especially the ones without native CronAlert channels: Pushover, Matrix, Signal, Gotify, Pushbullet, SMS gateways, and more. New destinations are one line.
Can CronAlert post to Apprise directly?
No; the payload shapes differ. The twenty-line Worker above translates and adds routing.
How do I route by team?
Tag the destination URLs in Apprise and let the relay set the tag from a name prefix. Or one CronAlert team per group.
Does it need a paid plan?
No. The webhook channel is free; Apprise and the Worker are free.
Is it secure?
The relay verifies CronAlert's signature; Apprise API should sit behind basic auth or a secret path at your proxy, since it has none of its own.
One channel to reach everything
Apprise turns "which services should alerts go to" from a per-tool question into a text file you own. A webhook channel, a Worker, and an Apprise configuration with tags give every monitor's alerts a route to the right people on whatever they actually check. Create a free account and send the first test. Related reading: webhook alert integrations for the other relay recipes, ntfy and Gotify alerts for the direct version of two popular Apprise targets, Mattermost and Rocket.Chat alerts, monitoring Home Assistant, and CronAlert vs Uptime Kuma, whose notification layer is Apprise.