incident.io treats an alert as a thing with a lifecycle: it fires, it may fire again, and it resolves, and everything downstream (escalations, the incident it may open, the on-call page) hangs off that one object. CronAlert's view of an outage is the same shape: one monitor.down event when a monitor fails, one monitor.recovered when it comes back. The two just speak different JSON. This guide puts a twenty-line Cloudflare Worker between them so a CronAlert incident becomes a single incident.io alert that fires on down, carries the useful detail, and resolves itself on recovery.
Everything here is on the free plan: CronAlert's webhook channel is available on every plan, and the Worker fits Cloudflare's free tier with room to spare.
How the two sides fit
CronAlert's webhook channel has exactly two settings, a URL and an optional signing secret, and sends one POST per transition with a JSON body: the event name, the monitor's name and URL, the incident's start and (on recovery) resolution time, and the check that triggered it. The webhook integrations guide documents the payload and the X-CronAlert-Signature header field by field.
incident.io's HTTP alert source accepts POST https://api.incident.io/v2/alert_events/http/<alert_source_config_id> with a Bearer token (the source's secret) and a JSON body whose required fields are status (firing or resolved) and title, plus a deduplication_key, an optional Markdown description, a source_url, and free-form metadata that alert routes can match on. It answers 202 Accepted. Events sharing a deduplication key within a source belong to one alert: a second firing updates it, a resolved resolves it.
So the relay has three jobs: verify the signature, translate the event, and pick a deduplication key that is stable per monitor.
Step 1: create an HTTP alert source in incident.io
- In incident.io, open Alerts → Sources and create a new source of type HTTP. Name it CronAlert.
- Copy the source's config ID (the last path segment of the endpoint it shows you) and its secret token. The relay needs both.
- Leave the source's attribute mapping for now; after the first event arrives you can map
metadata.team,metadata.url, andmetadata.regionto alert attributes and build routes on them.
Step 2: deploy the relay
Create a Worker, paste the code below, and set three secrets with wrangler secret put: CRONALERT_WEBHOOK_SECRET (a random string you will also give CronAlert), INCIDENTIO_SOURCE_ID, and INCIDENTIO_TOKEN.
// CronAlert webhook → incident.io HTTP alert source: firing on down, resolved on recovered
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";
// Optional routing from a name prefix like "[billing] Stripe webhooks" or "[p1] Checkout"
const tags = [...a.monitor.name.matchAll(/\[([a-z0-9-]+)\]/gi)].map((m) => m[1].toLowerCase());
const team = tags.find((t) => !/^p[1-4]$/.test(t)) ?? "default";
const priority = tags.find((t) => /^p[1-4]$/.test(t))?.toUpperCase() ?? "P2";
const detail = a.check.statusCode ?? a.check.errorMessage ?? "no response";
const body = {
status: down ? "firing" : "resolved",
title: `${a.monitor.name} is ${down ? "DOWN" : "back up"}`,
deduplication_key: `cronalert:${a.monitor.url}`, // stable per monitor → one alert per outage
description: down
? `**${a.monitor.url}**\n\n${detail}${a.check.region ? ` from ${a.check.region}` : ""}\n\nSince ${a.incident.startedAt}`
: `Recovered after ${minutes(a.incident)} min.`,
source_url: a.monitor.url,
metadata: { team, priority, url: a.monitor.url, region: a.check.region ?? "", status_code: String(a.check.statusCode ?? "") },
};
const res = await fetch(`https://api.incident.io/v2/alert_events/http/${env.INCIDENTIO_SOURCE_ID}`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.INCIDENTIO_TOKEN}` },
body: JSON.stringify(body),
});
return new Response(null, { status: res.ok ? 204 : 502 }); // 502 → CronAlert logs a failed delivery
},
};
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));
} Three details worth noticing. The signature is checked against the raw body before parsing, with a constant-time comparison, because a webhook URL is public and anyone who guesses it could otherwise page your on-call. The deduplication key is the monitor URL, not the name, so renaming a monitor mid-incident does not orphan the alert. And the Worker returns 502 when incident.io does not accept the event, so the failure shows up in CronAlert's notification log for the channel instead of vanishing.
Step 3: add the webhook channel in CronAlert
- Open Alert Channels, add a Webhook channel named incident.io.
- Paste the Worker URL, enter the same signing secret you gave the Worker, save. Those are the channel's only two settings.
- Send test alert; a firing alert titled with the test monitor's name should appear in incident.io within a second or two.
The channel is team-wide: every monitor in this CronAlert team now reaches incident.io through it. Nothing is attached per monitor, which is what the next section is about.
Deduplication and auto-resolve
incident.io groups events by deduplication_key within a source. The relay's key is stable per monitor, so each monitor has at most one open alert. CronAlert emits exactly one monitor.down and one monitor.recovered per incident, so in normal operation an outage is one firing followed by one resolved, and the alert closes itself with the outage duration in its description. A flapping monitor collapses into one alert's timeline rather than a page per transition, which is the behavior you want while you fix the flap.
Note what the relay does not do: it does not create incidents directly. Whether a firing alert becomes an incident, who gets paged, and how fast it escalates are incident.io's alert routes and escalation paths, configured once on the source. That is the right split: CronAlert says what is down, incident.io decides what that means for people.
Routing and priority without per-monitor settings
CronAlert alert channels belong to a team, not to individual monitors, so routing has to come from somewhere else (how alert routing works). Two options:
- One CronAlert team per incident.io alert source. Teams can be created on every plan (inviting members needs the Team plan); each gets a webhook channel and a Worker whose secrets point at that team's source, and the source's routes send it to the right escalation path. Simple and visible.
- One team, routing by metadata. The Worker above reads
[tag]prefixes from the monitor name intometadata.teamandmetadata.priority. In incident.io, map those metadata keys to alert attributes on the source, then build alert routes on them: [billing] [p1] Checkout API routes to the Billing escalation at P1; [platform] Internal wiki routes to Platform at P2. The routing is readable in the monitors list.
Staging monitors should not reach incident.io at all; keep them in a CronAlert team whose channels are email only. Monitoring staging covers why.
Testing both paths
curlafiringevent with a testdeduplication_keystraight at the alert source with your Bearer token, confirm the alert appears, then send aresolvedevent with the same key and confirm it resolves.- Use CronAlert's Send test alert on the channel and confirm an alert appears.
- Point a spare monitor at a URL returning 500, confirm the real alert and any page it triggers, fix the URL, and confirm the alert resolves with the duration. Recoveries are where relays break, because they carry
resolvedAtand no status code; the Worker above handles both branches.
Put an HTTP monitor on the Worker itself, per monitoring webhook receivers, and keep email or push enabled as a second channel so a relay or incident.io outage cannot take your paging with it.
Frequently asked questions
Is there a native incident.io channel?
No. The relay sends firing on down and resolved on recovered, with the Bearer token incident.io's HTTP source requires.
Why not post straight to incident.io's Slack or email ingestion instead?
You could send CronAlert's Slack alerts into a channel incident.io watches, but you lose the resolve, the deduplication key, and the metadata that routes use. The HTTP source gives you a proper alert lifecycle for the same amount of setup.
Can one outage open an incident automatically?
Yes, in incident.io: configure the alert route for this source to create incidents for the attributes you choose (P1, a given team). The relay only sends alerts.
How do I include the status page or runbook link?
Add it to description or metadata in the Worker; both accept arbitrary text, and description renders Markdown. A per-monitor runbook can be derived from the monitor URL's hostname with a small lookup table in the Worker.
Is any of this on a paid plan?
No. The webhook channel is free on CronAlert, and the Worker fits Cloudflare's free tier. incident.io's own plan determines what its alerts can do.
Get started
An HTTP alert source, a Worker, a webhook channel, and three tests, and every downtime incident becomes an incident.io alert that fires on down and resolves itself on recovery. Create a free CronAlert account and begin with the test curl. Related reading: PagerDuty alerts (native channel, no relay), Opsgenie alerts and Splunk On-Call alerts for the same relay against other tools, opening Linear or Jira issues from alerts, incident response workflows, and on-call for small teams.