A Slack alert is the right way to find out a site is down. It's a bad way to remember that it happened. Three weeks later, when someone asks how often the checkout API has been flaky this quarter, the answer is buried in a channel between lunch orders and deploy notifications. Teams that track work in Linear or Jira already have the right place for that record: one issue per incident, opened when the monitor goes down, closed with the duration when it recovers, with the postmortem and the fix linked to it.

CronAlert has no dedicated Linear or Jira channel, and doesn't need one. The webhook channel — on the free plan — POSTs a signed JSON payload on every down and recovery event, and every issue tracker has an API or an automation trigger that can turn that into an issue. This post gives you three ways to do it, in order of effort, and then spends most of its time on the three details that make the difference between a useful record and a tracker full of junk: correlation, duplicates, and flapping.

What you're working with

Every event is one POST with this shape (the webhook guide has the full field notes and signature verification):

{
  "event": "monitor.down",            // or "monitor.recovered"
  "monitor": { "name": "Checkout API", "url": "https://api.example.com/health" },
  "incident": { "startedAt": "2026-09-12T14:02:11.000Z", "resolvedAt": null },
  "check": { "statusCode": 503, "responseTime": 412, "errorMessage": null, "region": "us-east" },
  "timestamp": "2026-09-12T14:02:12.318Z"
}

Three facts about it shape everything below. There is no incident ID, but incident.startedAt is the same on the down event and its matching recovery, so monitor.url + incident.startedAt is a reliable correlation key. Delivery is one POST, no retry — respond fast and do the API calls afterwards. And alert channels are team-wide: every enabled channel receives every monitor's events, so if you only want issues for production monitors, filter on monitor.name or monitor.url in whatever receives the webhook (a [prod] name prefix is the low-tech convention).

Option 1: Jira Automation, no code

Jira Cloud's Automation has an Incoming webhook trigger that gives you a URL to paste straight into a CronAlert webhook channel — no middleware at all. Build two rules:

  1. Open. Trigger: Incoming webhook. Condition: {{webhookData.event}} equals monitor.down. Action: Create issue, with summary Down: {{webhookData.monitor.name}}, description using {{webhookData.monitor.url}}, {{webhookData.check.statusCode}}, and {{webhookData.incident.startedAt}}. Put the monitor URL in the summary or a custom field — you need it for the next rule.
  2. Close. Trigger: Incoming webhook (a second URL, or the same one with a branch on event). Action: Lookup issues with JQL like project = OPS AND statusCategory != Done AND summary ~ "{{webhookData.monitor.name}}", then for each: add a comment with {{webhookData.incident.resolvedAt}} and transition to Done.

Limits worth knowing: the incoming-webhook trigger and the number of rule executions per month depend on your Jira plan, so check yours before wiring twenty monitors to it; the signing secret can't be verified inside Automation, so keep the webhook URL private and treat the token in it as the secret. For most small teams on Jira Cloud this is the whole integration and it takes fifteen minutes.

Option 2: Zapier or Make, also no code

Linear has no native inbound generic webhook trigger, so its no-code path runs through an automation tool. In Zapier: Webhooks by Zapier → Catch Hook as the trigger, a Filter on event = monitor.down, then Linear → Create Issue (or Jira Software Cloud → Create Issue) with the payload fields mapped into title and description. For recovery, a second Zap filters on monitor.recovered, uses Linear → Find Issue searching for the monitor name, then Create Comment and Update Issue to the done state. Make's Router does both branches in one scenario. It works; the cost is a per-task bill and correlation that relies on text search rather than a real key. If that bothers you, the next option is sixty lines.

Option 3: a Cloudflare Worker that does it properly

A Worker on the free tier with a KV namespace for the correlation map handles verification, correlation by key, duplicate suppression, and flapping — everything the no-code paths approximate. The Linear version:

// wrangler.toml: [[kv_namespaces]] binding = "INCIDENTS"
// secrets: CRONALERT_SECRET, LINEAR_API_KEY; vars: LINEAR_TEAM_ID, LINEAR_DONE_STATE_ID
const COOLDOWN_MS = 15 * 60 * 1000;

export default {
  async fetch(request, env, ctx) {
    const raw = await request.text();
    if (!(await verify(raw, request.headers.get("X-CronAlert-Signature"), env.CRONALERT_SECRET))) {
      return new Response(null, { status: 401 });
    }
    const a = JSON.parse(raw);
    if (!a.monitor.name.startsWith("[prod]")) return ok();   // channels are team-wide; filter here

    ctx.waitUntil(handle(a, env));                            // acknowledge now, work after
    return ok();
  },
};

async function handle(a, env) {
  const key = `${a.monitor.url}|${a.incident.startedAt}`;
  const recentKey = `recent|${a.monitor.url}`;

  if (a.event === "monitor.down") {
    if (await env.INCIDENTS.get(key)) return;                 // duplicate delivery, same incident

    const recent = await env.INCIDENTS.get(recentKey);        // closed within cooldown? reopen instead
    if (recent) {
      await linear(env, `mutation($id: String!, $body: String!) {
        commentCreate(input: { issueId: $id, body: $body }) { success } }`,
        { id: recent, body: `Down again at ${a.incident.startedAt} (${describe(a.check)}). Flapping — see monitor.` });
      await env.INCIDENTS.put(key, recent, { expirationTtl: 60 * 60 * 24 * 30 });
      return;
    }

    const res = await linear(env, `mutation($input: IssueCreateInput!) {
      issueCreate(input: $input) { issue { id identifier } } }`, {
      input: {
        teamId: env.LINEAR_TEAM_ID,
        title: `Down: ${a.monitor.name}`,
        priority: 1,
        description: `**${a.monitor.url}** went down at ${a.incident.startedAt}.\n\n${describe(a.check)}`,
      },
    });
    await env.INCIDENTS.put(key, res.data.issueCreate.issue.id, { expirationTtl: 60 * 60 * 24 * 30 });
  } else {
    const id = await env.INCIDENTS.get(key);
    if (!id) return;                                          // recovered before we created it, or filtered
    const minutes = Math.round((new Date(a.incident.resolvedAt) - new Date(a.incident.startedAt)) / 60000);
    await linear(env, `mutation($id: String!, $body: String!, $state: String!) {
      commentCreate(input: { issueId: $id, body: $body }) { success }
      issueUpdate(id: $id, input: { stateId: $state }) { success } }`,
      { id, body: `Recovered at ${a.incident.resolvedAt} after ${minutes} min.`, state: env.LINEAR_DONE_STATE_ID });
    await env.INCIDENTS.put(recentKey, id, { expirationTtl: COOLDOWN_MS / 1000 });
  }
}

const ok = () => new Response(null, { status: 204 });
const describe = (c) => c.statusCode ? `HTTP ${c.statusCode}` : (c.errorMessage ?? "no response");

async function linear(env, query, variables) {
  const r = await fetch("https://api.linear.app/graphql", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: env.LINEAR_API_KEY },
    body: JSON.stringify({ query, variables }),
  });
  return r.json();
}

async function verify(raw, given, secret) {
  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 expected = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
  return given?.length === expected.length &&
    [...expected].every((ch, i) => ch === given[i]);   // fine here; use timingSafeEqual where available
}

Notes on the Linear side: a personal API key goes in the Authorization header as-is (OAuth tokens use Bearer); LINEAR_DONE_STATE_ID is the ID of your team's completed workflow state, which you can fetch once with a team { states { nodes { id name type } } } query and look for type: "completed". Field names are current as of this writing — check Linear's API docs if a mutation fails.

The same Worker for Jira Cloud

Swap the linear() helper for three REST calls. Authentication is Basic with email:api-token. The one thing that bites everyone: Jira Cloud's v3 API requires descriptions and comments in Atlassian Document Format, not plain text — a plain string returns a 400.

const adf = (text) => ({ type: "doc", version: 1,
  content: [{ type: "paragraph", content: [{ type: "text", text }] }] });

// create
await jira(env, "POST", "/rest/api/3/issue", { fields: {
  project: { key: env.JIRA_PROJECT_KEY }, issuetype: { name: "Bug" },
  summary: `Down: ${a.monitor.name}`,
  description: adf(`${a.monitor.url} went down at ${a.incident.startedAt}. ${describe(a.check)}`),
}});                                                   // → { id, key }

// resolve: comment, then transition (fetch transitions once to find your Done ID)
await jira(env, "POST", `/rest/api/3/issue/${key}/comment`, { body: adf(`Recovered after ${minutes} min.`) });
await jira(env, "POST", `/rest/api/3/issue/${key}/transitions`, { transition: { id: env.JIRA_DONE_TRANSITION_ID } });

async function jira(env, method, path, body) {
  const r = await fetch(`https://${env.JIRA_SITE}.atlassian.net${path}`, {
    method,
    headers: {
      Authorization: "Basic " + btoa(`${env.JIRA_EMAIL}:${env.JIRA_API_TOKEN}`),
      "Content-Type": "application/json", Accept: "application/json",
    },
    body: JSON.stringify(body),
  });
  return r.status === 204 ? null : r.json();
}

The three details that matter

Correlation

Store the issue ID under monitor.url|incident.startedAt at creation and look it up at recovery. Text-searching the tracker for the monitor name works until two incidents overlap or someone renames the monitor; the key doesn't care. Give the KV entry a 30-day TTL so the map cleans itself.

Duplicates

CronAlert sends one POST per event, but your own retries, a redeploy mid-delivery, or a fire drill can produce a second identical down event. The get(key) check before creating makes the Worker idempotent — the second delivery is a no-op rather than a second issue. This is the single most common bug in webhook-to-ticket integrations and it costs one line.

Flapping

A monitor that goes down for two minutes every hour is a real problem and also the fastest way to make a team ignore the tracker. Each flap is a new incident with a new startedAt, so without the cooldown you'd get 24 issues a day. The recent|url key with a 15-minute TTL turns the next flap into a comment on the just-closed issue instead. Fix the flapping itself too: raise the monitor's failure threshold so a single bad check doesn't open an incident, and read the alert-fatigue guide before you wire every monitor to the tracker.

What to put in the issue

Enough that the person picking it up doesn't have to open CronAlert first: the URL, when it went down, the status code or error, and the region if you run multi-region checks (a single region failing is a different investigation from all five). On recovery, the duration — it's the number every postmortem and SLA report wants and it's right there in the payload. Skip response times and check IDs; nobody reads them in a ticket. If the incident warrants a public note, that lives on the status page, and the issue links to it, not the other way around.

What this is not

An issue tracker does not page anyone. The issue is the record; something else has to get a human's attention at 3 AM. Keep push or email on the same monitors (both free), and if the site's downtime is expensive enough, a real pager — on-call for small teams makes the case for pairing cheap detection with PagerDuty's free tier. And monitor the Worker's own URL with an HTTP check so a broken deploy of your integration doesn't fail silently; monitoring webhook receivers has the pattern.

Frequently asked questions

Does CronAlert integrate with Linear or Jira?

Via the free webhook channel. Jira Automation consumes it directly; Linear via Zapier, Make, or a few lines against its GraphQL API.

How do I match recovery to the issue?

monitor.url + incident.startedAt, which is identical on both events. Store the issue ID under that key.

Will flapping flood the tracker?

Without a cooldown, yes — each flap is a new incident. Comment on the recently closed issue instead, and raise the failure threshold.

Is this a replacement for PagerDuty?

No. Issues are the record; paging is a separate channel. Use both.

Can I do it without running any code?

Jira: yes, with Automation's incoming webhook. Linear: yes, via Zapier or Make. Both lose exact correlation and duplicate suppression compared to the Worker.

One issue per incident, closed with the duration

The chat alert finds the outage; the issue remembers it. Create a free account, add a webhook channel with a signing secret, point it at Jira Automation or a Worker, and trigger a deliberate test of both the down and the recovery event before you trust it. Related reading: the webhook payload and routing recipes, incident response workflows, MTTR and incident metrics, and incident response for small teams.