Notification connector
Included from Basic tier
An instance-level channel connector: the
admin-configured egress a user's or team's connector-backed notification
route rides. Users pick routes; only platform administrators configure where
platform data may go — a webhook route can only target URLs inside a
connector's allowlist, and a slack/teams route delivers to the connector's
own stored URL, never an arbitrary one. The surface is
NotificationAdminService (API v2, PostgreSQL-backed), gated on the
/admin updatable determination except
ListNotificationConnectorOptions, the id/name/kind picker any
authenticated caller reads.
Kinds
webhook— the generic HMAC-signed egress: admin-allowlisted HTTPS targets, per-route target URL and payload template.slack/teams(never an entitlement) — native chat channels. The connector stores the service's incoming-webhook URL, which is a capability secret: possession of the URL is the authenticator, so it is held in the same encrypted write-only slot as the webhook kind's signing secret (webhook_urlon create/update,webhook_url_setin responses) and is never echoed by any response, audit row, error or log. HMAC signing, the URL allowlist, default headers and per-route targets/templates do not apply to these kinds — the payload shape is implied by the kind. Validation is per kind: aslackURL must carry a Slack incoming-webhook host (hooks.slack.com, orhooks.slack-gov.comfor GovSlack); ateamsURL must be a Teams Workflows webhook (the modern Power Automate lane) — the retired Office 365 connector hosts (*.webhook.office.com,outlook.office.com) are refused, since Microsoft has retired that lane.
Fields
| Field | Type | Meaning |
|---|---|---|
kind | string | webhook, slack or teams. Fixed at create. |
name | string | Unique instance-wide. |
url_allowlist | string[] | Webhook kind only. The HTTPS URL prefixes deliveries may target. Each entry is a bare https:// prefix (host required; no credentials, query or fragment). A target must match an entry's host (and port) exactly and extend its path on a segment boundary. |
default_headers_json | string | Webhook kind only. A JSON object of non-secret header values added to every delivery (e.g. a routing key). The reserved delivery headers (Content-Type, X-OpsChain-*) cannot be overridden. Secrets do not belong here — the signing secret is the authenticator. |
signing_secret_set | bool | Webhook kind: whether a signing secret is stored. The secret itself is write-only: supplied on create (required) or update (rotation), stored encrypted with the server's encryption keys, never echoed by any response, audit row or log. A keyless server refuses to store one (typed ERROR_REASON_SERVER_NOT_CONFIGURED). |
webhook_url_set | bool | Slack/teams kinds: whether an incoming-webhook URL is stored. The URL itself is write-only with exactly the signing secret's storage posture (encrypted, rotation via update, keyless server refused). |
enabled | bool | A disabled connector leaves the options picker, stops matching at dispatch and dead-letters queued deliveries. |
Bounds: 50 connectors per instance; name ≤ 255 characters. A connector any notification route still references refuses deletion.
Delivery security posture
- Admin allowlist — webhook targets (route config and the test verb
alike) must fall inside the connector's
url_allowlist; HTTPS only. A slack/teams delivery has exactly one possible target — the connector's stored URL — so no allowlist exists for those kinds. - SSRF hygiene — a DNS name resolving to a loopback, link-local
(the cloud metadata range included), unspecified or multicast address is
refused; a literal IP (or
localhost) allowlist entry is the explicit admin opt-in that permits such an address. The connection dials the vetted address, so a rebinding resolver cannot swap the target between check and dial. Redirects are never followed; the delivery times out at 10s. - HMAC signing — every webhook payload carries
X-OpsChain-TimestampandX-OpsChain-Signature: v1=hex(HMAC-SHA256(secret, "<timestamp>.<body>"))so receivers authenticate the sender and reject replays by timestamp. Unsigned webhook deliveries are never sent. HMAC does not apply to the slack/teams kinds — neither service verifies a sender signature, and the capability URL is the authenticator — so a chat delivery carries noX-OpsChain-*headers at all. - Allowlisted content — payloads render from the notification row's allowlisted field set only (event type, classification, summary, node path, thread key, deep link) — never event payloads, resolved secrets or PII.
- Retry / dead-letter — transient failures retry with backoff
(30s/2m/8m/30m, 5 attempts) before the row dead-letters as
delivery_status: failedwith the reason indelivery_error— an HTTP refusal records the status plus the leading printable slice of the target's answer (e.g. Slack'starget answered HTTP 400: invalid_payload), so a shape mismatch is legible from the delivery evidence; permanent refusals (target outside the allowlist, disabled or removed connector, a connector whose kind no longer matches the route, keyless server) dead-letter immediately.
Payload shapes
A slack connector posts a Slack Block Kit document: a plain-text
fallback, a header naming the event, an mrkdwn section with the severity
(and state) and the rendered summary, the node path as context, and the
deep link (linked when absolute, as context text when it is a bare GUI
path). A teams connector posts a Teams Workflows message envelope
carrying one Adaptive Card (schema 1.4): title, summary text, a severity/
node FactSet and an Action.OpenUrl back to the GUI for an absolute deep
link. Both render from the same allowlisted field set as the webhook
templates.
A webhook route picks one of three shapes (template in the route
config; the test verb accepts the same):
generic(default) —{"version":"1","id":…,"event_type":…,"severity":…, "state":…,"object_kind":…,"summary":…,"node_path":…,"thread_key":…, "deep_link":…,"created_at":…,"test":…}.slack— a Slack-compatible incoming-webhook document:textfallback plus onemrkdwnsection block.teams— a Teams-compatibleMessageCard(summary,title,text, severity theme colour, node/link facts).
The webhook kind's Slack/Teams-compatible templates remain for customers
fronting Slack, Teams, PagerDuty or ServiceNow behind their own signed
webhook endpoints; the native slack/teams kinds are the simpler lane
when the admin can paste the service's incoming-webhook URL directly.
Richer native behaviour (threading, update-in-place) is planned
for a later release.
Point a Slack/Teams incoming-webhook URL at its own kind, not at a
generic webhook connector. Slack and Teams only accept their own body
shapes, so a webhook connector aimed at an incoming-webhook URL fails on
every delivery whose route keeps the default generic template — Slack
answers HTTP 400: invalid_payload, now captured verbatim in the row's
delivery_error. The trap's signature is a green Test with a silent
channel: the test verb's template is chosen in the test dialog
independently of any route's stored template, so a test sent with the
slack template proves the connector reaches Slack while routes configured
with generic keep failing. The native kinds close the trap — they carry
no template, so their test verb posts exactly the shape real deliveries
use (the same renderer end to end): a green slack/teams test proves the
lane.
The test verb takes no arguments for a slack/teams connector — it posts the fixed test payload, in the kind's own shape, to the stored webhook URL.
Switching an existing connector's kind is a supported update: supply
kind together with the target kind's secret material (webhook_url for
slack/teams; signing_secret + url_allowlist for webhook) — e.g.
re-pointing a webhook connector that already fronts a Slack URL at the
native slack kind by pasting the same URL as webhook_url. The switch
clears the old kind's fields; routes still bound to the connector under the
old channel kind stop matching (queued rows dead-letter with
connector kind no longer matches the route) and are re-created on the
new kind.
Mutations record api:notification_connectors:create|update|destroy and
api:notification_connectors:test audit events (names, ids, the test
target and outcome — never headers or secrets, and never a slack/teams
connector's stored URL, which is a capability secret).