Notifications
A pull request’s report is posted as a PR comment and a commit status. Both are only seen by someone already looking at the pull request. Notification channels are how the same events reach people who aren’t — a Slack channel, or any endpoint you want to wire up yourself.
An org can have as many channels as it needs (20 by default, see Operator settings), each subscribing to whichever events it cares about. Two teams watching two different Slack channels is the case this exists for.
Channel kinds
Section titled “Channel kinds”| Kind | Destination | Signing |
|---|---|---|
slack | An Incoming Webhook URL from an app in your own workspace | Not applicable — Slack does not verify one; the URL authenticates on its own |
webhook | Any HTTPS endpoint. Receives the raw event payload as JSON | Optional HMAC-SHA256 |
Microsoft Teams, Discord and PagerDuty are not supported yet. Each is a renderer against the same payload rather than a new subsystem, so the shape below is what they will consume when they land.
Connecting Slack
Section titled “Connecting Slack”There is a Slack app involved either way — an incoming webhook is a feature of an app, not a standalone object. But it is yours, in your own workspace. OpenTremor registers nothing with Slack, holds no OAuth app of its own, and never sees your workspace.
Two ways in. The guided one is better if this deployment has a public URL; the manual one works everywhere, including air-gapped.
Guided (recommended)
Section titled “Guided (recommended)”Set up the app once, then each channel is two clicks — you pick it on Slack’s own screen and no URL ever passes through your clipboard.
-
In the dashboard, go to Integrations → Notifications and click Show the app manifest. Copy the JSON.
-
At api.slack.com/apps, choose Create New App → From a manifest, pick your workspace, and paste it. The app arrives with incoming webhooks already enabled and this deployment’s redirect URL already registered — the two settings that otherwise get mistyped.
-
Open the new app’s Basic Information page, copy the Client ID and Client Secret back into the dashboard, and save. The secret is encrypted at rest and never shown again.
-
Click Add to Slack. Slack asks which channel to post to; choose one and click Allow. You land back on the notifications page with the channel created and named after itself.
-
Switch on the events you want.
pr.gate_blockedis the one most people want, and a newly installed channel starts subscribed to nothing — installing must not also mean paging you. -
Click Test to confirm delivery.
Repeat step 4 for each additional Slack channel: one install posts to one channel, which is Slack’s rule, not ours.
Manual (any deployment)
Section titled “Manual (any deployment)”The guided flow needs server.public_base_url set, because Slack redirects a browser back here
and this server exchanges a code with slack.com. An air-gapped install can never use it —
this path is the one to use there, and it is fully supported rather than a legacy route.
-
Create a Slack app at api.slack.com/apps → Create New App → From scratch, and pick the workspace you want to post into.
-
Turn on incoming webhooks. In the app’s settings, open Incoming Webhooks and toggle Activate Incoming Webhooks.
-
Add a webhook to the workspace. Click Add New Webhook to Workspace and choose the channel. Slack hands back a URL like
https://hooks.slack.com/services/T000/B000/xxxxxxxx. It is bound to that one channel — to post into a second channel, repeat this step for another webhook. -
In the dashboard, go to Integrations → Notifications, click Add a channel by URL, pick Slack, and paste it. Give it a label that names the Slack channel — with a pasted URL that label is the only thing distinguishing two channels, because the URL is never displayed again.
-
Switch on the events you want, then click Test.
Events
Section titled “Events”What a channel may subscribe to depends on what is installed — GET /notifications/events
returns the catalogue this deployment can actually emit, which is what the dashboard builds its
switches from.
| Event | Fires when |
|---|---|
pr.gate_blocked | An analysis set a pull request’s check to failure or pending. The headline event. |
finding.needs_review | A finding entered needs_review — from low model confidence, a rule marked requires_review, or a manual triage action. |
Both are on by default for a new channel.
pr.gate_blocked fires only when the check did not go green. A notification for every
successful analysis is a separate, deliberately chatty event that does not exist yet.
The payload
Section titled “The payload”Every kind renders from one envelope. A webhook channel receives it verbatim, so this is the
contract to write an integration against:
{ "event": "pr.gate_blocked", "org_id": "org_...", "timestamp": "2026-09-09T12:34:56.000000+00:00", "title": "acme/infra#412: check failure — 2 blocking findings", "severity": "CRITICAL", "summary": "1 CRITICAL, 1 HIGH across 12 analysed resources", "subject": "acme/infra#412", "links": { "dashboard": "https://dashboard.example.com/findings?namespace=acme%2Finfra%23412", "external": "https://github.com/acme/infra/pull/412" }, "findings": [ { "rule_id": "S3-001", "severity": "CRITICAL", "title": "Bucket is publicly readable", "resource_address": "aws_s3_bucket.logs" } ], "counts": {"critical": 1, "high": 1, "medium": 0, "low": 0, "resources": 12}}Notes on reading it:
findingsis capped — at most 10 in the payload, and the Slack renderer lists 5 before deferring to the dashboard.countsalways reflects the full set.- Findings are post-triage: anything suppressed or marked a false positive is already gone, so a notification never reports something a reviewer has dismissed.
linkskeys are omitted rather than null when the corresponding base URL is not configured (seeserver.public_base_url/server.dashboard_base_urlin the reference).title,summaryand every string underfindingsare model output derived from the content under analysis. Escape them for wherever you render them; OpenTremor escapes for the destinations it renders itself, but a rawwebhookpayload is JSON and JSON encoding is the only escaping applied.
Verifying a signature
Section titled “Verifying a signature”For a webhook channel with a secret configured, every request carries
X-OpenTremor-Signature: sha256=<hex> — HMAC-SHA256 over the exact request body, the same
scheme GitHub uses inbound:
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header)Compute it over the raw bytes, not over a re-serialized copy of the parsed JSON — key order and whitespace will not survive the round trip.
Delivery guarantees
Section titled “Delivery guarantees”Best-effort, and deliberately so. A slow or failing destination never fails, delays or blocks the analysis that triggered it. There is no retry queue: a delivery that fails is recorded on the channel and gone. The dashboard shows each channel’s last delivery outcome, and that is the place to notice a broken one.
If a notification must not be lost, subscribe a webhook channel pointed at something you
control and durable-queue it on your side.
Operator settings
Section titled “Operator settings”File/env only, never editable from inside the product — the same privilege boundary as
llm.custom_endpoints. An admin who could widen the egress policy from the dashboard could
widen it to the cloud metadata endpoint.
notifications: egress: enabled: true # false disables notification channels entirely allow_private_networks: false # true to reach a destination inside your own network allowed_hosts: [] # exact hostnames or "*.suffix"; empty means any permitted address max_channels_per_org: 20 timeout_seconds: 5.0What the guided Slack install needs
Section titled “What the guided Slack install needs”server.public_base_url must be set — Slack redirects a browser back to
<public_base_url>/notifications/slack/callback, and that URL is registered on the customer’s
app via the manifest. This server also needs outbound access to slack.com to exchange the
authorization code.
Those two calls are to fixed, known hosts rather than customer-supplied addresses, so they are
deliberately not subject to notifications.egress — that policy exists to govern where a
customer can point us. A deployment that firewalls slack.com simply cannot use the guided
flow and should use the manual path.
Nothing here requires an operator-registered Slack app: each organization brings its own, so there is no app to distribute, no Slack Marketplace listing, and no review to pass.
Egress
Section titled “Egress”notifications.egress is a separate policy from llm.custom_endpoints, on purpose. The two
answer different questions and deployments routinely want opposite answers: a cloud instance
ships llm.custom_endpoints.enabled: false — every org uses the hosted providers’ own
endpoints — while still needing to reach hooks.slack.com. Sharing one policy would couple
“I run my own model” to “I can send a finding to an address inside my network”.
The defaults are the safe ones: any public address, no private networks. A self-hosted
deployment notifying an internal Mattermost or an internal webhook must set
allow_private_networks: true — link-local (169.254.0.0/16, fe80::/10) stays blocked
regardless, because that is where cloud instance metadata lives.
A destination URL is checked against this policy twice: when it is saved, so you get an explanatory error instead of a channel that silently never delivers, and again on every delivery, because a hostname that resolved to a permitted address at save time can resolve somewhere else later.
Relationship to the needs-review webhook
Section titled “Relationship to the needs-review webhook”The older review webhook — one endpoint, one event, one per org — still works and is still supported. It fires alongside channels rather than being replaced by them, and there is nothing to migrate.
Prefer a notification channel for anything new: it can reach Slack, there can be more than one, and it subscribes to events rather than being hardwired to a single one.