CronAlert has native channels for the places most teams want alerts — Slack, Discord, email, push, and on Pro Teams, Telegram, PagerDuty, and Opsgenie. For everywhere else, there's the webhook channel: a signed JSON POST on every down and recovery event, included on the free plan. This post documents the payload exactly, shows how to verify it, and walks through five routing recipes — including the one thing that trips people up, which is that chat apps want their own JSON shape and need a small transformer in between.
The payload
When a monitor goes down or recovers, CronAlert sends one POST to the URL you configured, with Content-Type: application/json and User-Agent: CronAlert-Webhook/1.0:
{
"event": "monitor.down",
"monitor": {
"name": "Checkout API",
"url": "https://api.example.com/health"
},
"incident": {
"startedAt": "2026-09-09T14:02:11.000Z",
"resolvedAt": null
},
"check": {
"statusCode": 503,
"responseTime": 412,
"errorMessage": null,
"region": "us-east"
},
"timestamp": "2026-09-09T14:02:12.318Z"
} Field notes, so you can build on it without guessing:
eventismonitor.downormonitor.recovered. Branch on this first.incident.resolvedAtisnullon a down event and an ISO timestamp on recovery — so recovery events carry the incident's full duration, which is what most "post to a channel" integrations want to display.check.statusCode,responseTime(milliseconds),errorMessage, andregiondescribe the check that triggered the transition. Any of them can benull— a timeout has no status code, a heartbeat monitor has no response time, a single-region check has no region.- Delivery is a single POST. A non-2xx response is recorded as a failed delivery for that channel. Respond fast, do slow work afterwards, and keep a second channel on anything critical.
Verifying the signature
If you enter a signing secret when creating the channel (the field is optional), every request carries X-CronAlert-Signature: the hex-encoded HMAC-SHA256 of the raw request body. Verify it before trusting anything in the payload — a webhook URL is a public endpoint, and without verification anyone who guesses it can page you. Two rules: read the raw body bytes before parsing (a re-serialized body won't match), and use a constant-time comparison.
// Node (Express with raw body)
import { createHmac, timingSafeEqual } from "node:crypto";
app.post("/alerts", express.raw({ type: "application/json" }), (req, res) => {
const expected = createHmac("sha256", process.env.CRONALERT_SECRET)
.update(req.body) // Buffer of the raw body
.digest("hex");
const given = req.get("X-CronAlert-Signature") ?? "";
if (given.length !== expected.length ||
!timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
return res.status(401).end();
}
const alert = JSON.parse(req.body.toString());
res.status(204).end(); // acknowledge first...
queueMicrotask(() => handle(alert)); // ...then do the work
}); # Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.post("/alerts")
def alerts():
raw = request.get_data() # bytes, before any parsing
expected = hmac.new(os.environ["CRONALERT_SECRET"].encode(), raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(request.headers.get("X-CronAlert-Signature", ""), expected):
abort(401)
alert = request.get_json(force=True)
enqueue(alert)
return "", 204 Recipe 1: Zapier — into 7,000 apps with no code
Create a Zap with the Webhooks by Zapier → Catch Hook trigger. Zapier gives you a URL; paste it into a new CronAlert webhook channel, attach the channel to a monitor, and trigger a test alert (pause a monitor's target, or use a deliberate fire drill). Zapier captures the sample payload and exposes every field — event, monitor name, incident startedAt — as variables for the action step. From there it's whatever Zapier can do: a Google Sheets row per incident, a Trello card, a Twilio SMS, a Notion database entry. Add a Filter step on event if you only want down events (or only recoveries) to proceed.
Recipe 2: Make — same idea, with routing
Add a Webhooks → Custom webhook module, copy its URL into CronAlert, and send a test event so Make can determine the data structure. Make's Router is the useful bit: one branch on event = monitor.down that creates a ticket, another on monitor.recovered that closes it. Because incident.resolvedAt arrives on the recovery, you can compute the duration in a single Make formula and put it in the closing note.
Recipe 3: n8n — self-hosted, with a transformer built in
Add a Webhook node (method POST), activate the workflow, and use the production URL in CronAlert. If you set a signing secret, verify it in a Code node before anything else — n8n exposes the raw body when the Webhook node's "Raw Body" option is on. n8n is also the easiest place to reshape the payload for services that want their own format, which brings us to the two recipes that need it.
Recipe 4: Google Chat — needs a transformer
Google Chat's incoming webhooks accept a specific JSON shape: at minimum a top-level text field. CronAlert's payload doesn't have one, so a Chat webhook URL pasted straight into CronAlert produces nothing (Chat returns a 400, which CronAlert logs as a failed delivery). Put a transformer in the middle. Any of the tools above can do it; the leanest option is a Cloudflare Worker, which is free at this volume and about twenty lines:
export default {
async fetch(request, env) {
const raw = await request.text();
// Verify CronAlert's signature (see above), then reshape:
const a = JSON.parse(raw);
const down = a.event === "monitor.down";
const text = down
? `🔴 *${a.monitor.name}* is DOWN — ${a.check.statusCode ?? a.check.errorMessage ?? "no response"}\n${a.monitor.url}`
: `🟢 *${a.monitor.name}* recovered after ${minutes(a.incident)} min`;
await fetch(env.GOOGLE_CHAT_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
return new Response(null, { status: 204 });
},
};
const minutes = (i) => Math.round((new Date(i.resolvedAt) - new Date(i.startedAt)) / 60000); The same pattern — verify, reshape, forward — works for any chat or ticketing tool with an incoming-webhook URL: Mattermost, Rocket.Chat, Zulip, Linear, and so on. The only thing that changes is the JSON you build.
Recipe 5: ntfy — push to your phone without an app account
ntfy is the developer-favorite way to get a push notification from anything: publish to a topic, subscribe on your phone. It wants either a plain-text body posted to https://ntfy.sh/your-topic or JSON with topic and message fields at the root — again, not CronAlert's shape. Reuse the Worker above and swap the forwarding call:
await fetch("https://ntfy.sh/your-topic", {
method: "POST",
headers: {
"Title": down ? `${a.monitor.name} is down` : `${a.monitor.name} recovered`,
"Priority": down ? "high" : "default",
"Tags": down ? "rotating_light" : "white_check_mark",
},
body: text,
}); (CronAlert also has native push notifications on the free plan, if you'd rather not run anything. ntfy is for people who already live in it.)
Four practical notes
- Test both events. Most integrations are built against a down payload and break on recovery because
resolvedAtis suddenly non-null or a status code is suddenly absent. Fire one of each before you trust it. - Don't build the on-call layer in Zapier. Routing an alert to a spreadsheet is a fine use of a webhook; escalating to a human at 3 AM is a pager's job. On-call for small teams covers pairing cheap detection with a free PagerDuty tier.
- Monitor the receiver. A webhook receiver is itself an endpoint that can go down, and if it does, your alert pipeline fails silently. Point an HTTP monitor at its health route — monitoring webhook receivers has the pattern.
- Keep a boring channel too. Email or push on the same monitor costs nothing and survives the day your Worker deploy is broken.
Frequently asked questions
What's in the payload?
An event (monitor.down / monitor.recovered), the monitor's name and URL, incident start/resolution times, the triggering check's status code, response time, error, and region, and a timestamp. Optional HMAC-SHA256 signature header.
Why don't Google Chat or ntfy show anything?
They expect their own JSON shape. Use a transformer — Zapier, Make, n8n, or the Worker above. Slack, Discord, Teams, Telegram, PagerDuty, and Opsgenie don't need one; they have native channels.
Are deliveries retried?
One POST per event; non-2xx is logged as a failed delivery. Acknowledge fast, work later, and keep a second channel on anything critical.
Is the webhook channel free?
Yes — webhook, email, Slack, Discord, and push are all on the free plan.
One payload, any destination
Native channels cover the common cases; the webhook covers the rest, and the recipe is always the same three steps: catch, verify, reshape if the destination is picky. Create a free account, add a webhook channel with a signing secret, and send yourself a test event before you build anything on top of it. Related reading: configuring incident response workflows, reducing alert fatigue, monitoring webhook receivers, and monitoring Zapier, Make, and n8n automations themselves.