Skip to content

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.

KindDestinationSigning
slackAn Incoming Webhook URL from an app in your own workspaceNot applicable — Slack does not verify one; the URL authenticates on its own
webhookAny HTTPS endpoint. Receives the raw event payload as JSONOptional 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.

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.

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.

  1. In the dashboard, go to Integrations → Notifications and click Show the app manifest. Copy the JSON.

  2. 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.

  3. 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.

  4. 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.

  5. Switch on the events you want. pr.gate_blocked is the one most people want, and a newly installed channel starts subscribed to nothing — installing must not also mean paging you.

  6. 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.

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.

  1. Create a Slack app at api.slack.com/apps → Create New App → From scratch, and pick the workspace you want to post into.

  2. Turn on incoming webhooks. In the app’s settings, open Incoming Webhooks and toggle Activate Incoming Webhooks.

  3. 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.

  4. 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.

  5. Switch on the events you want, then click Test.

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.

EventFires when
pr.gate_blockedAn analysis set a pull request’s check to failure or pending. The headline event.
finding.needs_reviewA 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.

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:

  • findings is capped — at most 10 in the payload, and the Slack renderer lists 5 before deferring to the dashboard. counts always 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.
  • links keys are omitted rather than null when the corresponding base URL is not configured (see server.public_base_url / server.dashboard_base_url in the reference).
  • title, summary and every string under findings are 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 raw webhook payload is JSON and JSON encoding is the only escaping applied.

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.

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.

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.0

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.

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.

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.