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 a “View full HTML 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}