12 min read

Opsgenie vs PagerDuty for Audit Alert Routing

Teams choosing an incident platform for audit alerts usually expect the decision to matter more than it does. Both Opsgenie and PagerDuty accept an event, group it by an identifier you supply, escalate through a schedule, and close when told to — and every hard part of the integration stays on the audit side regardless. This page is part of Integrating Audit Alerts with Incident Management and compares the two where they genuinely differ.

Four things the platform never owns Four stacked responsibilities that stay on the audit side. Deciding whether a breach is real, by requiring it to survive several consecutive runs. Building a stable identity from metric, segment and environment. Choosing severity from the score band. And emitting the close event when the metric recovers, because no incident platform can see the underlying measurement. Each note explains why the platform cannot do it. Confirmation survive N consecutive runs first the platform sees events, not trends Identity hash of metric, segment, environment the platform groups by what you send Severity derived from the score band the platform routes, it does not judge Closing emit resolve when the metric recovers nothing else can see your metric Every one of these stays on your side of the boundary whichever platform you pick, which is why the choice matters far less than teams expect it to.

Comparison Table

Concern Opsgenie PagerDuty
Credential scope API key, per integration Routing key, per service
Deduplication field alias dedup_key
Open / update semantics Same alias updates the open alert Same dedup key updates the open incident
Close semantics Close action against the alias event_action: "resolve" with the same key
Ownership model Responder team, with its own schedules Service, with its own escalation policy
Extra context details map plus tags custom_details object
Priority levels P1 to P5 critical, error, warning, info
Rate limiting Per integration Per routing key
Terraform support Official provider Official provider
Acknowledged-but-open state Yes Yes

The mapping is close enough that a well-built emitter is portable by renaming four fields. What is not portable is an emitter whose identity field was assembled carelessly — that breaks the same way on both sides.

Six primitives, two vocabularies Two columns pairing the same six concerns under each platform vocabulary. Opsgenie identifies an integration by an API key, deduplicates on an alias, groups by tags and a team, closes with a close action on the alias, escalates through an escalation policy attached to a team, and rate-limits per integration. PagerDuty uses a routing key, a dedup key, custom details and a service, an event action of resolve, an escalation policy attached to a service, and rate-limits per routing key. The verdicts note that the concepts map one to one. OPSGENIE API key per integration scoped to one integration, not the account alias — the dedup identity same alias updates, does not duplicate tags + responder team how grouping and ownership are expressed close action on the alias the alert closes, not a new one opens escalation on the team schedules attached to the responder rate limit per integration a burst of alerts hits one ceiling PAGERDUTY routing key per service scoped to one service, not the account dedup_key — the same idea same key updates the open incident custom_details + service the same grouping, different field event_action: resolve same key, or nothing closes escalation on the service schedules attached to the service rate limit per routing key the same burst problem, same shape The concepts map one to one, which is the useful conclusion: an integration written against either platform can be ported by renaming fields, provided the identity field was built from identity in the first place.

One emitter, two adapters

#!/usr/bin/env python3
# /opt/audit/incidents/emit.py
# The judgement stays here; the adapter only renames fields.
from __future__ import annotations
import hashlib
import os
from dataclasses import dataclass

import httpx


@dataclass(frozen=True)
class Breach:
    metric: str
    segment: str
    environment: str
    severity: str        # critical | error | warning | info
    value: float
    run_id: str


def identity(b: Breach) -> str:
    """Stable across runs by construction: nothing here varies per run.
    The value and the run id are deliberately excluded."""
    raw = f"{b.metric}|{b.segment}|{b.environment}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]


class PagerDutyAdapter:
    URL = "https://events.pagerduty.com/v2/enqueue"

    def send(self, b: Breach, *, action: str) -> int:
        payload = {
            "routing_key": os.environ["PD_ROUTING_KEY"],
            "event_action": action,                 # trigger | resolve
            "dedup_key": identity(b),
            "payload": {
                "summary": f"{b.metric} on {b.segment} ({b.environment})",
                "severity": b.severity,
                "source": "site-health-audit",
                "custom_details": {"value": b.value, "run_id": b.run_id},
            },
        }
        return httpx.post(self.URL, json=payload, timeout=10).status_code


class OpsgenieAdapter:
    BASE = "https://api.opsgenie.com/v2/alerts"
    PRIORITY = {"critical": "P1", "error": "P2",
                "warning": "P3", "info": "P4"}

    def send(self, b: Breach, *, action: str) -> int:
        headers = {"Authorization": f"GenieKey {os.environ['OG_API_KEY']}"}
        if action == "resolve":
            url = f"{self.BASE}/{identity(b)}/close?identifierType=alias"
            return httpx.post(url, headers=headers, json={}, timeout=10).status_code
        payload = {
            "message": f"{b.metric} on {b.segment} ({b.environment})",
            "alias": identity(b),
            "priority": self.PRIORITY[b.severity],
            "responders": [{"name": os.environ["OG_TEAM"], "type": "team"}],
            "details": {"value": str(b.value), "run_id": b.run_id},
            "tags": [b.environment, b.segment],
        }
        return httpx.post(self.BASE, headers=headers, json=payload, timeout=10).status_code

The shape is the point. identity is shared, defined once, and excludes everything that varies between runs — which is the property both platforms depend on and neither can supply. The adapters contain no judgement at all: they rename fields and map a severity vocabulary, and swapping one for the other is a configuration change.

Verification

set -euo pipefail
export ADAPTER="${1:-pagerduty}"

# 1. A trigger opens exactly one incident.
python3 -m audit.incidents.emit --adapter "$ADAPTER" --action trigger   --metric lcp_p75 --segment /checkout/ --env production --severity critical
sleep 5

# 2. An identical trigger updates rather than duplicating.
python3 -m audit.incidents.emit --adapter "$ADAPTER" --action trigger   --metric lcp_p75 --segment /checkout/ --env production --severity critical
sleep 5

# 3. A resolve with the same identity closes it.
python3 -m audit.incidents.emit --adapter "$ADAPTER" --action resolve   --metric lcp_p75 --segment /checkout/ --env production --severity critical
echo "Now confirm in the platform UI: exactly one incident, now closed."

Expected result is one incident that opened, updated once, and closed — on either adapter, with an identical sequence of commands. Running the same script against both platforms is the cheapest possible portability test, and it is the one that catches an identity field that is not actually stable.

Failure Modes

Three failures both platforms share Three rows. An identity built from anything that changes between runs fragments one regression into many incidents on either platform. A missing close event leaves incidents open indefinitely on either platform, because neither can see the metric. And a burst of simultaneous breaches exceeds a per-key rate limit on either platform, which is fixed by batching rather than by switching vendor. FAILURE ON EITHER PLATFORM FIX One regression becomes fifty separate incidents in one evening Identity is unstable a timestamp or a score value leaked into the key Hash identity only metric, segment, environment — nothing that varies per run Incidents never close long after the metric has fully recovered No close event is emitted neither platform can see the measurement behind the alert Emit on recovery from the run that confirms it, with the identical key A broad regression returns rate-limit errors from the events API One call per segment exceeds the per-key ceiling in a synchronised burst Batch by severity one incident per severity per run, segments in the details None of these is a platform defect and none is fixed by migrating. They are properties of the emitter, which is why the emitter is worth building carefully once.

A 202 or a 200 arrives and nobody is paged

Both platforms acknowledge acceptance of an event separately from routing it, so a successful status code confirms only that the event was queued. On PagerDuty the usual cause is an escalation policy with no active schedule; on Opsgenie it is a responder team with an empty on-call rotation. Verify in the platform's own incident list rather than by status code, on both.

Migration breaks the close path first

When switching platforms, triggers keep working and resolves silently stop, because the close semantics differ more than the open ones — a resolve on PagerDuty is another event to the same endpoint, while on Opsgenie it is a different endpoint keyed by alias. Test the close path explicitly before cutting over, or the first week of the migration accumulates open incidents nobody notices.

Alert volume looks different after a migration

Usually a severity-mapping artefact rather than a change in the underlying findings: error and warning map onto P2 and P3, and if the escalation policies attached to those priorities differ from the previous service, the same events produce a different number of pages. Compare policies, not just severities, and keep the confirmation and cooldown logic described in the parent guide unchanged across the cutover so the emitter is not a variable.

FAQ

Does the choice of incident platform matter much for audit alerting?

Less than most teams expect. The concepts map one to one — a routing key is an API key, a dedup key is an alias, a resolve event is a close action — and every genuinely hard part of the integration stays on the audit side regardless: deciding whether a breach is real, building a stable identity, choosing severity, and emitting the close when the metric recovers. Pick on the basis of what the organisation already runs, and spend the effort on the emitter.

What breaks first during a migration between the two?

The close path. Triggers translate almost directly, so they keep working, while resolves differ more: on PagerDuty a resolve is another event posted to the same endpoint, and on Opsgenie it is a separate endpoint keyed by alias. The result is a migration where alerts still open correctly and quietly stop closing, so the first week accumulates open incidents that nobody attributes to the cutover. Test the close path explicitly before switching.

Why does a successful API response not mean someone was paged?

Because both platforms separate accepting an event from routing it. A 202 or 200 confirms the event was queued for processing, not that it reached a service or team with an active on-call schedule attached. The common causes differ in name and not in substance: an escalation policy with no schedule on PagerDuty, a responder team with an empty rotation on Opsgenie. Verify in the platform incident list during setup rather than trusting the status code.