Endpoints: Integrations, Resources & Reports
Integrations
Section titled “Integrations”Two ways an org connects a GitHub App: the platform App — one shared identity (App ID + private key + webhook secret, set once by the operator via config.github) that an org installs on its own repos and registers by installation_id — or a custom App the org registers for itself via the manifest flow, whose credentials are encrypted at rest per org and whose installation_id is captured automatically. Every webhook-secret lookup and installation-token mint prefers an org’s own App when present, falling back to config.github otherwise. On pull_request events, the PR’s diff is analysed with terraform-code-change and the result posted back as a PR comment + commit status. There is no GitLab support yet, and no live GitHub App testing is possible without real credentials. See GitHub App Integration for the full walkthrough of both paths.
POST /orgs/{org_id}/integrations/github
Section titled “POST /orgs/{org_id}/integrations/github”Connect (or reconnect) a GitHub App installation to this org. Requires org-admin. Upserts LLM config; installation_id is optional — pass it to overwrite the stored value (the platform-App path), or omit it to leave whatever’s already stored untouched (the custom-App path, where it’s captured automatically from GitHub’s installation webhook event instead).
Request body
{"installation_id": "12345678", "llm_backend": "anthropic", "model": "claude-sonnet-5", "rule_type": null}llm_backend/model are used for every webhook-triggered analysis — there’s no interactive caller to supply these per request, unlike POST .../analyze. The org must also have a stored LLM credential for llm_backend (see POST /orgs/{org_id}/llm-credentials) — webhooks always use the stored credential, never an inline key. model is checked against the allowed-models catalog at save time (400 if rejected) and re-checked before every webhook-triggered run, since a model saved here can be disabled later.
Response 201
{ "installation_id": "12345678", "llm_backend": "anthropic", "model": "claude-sonnet-5", "rule_type": null, "created_at": "2026-07-09T00:00:00Z", "github_app": null}github_app is {"app_id", "app_slug", "html_url"} when this org has a custom App connected, else null — never the private key or webhook secret.
GET /orgs/{org_id}/integrations/github
Section titled “GET /orgs/{org_id}/integrations/github”Current config. Requires org-admin. 404 if nothing is connected.
DELETE /orgs/{org_id}/integrations/github
Section titled “DELETE /orgs/{org_id}/integrations/github”Disconnect everything — installation, LLM config, and any custom App credentials. Requires org-admin. 404 if nothing is connected.
POST /orgs/{org_id}/integrations/github/manifest
Section titled “POST /orgs/{org_id}/integrations/github/manifest”Start the GitHub App manifest flow for a custom, org-owned App. Requires org-admin.
Response 200
{ "manifest": {"name": "acme-review-analyzer", "hook_attributes": {"url": "..."}, "redirect_url": "...", "...": "..."}, "state": "a1b2c3...", "target_url": "https://github.com/settings/apps/new"}state is a single-use, 10-minute CSRF token GitHub round-trips back to the callback below. manifest/state are meant to be POSTed as a real top-level browser form submission to target_url, not fetched — see the linked walkthrough for why.
501 if config.server.public_base_url isn’t set — the manifest’s webhook/redirect URLs need a real, reachable HTTPS address.
DELETE /orgs/{org_id}/integrations/github/app
Section titled “DELETE /orgs/{org_id}/integrations/github/app”Drop this org’s custom App credentials only — installation_id and LLM config are left untouched. Requires org-admin. 404 if no custom App is connected. Does not delete the App on GitHub’s side.
GET /integrations/github/manifest/callback
Section titled “GET /integrations/github/manifest/callback”GitHub’s redirect target after an admin creates a custom App — not session/API-key authenticated; the single-use state token (minted by the trigger endpoint above) identifies which org/admin this belongs to. Exchanges code for the new App’s identity, encrypts and stores it, and redirects (307) to {dashboard_base_url or public_base_url}/integrations/github?app_created=1.
| Status | Description |
|---|---|
307 | App created, credentials stored, redirecting back to the dashboard |
400 | Invalid, expired, or already-consumed state; or the code exchange with GitHub failed |
POST /integrations/github/webhook
Section titled “POST /integrations/github/webhook”The webhook receiver GitHub calls directly — configure this URL as the App’s webhook endpoint. Verified via the X-Hub-Signature-256 header, not session/API-key auth — but which secret to verify against is resolved per-request: the org is identified from the still-unverified installation.id (or installation.app_id, for the very first installation event a brand-new custom App receives) purely to pick which stored secret to try, falling back to config.github.webhook_secret when no integration matches. The signature check itself is what actually gates any action; an unrecognised or forged id just fails verification.
Only pull_request events with action in opened, synchronize, or reopened trigger analysis. installation events with action created/deleted (auto-)connect/disconnect that integration’s installation_id — this is how a custom App’s installation_id gets set without a manual paste. Everything else — other event types, other actions, an installation nobody registered here — is acknowledged with 200 and ignored, since GitHub expects a fast 2xx response regardless.
Response 202 (analysis triggered)
{"job_id": "b7e2...", "status": "queued"}Poll GET /jobs/{job_id} for completion — the job’s trigger field is "github_webhook" and external_ref carries {"provider": "github", "repo": "owner/repo", "pr_number": 42}.
| Status | Description |
|---|---|
200 | Ignored (unhandled event/action), acknowledged (installation created/deleted), or no integration registered for this installation |
202 | Analysis job queued |
401 | Missing or invalid X-Hub-Signature-256 for the resolved secret |
501 | Neither the matched integration’s own App nor the platform’s config.github is configured |
Resources
Section titled “Resources”GET /resource/{hash_resource}
Section titled “GET /resource/{hash_resource}”Fetch a stored resource by content hash.
| Parameter | Description |
|---|---|
hash_resource | Content hash returned by ingest |
| Parameter | Type | Description |
|---|---|---|
filter | string (optional) | Return only this top-level field: value, analysis, or hash |
Response 200
{ "hash": "a3f1c2d4e5b6", "value": {"type": "aws_db_instance", "name": "orders", "action": "create", "body": "…"}, "analysis": null}PUT /resource/{hash_resource}/analysis
Section titled “PUT /resource/{hash_resource}/analysis”Store the LLM analysis for a resource.
| Parameter | Description |
|---|---|
hash_resource | Content hash |
Request body — application/json
{ "summary": "S3 bucket exposes public read access.", "findings": [ { "title": "Public S3 bucket", "severity": "HIGH", "rule_id": "TFSEC-040", "risk": "Data exposure to the internet", "why_it_matters": "Anyone can read all objects in this bucket.", "suggested_next_step": "Enable S3 Block Public Access.", "confidence": "HIGH", "evidence": ["storage_encrypted = false"], "resource_address": "aws_db_instance.orders", "tags": ["s3", "public-access"] } ]}| Field | Type | Required | Description |
|---|---|---|---|
summary | string | Yes | One-paragraph summary |
findings | array | Yes (≥ 1) | Security findings |
| Field | Type | Description |
|---|---|---|
title | string | Short finding title |
severity | string | CRITICAL / HIGH / MEDIUM / LOW / INFO |
rule_id | string | Rule identifier from the ruleset |
risk | string | What goes wrong if not fixed |
why_it_matters | string | Business / security impact |
suggested_next_step | string | Actionable remediation |
confidence | string | HIGH / MEDIUM / LOW |
evidence | string[] | Exact lines from the resource body |
resource_address | string | Terraform address (e.g. aws_db_instance.orders) |
tags | string[] | Classification tags |
| Status | Description |
|---|---|
200 | Analysis stored — returns updated resource |
404 | Resource not found |
422 | Missing or empty summary / findings |
GET /{namespace}/resource/next
Section titled “GET /{namespace}/resource/next”Fetch the next resource in a namespace that has not been analysed yet. The response includes the applicable rules inline, resolved from the analyzer that produced the resource.
| Parameter | Type | Default | Description |
|---|---|---|---|
rule_type | string | (none) | Rule sub-category (e.g. aws). Passed to the analyzer’s get_rules(). |
| Status | Description |
|---|---|
200 | Resource document ({hash, value, rules, analysis: null}) |
208 | All resources in this namespace have been analysed |
The rules field contains the Markdown ruleset the LLM must apply when analysing value.body. It is null if the originating analyzer cannot be determined.
GET /namespaces
Section titled “GET /namespaces”List every namespace with at least one resource in the caller’s org, each as {namespace, created_at}, newest first. A namespace is created implicitly by whatever ingest/analyze call first used it — this is the only way to discover what exists without already knowing the string, and is what the dashboard’s Resources page uses to populate its namespace picker.
[ { "namespace": "prod-deploy-42", "created_at": "2026-07-15T08:59:40.890869+00:00" }, { "namespace": "staging-deploy-7", "created_at": "2026-07-10T14:12:03.221000+00:00" }]GET /{namespace}/dump
Section titled “GET /{namespace}/dump”List all resource documents registered under a namespace (including analysis).
GET /{namespace}/report
Section titled “GET /{namespace}/report”Generate a consolidated security report. The analyzer name(s) shown in the report are auto-detected from metadata.analyzer on each resource.
When the namespace contains resources from multiple analyzers, the report groups findings by analyzer and adds an Analyzer column to the summary tables.
Each finding is joined against its triage status, if any. By default, findings triaged as suppressed or false_positive are dropped from the report entirely; acknowledged findings stay visible with a status badge/column.
| Parameter | Type | Default | Description |
|---|---|---|---|
analyzer | string | (auto) | Deprecated — auto-detected from resources. Kept as fallback for resources without metadata.analyzer. |
format | string | markdown | markdown, markdown_light, or html |
template_id | string | (none) | Render with one specific report template from this org, overriding both format (the template’s own format wins) and the org’s default template for that format |
raw | bool | false | true → plain text body; false → JSON wrapper |
include_suppressed | bool | false | true → also show findings triaged as suppressed/false_positive |
| Status | Content-Type | Description |
|---|---|---|
200 (raw=false) | application/json | {"markdown_report": "…"} or {"html_report": "…"} |
200 (raw=true) | text/markdown or text/html | Report text directly |
404 | — | template_id given but no such template exists in this org |
422 | — | The resolved template’s Jinja2 source failed to render (syntax error, undefined variable, or a SandboxedEnvironment security error) |
Report templates
Section titled “Report templates”report_templates (/orgs/{org_id}/report-templates/…) let an org compose its own Jinja2 report layouts instead of using the three built-in templates (markdown, markdown_light, html) unconditionally. Every new org gets the three built-ins seeded automatically as editable, non-default rows (is_builtin_seed: true) — POST /orgs/{org_id}/report-templates/seed-builtin is the idempotent manual backdoor for orgs that predate this feature.
POST /orgs/{org_id}/report-templates
Section titled “POST /orgs/{org_id}/report-templates”Requires admin role. template_id is derived from name and is immutable; format is also immutable once set — it determines the variables/escaping contract source is written against.
Request body
{ "name": "Slack-friendly summary", "format": "markdown", "source": "# {{ namespace }}\n{{ resources | length }} resources analysed.", "is_default": false}| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | — |
format | string | — | markdown, markdown_light, or html — immutable after creation |
source | string | — | Jinja2 template source |
is_default | bool | false | If true, becomes this org’s default template for format |
Response 201
{ "template_id": "slack-friendly-summary", "org_id": "8f2a...", "name": "Slack-friendly summary", "format": "markdown", "source": "# {{ namespace }}\n{{ resources | length }} resources analysed.", "is_default": false, "is_builtin_seed": false, "created_by": "u1...", "created_at": "2026-01-01T00:00:00Z", "updated_at": null}GET /orgs/{org_id}/report-templates
Section titled “GET /orgs/{org_id}/report-templates”Any member. Filter by format and/or default state.
| Parameter | Type | Description |
|---|---|---|
format | string | markdown, markdown_light, or html |
is_default | bool | Filter by default state |
Response 200 — {"templates": [...]}, each shaped like the create response above.
GET /orgs/{org_id}/report-templates/{template_id}
Section titled “GET /orgs/{org_id}/report-templates/{template_id}”Any member. 404 if not found.
PATCH /orgs/{org_id}/report-templates/{template_id}
Section titled “PATCH /orgs/{org_id}/report-templates/{template_id}”Requires admin role. Partial update — template_id/format never change. Setting is_default: true unsets any other default this org has for the same format — at most one default per (org, format).
| Status | Description |
|---|---|
200 | Updated — returns the full template, same shape as create |
404 | Template not found |
422 | Malformed update |
DELETE /orgs/{org_id}/report-templates/{template_id}
Section titled “DELETE /orgs/{org_id}/report-templates/{template_id}”Requires admin role. If it was the org’s default, reports for that format silently fall back to the built-in template.
| Status | Description |
|---|---|
200 | Deleted |
404 | Template not found |
POST /orgs/{org_id}/report-templates/seed-builtin
Section titled “POST /orgs/{org_id}/report-templates/seed-builtin”Requires admin role. (Re-)imports the three built-in templates as editable, non-default rows. Idempotent — skips any template_id already present. Every imported row is is_default: false — existing report output shouldn’t change until this org explicitly promotes one.
Response 200
{"created": 3}POST /orgs/{org_id}/report-templates/preview
Section titled “POST /orgs/{org_id}/report-templates/preview”Any member. Renders a draft source (not persisted) against namespace’s real, already-analysed resources — the same data assembly GET /{namespace}/report uses.
Request body
{"source": "# {{ namespace }}\n...", "format": "markdown", "namespace": "prod-deploy-42"}Response 200
{"content": "# prod-deploy-42\n...", "content_type": "text/markdown"}| Status | Description |
|---|---|
200 | Rendered |
404 | namespace has no resources yet |
422 | source failed to render (see the sandboxing note above) |
Report links
Section titled “Report links”markdown/html format calls to GET /{namespace}/report never mint a link (nothing in
their own output needs one) — only markdown_light does, since it’s the one format whose
text embeds an “Open the full report” link.
GET /reports/{token}
Section titled “GET /reports/{token}”Full HTML report — publicly accessible, no authentication. token is opaque and
high-entropy (only its SHA-256 is stored server-side, same pattern as API keys).
| Status | Description |
|---|---|
200 | Rendered HTML report |
404 | Token unknown or expired — both cases respond identically, on purpose |
PATCH /orgs/{org_id}/report-link-ttl
Section titled “PATCH /orgs/{org_id}/report-link-ttl”Requires admin role. Sets how long links minted after this call stay valid — existing outstanding links keep whatever expiry they were minted with.
Request body
{"ttl_hours": 24}ttl_hours accepts 1–720 (30 days). Default is 24.
Response 200
{"report_link_ttl_hours": 24}Notification channels
Section titled “Notification channels”Where an org’s analysis events are delivered. Unlike the review webhook — one endpoint, one event, one per org — an org holds many channels, each with its own destination and its own event subscription. See Notifications for the conceptual guide, the payload shape and the operator settings.
Every endpoint below requires admin role except the event catalogue, which any member may read.
GET /notifications/events
Section titled “GET /notifications/events”The event catalogue this deployment can emit — what a channel may subscribe to. Requires any
org member. A deployment with no analyzers installed returns [], which is correct rather
than degraded: with nothing analysing, there is nothing to be notified about.
Response 200
[ {"name": "pr.gate_blocked", "description": "A pull request's check was set to failure or pending by an analysis", "default_on": true}, {"name": "finding.needs_review", "description": "A finding entered needs_review and is waiting on a human decision", "default_on": true}]POST /orgs/{org_id}/notifications/channels
Section titled “POST /orgs/{org_id}/notifications/channels”Create a channel.
Request body
{ "label": "Security — #sec-alerts", "kind": "slack", "target_url": "https://hooks.slack.com/services/T000/B000/xxxxxxxx", "secret": null, "events": ["pr.gate_blocked"], "enabled": true}| Field | Type | Default | Description |
|---|---|---|---|
label | string | — | 1–80 chars. The only thing distinguishing two channels, since the URL is never shown. |
kind | "slack" | "webhook" | — | Which renderer formats the payload |
target_url | string | — | Write-only. Validated against notifications.egress at save time — a 422 here names the address rule that refused and the setting an operator would change. |
secret | string | null | null | kind: "webhook" only. HMAC-SHA256 signing key, sent as X-OpenTremor-Signature. Slack neither expects nor verifies one. Write-only. |
events | string[] | [] | Validated against the catalogue above — an unknown name is a 422, not a silently stored subscription that matches nothing. |
enabled | bool | true | — |
Response 201
{ "channel_id": "ch_3f9a1c7e2b4d8e60", "label": "Security — #sec-alerts", "kind": "slack", "target_url_hint": "hooks.slack.com/services/…", "has_secret": false, "events": ["pr.gate_blocked"], "enabled": true, "created_at": "2026-09-09T12:00:00Z", "updated_at": null, "last_delivery": null}409 if the org is already at notifications.max_channels_per_org (20 by default).
GET /orgs/{org_id}/notifications/channels
Section titled “GET /orgs/{org_id}/notifications/channels”Every channel for this org, oldest first. Returns an array of the object above.
GET /orgs/{org_id}/notifications/channels/{channel_id}
Section titled “GET /orgs/{org_id}/notifications/channels/{channel_id}”One channel. 404 if no such channel in this org.
PATCH /orgs/{org_id}/notifications/channels/{channel_id}
Section titled “PATCH /orgs/{org_id}/notifications/channels/{channel_id}”Partial update — omitted fields are left alone, unlike the review webhook’s replacing PUT.
{"label": "Platform — #plat-alerts", "events": ["pr.gate_blocked", "finding.needs_review"], "enabled": false}target_url and secret both keep their stored value when omitted. To remove a signing secret
without setting a new one, pass "clear_secret": true — omitting secret deliberately does
not clear it, so renaming a channel cannot silently downgrade its deliveries to unsigned.
DELETE /orgs/{org_id}/notifications/channels/{channel_id}
Section titled “DELETE /orgs/{org_id}/notifications/channels/{channel_id}”Remove it. 404 if none. The destination URL is not recoverable afterwards.
POST /orgs/{org_id}/notifications/channels/{channel_id}/test
Section titled “POST /orgs/{org_id}/notifications/channels/{channel_id}/test”Deliver a synthetic notification and report what actually happened. The one delivery in the product that is not fire-and-forget — its whole purpose is to answer “does this URL work”, so it returns the real outcome instead of swallowing it.
Ignores the channel’s event subscriptions and its enabled flag, so a channel can be proven
before it is switched on. The result is recorded as the channel’s last_delivery like any
other.
Response 200
{"ok": false, "status_code": 404, "error": "Endpoint returned HTTP 404"}status_code is null when nothing was ever sent — an egress refusal, a DNS failure, a
connection error. That distinction is the first thing to look at: “we refused to call it” and
“it answered 404” are different problems.
Slack install (guided)
Section titled “Slack install (guided)”An organization’s own Slack app, installed through Slack’s OAuth flow so the admin picks a
channel on Slack’s consent screen instead of copying a webhook URL. No app of the operator’s
is involved — each org registers its own, so there is nothing to distribute and no Slack
Marketplace review. Requires server.public_base_url; without it every endpoint below answers
501 and the manual POST .../channels path is the one to use.
GET /orgs/{org_id}/notifications/slack/manifest
Section titled “GET /orgs/{org_id}/notifications/slack/manifest”A Slack app manifest for this org to paste into Create New App → From a manifest. Comes back
with incoming-webhook as the only scope, incoming webhooks enabled, and this deployment’s
redirect URL registered.
{ "manifest": { "display_information": {"name": "OpenTremor · Acme"}, "oauth_config": {"…": "…"} }, "target_url": "https://api.slack.com/apps", "redirect_url": "https://api.example.com/notifications/slack/callback"}PUT|GET|DELETE /orgs/{org_id}/notifications/slack/app
Section titled “PUT|GET|DELETE /orgs/{org_id}/notifications/slack/app”The org’s client_id and client_secret, from the Slack app’s Basic Information page. The
secret is encrypted at rest and never returned; client_id is public (it travels in the
authorize URL) and does come back. DELETE leaves already-installed channels working — a
webhook URL outlives the OAuth grant that produced it — and only prevents further installs.
POST /orgs/{org_id}/notifications/slack/install
Section titled “POST /orgs/{org_id}/notifications/slack/install”Returns where to send the admin’s browser. 404 if no app is stored yet.
{"authorize_url": "https://slack.com/oauth/v2/authorize?…", "state": "otr_…"}GET /notifications/slack/callback
Section titled “GET /notifications/slack/callback”Where Slack redirects once the admin picks a channel. Public and state-authenticated — a redirect from a third party carries no session, so the single-use token minted above is what identifies the org and admin. It is stored only as a SHA-256 hash and consumed on first use, so a replayed callback creates nothing.
On success a channel is created with provenance: "oauth", a label naming the Slack channel,
and no event subscriptions — installing must not also mean paging you. The response is a
307 back to the dashboard carrying the outcome.