Alert channels: Slack, PagerDuty, OpsGenie and webhooks
The four alert channel types, what each receives, how dispatch failures are reported, testing a channel, and composing escalation from several rules.
Channel types
Every alert rule names one channel, and a firing dispatches to that channel alone. Rules are created and edited through shield-alerting, described in Alert thresholds and cooldowns; this page covers what each channel receives.
Who can use it. A tenant admin with a dashboard session. The function is console-only, so channel configuration happens in the dashboard or through supabase.functions.invoke("shield-alerting"), never against api.passkeybridge.io.
Exactly four channel types exist. Any other value is refused when the rule is created or updated, with 400 Invalid channel_type. Valid: slack, pagerduty, opsgenie, webhook.
channel_type | Required channel_config key | Destination |
|---|---|---|
slack | webhook_url | your incoming webhook, which must start with https://hooks.slack.com/ |
pagerduty | routing_key | fixed https://events.pagerduty.com/v2/enqueue |
opsgenie | api_key | fixed https://api.opsgenie.com/v2/alerts |
webhook | webhook_url | any public HTTPS endpoint that passes the SSRF check |
Dispatch happens after the firing is recorded, so a channel outage never suppresses the shield_alert_history row or the cooldown. The outcome is reported per firing as dispatch_ok, and on failure dispatch_reason names the cause: missing_url, blocked_url, missing_routing_key, missing_api_key, unsupported_channel, timeout, network_error, or http_ followed by the status code. Outbound calls abort after ten seconds.
Slack
Channel type slack. The URL must be a Slack incoming webhook: the dispatcher requires HTTPS and the https://hooks.slack.com/ prefix, and refuses anything else with blocked_url rather than posting your alert somewhere unexpected.
{
"channel_type": "slack",
"channel_config": { "webhook_url": "https://hooks.slack.com/services/T000/B000/xxxx" }
}The message posts as username "PasskeyBridge Alert" and carries two blocks: a section with a bracketed severity label ([CRITICAL], [WARNING] or [INFO]), the rule name, the metric, the current value and the threshold; and a context line with the severity and the firing timestamp. Plain text labels, no emoji.
Setup: in Slack, create an incoming webhook for the target channel, copy its URL into channel_config.webhook_url, then run test_channel before you rely on the rule. Separate webhooks for a warning channel and a critical channel let you route by severity with two rules on the same metric.
PagerDuty
Channel type pagerduty. The dispatcher sends an Events API v2 trigger to the fixed endpoint https://events.pagerduty.com/v2/enqueue.
{
"channel_type": "pagerduty",
"channel_config": { "routing_key": "R0123456789ABCDEF" }
}routing_key is the integration key and is the only key read. There is no fallback to webhook_url: a config without routing_key fails with missing_routing_key, and a webhook_url you set alongside it is ignored, including as a destination.
| PagerDuty field | Value |
|---|---|
payload.summary | [PasskeyBridge] {rule_name}: {metric} = {value} |
payload.severity | critical for a critical rule, warning for warning and info |
payload.source | passkeybridge |
payload.component | the metric name, for example high_risk_count |
payload.custom_details | the whole alert payload, including rule_id, operator, threshold and fired_at |
event_action | trigger |
Setup: create a PagerDuty service, add an Events API v2 integration, copy the integration key into routing_key. The dashboard's inline rule form labels the field "PagerDuty routing key"; the alert rule wizard labels it "Routing Key".
OpsGenie and generic webhooks
OpsGenie is a first-class channel. Channel type opsgenie, with the API key in channel_config.api_key. The dispatcher posts to https://api.opsgenie.com/v2/alerts and sets the Authorization: GenieKey header itself, so no proxy or integration platform is needed.
{
"channel_type": "opsgenie",
"channel_config": { "api_key": "<opsgenie-api-integration-key>" }
}Severity maps to priority: critical to P1, warning to P3, info to P5. The message repeats the summary line used for PagerDuty, source is passkeybridge, and every payload field is stringified into details. The dedup alias combines the rule id with the firing timestamp, so each cooldown window opens its own alert instead of folding into an alert that is still open.
Generic webhooks. Channel type webhook posts the alert payload as JSON to your URL, with Content-Type: application/json and no extra headers. The URL passes the same SSRF check as playbook callbacks: public HTTPS host, no private ranges, no raw IP addresses. Datadog, a queue, or your own receiver all use this type.
{
"rule_id": "<uuid>",
"rule_name": "High risk spike",
"metric": "high_risk_count",
"operator": ">",
"threshold": 10,
"current_value": 14,
"tenant_id": "<uuid>",
"severity": "critical",
"fired_at": "2026-09-16T14:22:00.000Z"
}If your receiver needs a custom header, put a small endpoint of your own in front of it; the dispatcher sends no configurable headers for this type.
Testing a channel
test_channel sends one synthetic notification, either with a config you pass (before a rule exists) or, with rule_id, with a stored rule's config opened from the vault, without evaluating the rule.
const { data, error } = await supabase.functions.invoke("shield-alerting", {
body: {
action: "test_channel",
tenant_id: "<uuid>",
channel_type: "slack",
channel_config: { webhook_url: "https://hooks.slack.com/services/T000/B000/xxxx" },
},
});The test payload is fixed: rule name "Test Alert", metric test, current value 42, threshold 0, severity info, and the current timestamp. In Slack that renders as an [INFO] message, in PagerDuty as a warning-severity incident, in OpsGenie as a P5 alert.
The action reports the real outcome.
| Status | Body | Meaning |
|---|---|---|
| 200 | {"status":"sent","channel_type":"slack"} | the receiver answered 2xx |
| 400 | {"status":"failed","channel_type":"...","reason":"missing_routing_key","error":"Test notification not delivered (missing_routing_key)"} | the config itself is wrong: missing_url, blocked_url, missing_routing_key, missing_api_key, unsupported_channel |
| 400 | {"error":"Missing channel_type"} or {"error":"Missing channel_config"} | the body is incomplete and names no rule_id |
| 400 | {"status":"failed","reason":"vault_unreadable",...} | the stored secret could not be opened; re-enter it with update_rule |
| 404 | {"error":"Rule not found"} | rule_id is not a rule of this organization |
| 502 | {"status":"failed","channel_type":"...","reason":"http_404","error":"Test notification not delivered (http_404)"} | the receiver rejected it, timed out, or could not be reached |
In the dashboard, the lightning-bolt control beside each rule in Dashboard > Observability > Alert rules runs the same test by rule_id, so the stored secret never travels to the browser.
Escalation patterns
There is no built-in escalation chain. Compose one from rules, since every matching rule fires independently with its own cooldown.
Graduated severity. Three rules on error_rate, each with its own channel and cooldown: a low threshold to Slack at warning with a long cooldown, a middle threshold to PagerDuty at critical, a high threshold to OpsGenie at critical with a short cooldown. The lower rules keep firing while the higher ones do, which is what makes the ladder visible in the history.
Multi-channel broadcast. Several rules with the same metric and threshold and different channel_type values. One breach notifies all of them in the same tick.
Quiet hours. Keep a warning Slack rule always active and switch the critical PagerDuty rule off and on with update_rule and is_active. Automating that switch needs a caller holding a tenant-admin session, because the function has no API-key lane; a scheduled job with only an API key cannot do it.
Two things to keep in mind when composing. Evaluation runs every five minutes, so a cooldown shorter than that does not make alerts arrive faster. And each rule's dispatch outcome is independent: one channel failing leaves the others delivered, and the failure shows as dispatch_ok: false on that rule's entry in the evaluate response.