Skip to content

Endpoints: Integrations, Resources & Reports

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.

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.

Current config. Requires org-admin. 404 if nothing is connected.

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.

StatusDescription
307App created, credentials stored, redirecting back to the dashboard
400Invalid, expired, or already-consumed state; or the code exchange with GitHub failed

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

StatusDescription
200Ignored (unhandled event/action), acknowledged (installation created/deleted), or no integration registered for this installation
202Analysis job queued
401Missing or invalid X-Hub-Signature-256 for the resolved secret
501Neither the matched integration’s own App nor the platform’s config.github is configured

Fetch a stored resource by content hash.

ParameterDescription
hash_resourceContent hash returned by ingest
ParameterTypeDescription
filterstring (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
}

Store the LLM analysis for a resource.

ParameterDescription
hash_resourceContent hash

Request bodyapplication/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"]
}
]
}
FieldTypeRequiredDescription
summarystringYesOne-paragraph summary
findingsarrayYes (≥ 1)Security findings
FieldTypeDescription
titlestringShort finding title
severitystringCRITICAL / HIGH / MEDIUM / LOW / INFO
rule_idstringRule identifier from the ruleset
riskstringWhat goes wrong if not fixed
why_it_mattersstringBusiness / security impact
suggested_next_stepstringActionable remediation
confidencestringHIGH / MEDIUM / LOW
evidencestring[]Exact lines from the resource body
resource_addressstringTerraform address (e.g. aws_db_instance.orders)
tagsstring[]Classification tags
StatusDescription
200Analysis stored — returns updated resource
404Resource not found
422Missing or empty summary / findings

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.

ParameterTypeDefaultDescription
rule_typestring(none)Rule sub-category (e.g. aws). Passed to the analyzer’s get_rules().
StatusDescription
200Resource document ({hash, value, rules, analysis: null})
208All 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.


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" }
]

List all resource documents registered under a namespace (including analysis).


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.

ParameterTypeDefaultDescription
analyzerstring(auto)Deprecated — auto-detected from resources. Kept as fallback for resources without metadata.analyzer.
formatstringmarkdownmarkdown, markdown_light, or html
template_idstring(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
rawboolfalsetrue → plain text body; false → JSON wrapper
include_suppressedboolfalsetrue → also show findings triaged as suppressed/false_positive
StatusContent-TypeDescription
200 (raw=false)application/json{"markdown_report": "…"} or {"html_report": "…"}
200 (raw=true)text/markdown or text/htmlReport text directly
404template_id given but no such template exists in this org
422The resolved template’s Jinja2 source failed to render (syntax error, undefined variable, or a SandboxedEnvironment security error)

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.

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
}
FieldTypeDefaultDescription
namestring
formatstringmarkdown, markdown_light, or html — immutable after creation
sourcestringJinja2 template source
is_defaultboolfalseIf 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
}

Any member. Filter by format and/or default state.

ParameterTypeDescription
formatstringmarkdown, markdown_light, or html
is_defaultboolFilter 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).

StatusDescription
200Updated — returns the full template, same shape as create
404Template not found
422Malformed 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.

StatusDescription
200Deleted
404Template 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"}
StatusDescription
200Rendered
404namespace has no resources yet
422source failed to render (see the sandboxing note above)

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.

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

StatusDescription
200Rendered HTML report
404Token unknown or expired — both cases respond identically, on purpose

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}