Opsgenie turns an alert into a page: on-call schedules, escalation when nobody acknowledges, and a mobile app that rings through Do Not Disturb. CronAlert detects the downtime; this guide gets the detection into Opsgenie with deduplication, auto-close on recovery, and routing to the right team.

One caveat first. Opsgenie's Alert API wants a GenieKey authorization header and a specific JSON shape, and it closes alerts through a second endpoint. CronAlert's webhook channel sends one fixed JSON document to one URL with an HMAC signature; it has no custom headers or payload templates. So a direct connection doesn't work, and the integration is a small relay in between, which is also where the dedup, close, priority, and routing logic naturally live.

A second caveat, about the destination: Atlassian has announced that Opsgenie is being retired in favor of Jira Service Management's operations features, with end of support scheduled for 2027. If you're setting this up today, know that the relay below is one target URL and one payload away from pointing at the replacement; the CronAlert side won't change.

Step 1: Create an API integration in Opsgenie

  1. In Opsgenie, open Settings → Integrations (or a specific team's integrations page) and add an API integration.
  2. Name it "CronAlert", leave Create and Update Access enabled, and copy the API Key.
  3. Note your API host: api.opsgenie.com, or api.eu.opsgenie.com for EU-resident accounts.

A team-level integration routes everything it creates to that team. A global integration, combined with the responders field the relay sends, lets one key route to many teams; the routing section below uses that.

Step 2: Deploy the relay

Create a Cloudflare Worker and set secrets with wrangler secret put: CRONALERT_WEBHOOK_SECRET (a random string you'll also enter in CronAlert), OPSGENIE_API_KEY, and optionally OPSGENIE_HOST for EU. Deploy:

// CronAlert webhook → Opsgenie Alert API relay: create on down, close 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 host = env.OPSGENIE_HOST ?? "api.opsgenie.com";
    const headers = { "Content-Type": "application/json", "Authorization": `GenieKey ${env.OPSGENIE_API_KEY}` };
    const alias = `cronalert:${a.monitor.url}`;           // stable per monitor → dedup + close target

    // Optional routing + priority 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) => !["p1", "p2", "p3"].includes(t));
    const priority = tags.find((t) => /^p[1-3]$/.test(t))?.toUpperCase() ?? "P2";

    let res;
    if (a.event === "monitor.down") {
      res = await fetch(`https://${host}/v2/alerts`, {
        method: "POST", headers,
        body: JSON.stringify({
          message: `${a.monitor.name} is DOWN`.slice(0, 130),
          alias,
          description: `${a.monitor.url}\n${a.check.statusCode ?? a.check.errorMessage ?? "no response"}${a.check.region ? ` from ${a.check.region}` : ""}\nSince ${a.incident.startedAt}`,
          priority,
          source: "CronAlert",
          tags: ["uptime", ...tags],
          details: { url: a.monitor.url, status_code: String(a.check.statusCode ?? ""), error: a.check.errorMessage ?? "", region: a.check.region ?? "" },
          ...(team ? { responders: [{ name: team, type: "team" }] } : {}),
        }),
      });
    } else {
      res = await fetch(`https://${host}/v2/alerts/${encodeURIComponent(alias)}/close?identifierType=alias`, {
        method: "POST", headers,
        body: JSON.stringify({ source: "CronAlert", note: `Recovered after ${minutes(a.incident)} min` }),
      });
    }
    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));
}

The fields CronAlert sends are documented in webhook alert integrations. Returning 502 when Opsgenie rejects a request surfaces the failure in CronAlert's notification log rather than losing it.

Step 3: Add the webhook channel in CronAlert

  1. Open Alert Channels, add a Webhook channel named Opsgenie.
  2. Paste the Worker URL, enter the signing secret, save. Those are the channel's only two settings.
  3. Send test alert; an alert should appear in Opsgenie and page the integration's team.

The channel is team-wide: every monitor in the CronAlert team now reaches Opsgenie through it. Nothing is attached per monitor.

Deduplication and auto-close

Opsgenie deduplicates on alias: a create whose alias matches an open alert adds a note to it instead of opening another. The relay's alias is stable per monitor, so each monitor has at most one open Opsgenie alert. On monitor.recovered the relay calls the close endpoint for that alias with the outage duration in the note, so the alert closes itself and the on-call engineer sees how long it lasted. CronAlert emits exactly one down and one recovered event per incident, so dedup only matters for a flapping monitor, which collapses to a single alert's activity log; reducing false positives covers stopping the flapping itself.

Routing and priority without per-monitor settings

CronAlert alert channels belong to a team, not to individual monitors, so the routing has to come from somewhere else (how alert routing works). Two options:

  • One CronAlert team per Opsgenie team. Teams can be created on every plan (inviting members needs the Team plan); each gets a webhook channel and a Worker pointed at that team's integration key. Simple and visible.
  • One team, routing in the relay. The Worker reads [tag] prefixes from the monitor name: a team name becomes Opsgenie's responders (requires a global API integration), and [p1] to [p3] overrides the default P2 priority. [billing] [p1] Checkout API pages the Billing team at P1; [platform] Internal wiki pages Platform at P2. The routing is readable in the monitors list.

On priority: P2 as the default for production is the common choice, with Opsgenie's escalation bumping unacknowledged alerts; reserve P1 for the handful of monitors whose failure is a customer-facing incident by definition, and route staging to a team whose Worker sets P4 or P5, or simply don't send staging to Opsgenie at all.

Testing both paths

  1. curl a create request with a test alias straight at /v2/alerts with your GenieKey, confirm the page, then call the close endpoint for the alias and confirm it closes.
  2. Use CronAlert's Send test alert on the channel and confirm an alert appears.
  3. Point a spare monitor at a URL returning 500, confirm the real alert, fix it, and confirm the close. Recoveries are where relays break, because they carry resolvedAt and 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 outage can't take your paging with it.

Frequently asked questions

Is there a native Opsgenie channel?

No. The relay creates on down and closes on recovered, with the GenieKey header Opsgenie requires.

How do alerts deduplicate and close?

Stable alias per monitor; create opens or annotates, the close endpoint resolves it on recovery.

Can monitors page different teams?

Yes: one CronAlert team per Opsgenie team, or responders set from a name prefix in the relay. Channels are team-wide.

Is Opsgenie going away?

Atlassian has announced its retirement with migration into Jira Service Management; the relay pattern carries over with a new target.

Is any of this on a paid plan?

No. The webhook channel is free, and the Worker fits Cloudflare's free tier.

Get started

An API key, a Worker, a webhook channel, and three tests, and every downtime incident becomes a page that closes itself when the monitor recovers. Create a free CronAlert account and begin with the test curl. Related reading: PagerDuty alerts (native channel, no relay), Splunk On-Call alerts, webhook alert integrations, on-call for small teams, and incident response workflows. The same relay shape, with firing and resolved events instead of create and close, works for incident.io.