Endpoints: LLM Credentials, Billing & Findings
LLM credentials
Section titled “LLM credentials”POST /orgs/{org_id}/llm-credentials
Section titled “POST /orgs/{org_id}/llm-credentials”Store an LLM provider API key for the org, encrypted at rest. Used by /analyze as a fallback when the request doesn’t include an inline api_key. Upserts — storing again for the same provider replaces the previous key. Requires org-admin.
Request body
{"provider": "anthropic", "api_key": "sk-..."}Response 201
{"provider": "anthropic", "created_at": "2026-07-09T00:00:00Z"}GET /orgs/{org_id}/llm-credentials
Section titled “GET /orgs/{org_id}/llm-credentials”List providers configured for the org. Never returns the key itself.
{"credentials": [{"provider": "anthropic", "created_at": "2026-07-09T00:00:00Z"}]}DELETE /orgs/{org_id}/llm-credentials/{provider}
Section titled “DELETE /orgs/{org_id}/llm-credentials/{provider}”Remove a stored credential. 404 if none exists for that provider.
Billing / quota
Section titled “Billing / quota”Server-side analysis (/analyze) can be capped by an optional per-org monthly LLM spend budget. There is no billing-provider (Stripe, etc.) integration — this is internal metering plus a hard block. No cap is set by default (unlimited).
PATCH /orgs/{org_id}/quota
Section titled “PATCH /orgs/{org_id}/quota”Set or clear the org’s monthly LLM spend cap. Requires org-admin.
Request body
{"monthly_budget_usd": 50.0}Pass "monthly_budget_usd": null to clear the cap (back to unlimited).
Response 200
{"monthly_budget_usd": 50.0}GET /orgs/{org_id}/usage
Section titled “GET /orgs/{org_id}/usage”Current-month LLM usage and remaining budget for the org, plus its metered resource usage and — on an enterprise plan — where it stands against its commitment. Requires org-viewer.
Response 200
{ "period": "2026-07", "total_cost_usd": 12.340000, "budget_usd": 50.0, "remaining_usd": 37.660000, "event_count": 84, "resource_count": 5400, "metered_cost_usd": 2.4, "included_resources_per_month": 5000, "included_resources_remaining": 0, "overage_resources": 400, "committed_amount_usd": 1500.0, "contract_expired": false, "demo_analyses_used": null, "demo_analyses_remaining": null}budget_usd and remaining_usd are null when the org has no cap set. period is the current calendar month in UTC (YYYY-MM); usage resets automatically at the start of each month — there’s no rollover.
resource_count is metered for every org regardless of plan — only billing is gated. What metered_cost_usd then means depends on the plan:
| Plan | metered_cost_usd |
|---|---|
free | null — never billed |
paid | The degressive per-resource price for the whole resource_count, from the first resource |
enterprise | The overage beyond included_resources_per_month, charged flat at the cheapest configured tier — 0.0, not null, while inside the commitment |
The four commitment fields (included_resources_per_month, included_resources_remaining, overage_resources, committed_amount_usd) are null/0 on every plan but enterprise, and included_resources_per_month is also null for an enterprise org whose contract sets no ceiling — which means unlimited, not zero.
demo_analyses_used/demo_analyses_remaining are populated only on a cloud deployment for a non-paying plan — null on self-hosted or on any paying plan, where no demo quota applies.
Allowed models
Section titled “Allowed models”A platform-wide (not per-org) catalog of which (llm_backend, model) pairs POST /{analyzer}/{namespace}/analyze and POST /orgs/{org_id}/integrations/github will accept, plus manually-entered pricing used to cost each analyze call. Superadmin-managed.
GET /models
Section titled “GET /models”Any org member. Returns only enabled rows — this is what the dashboard’s Analyze and GitHub Integration forms fetch to populate their model picker.
Response 200
{ "models": [ { "llm_backend": "anthropic", "model": "claude-sonnet-5", "input_price_per_1m_usd": 3.0, "output_price_per_1m_usd": 15.0, "enabled": true, "created_by": "u_123", "created_at": "2026-07-15T09:00:00+00:00", "updated_at": null } ]}GET /admin/models
Section titled “GET /admin/models”Superadmin only. The full catalog, including disabled rows. Optional ?enabled=true|false filter.
POST /admin/models
Section titled “POST /admin/models”Superadmin only. Adds a model to the catalog.
Request body — application/json
{ "llm_backend": "anthropic", "model": "claude-sonnet-5", "input_price_per_1m_usd": 3.0, "output_price_per_1m_usd": 15.0, "enabled": true}| Status | Description |
|---|---|
201 | Created |
409 | (llm_backend, model) already exists |
PATCH /admin/models/{llm_backend}/{model}
Section titled “PATCH /admin/models/{llm_backend}/{model}”Superadmin only. Partial update — send only the fields to change (typically input_price_per_1m_usd/output_price_per_1m_usd or enabled).
DELETE /admin/models/{llm_backend}/{model}
Section titled “DELETE /admin/models/{llm_backend}/{model}”Superadmin only. Removes the model from the catalog. Any org still configured to use it (a saved GitHub integration, or a client that hardcodes it in /analyze calls) starts getting 400s the next time it’s used.
Findings
Section titled “Findings”Every finding an analysis produces gets a persistent triage record, independent of the raw LLM output — keyed by (resource_hash, rule_id) within the caller’s org. There is no create endpoint: records appear automatically whenever an analysis is submitted (via either PUT /resource/{hash}/analysis or POST .../analyze). Re-analysing the same resource only refreshes last_seen/occurrence_count — it never resets a status you’ve already set.
GET /findings
Section titled “GET /findings”List/filter finding records for the caller’s org. Requires member.
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | open, needs_review, acknowledged, suppressed, or false_positive |
severity | string | — | e.g. CRITICAL, HIGH |
rule_id | string | — | Exact rule ID |
limit | int | 100 | Page size (1–1000) |
offset | int | 0 | Pagination offset |
Response 200
{ "findings": [ { "resource_hash": "a3f1c2d4e5b6", "rule_id": "TFSEC-040", "resource_address": "aws_db_instance.orders", "severity": "HIGH", "title": "S3 bucket allows public read", "status": "open", "first_seen": "2026-07-09T00:00:00Z", "last_seen": "2026-07-09T00:00:00Z", "occurrence_count": 1, "note": null, "updated_by": null, "updated_at": null } ]}GET /findings/summary
Section titled “GET /findings/summary”Aggregated counts for the caller’s org. Requires member.
| Parameter | Type | Default | Description |
|---|---|---|---|
days | int | 30 | Window for new_in_period/resolved_in_period (1–365) |
Response 200
{ "period_days": 30, "total": 42, "by_status": {"open": 28, "needs_review": 2, "acknowledged": 5, "suppressed": 6, "false_positive": 1}, "by_severity_open": {"CRITICAL": 1, "HIGH": 10, "MEDIUM": 15, "LOW": 4}, "new_in_period": 8, "resolved_in_period": 3}by_severity_open only counts findings currently status: "open". resolved_in_period counts findings whose status left open for anything other than needs_review within the window — a finding awaiting a human decision isn’t “resolved” just because it’s no longer open.
PATCH /findings/{resource_hash}/{rule_id}
Section titled “PATCH /findings/{resource_hash}/{rule_id}”Set a finding’s triage status. Requires member — triage is treated as routine day-to-day work, not an admin action.
Request body
{"status": "suppressed", "reason_code": "accepted_risk", "note": "internal tool, not internet-facing"}status is one of open, needs_review, acknowledged, suppressed, false_positive. Setting status: "open" reopens a previously triaged finding. note is optional.
reason_code is optional and one of false_positive, accepted_risk, fixed, not_applicable_here, other. Every status that carries a verdict (acknowledged, suppressed, false_positive) is appended to this org’s review-feedback log; the reason is what makes that entry usable. For suppressed it is the only thing separating “this is wrong” from “this is right and we accept it” — without it the verdict is recorded as unclear rather than guessed at, and counts toward neither side of the rule’s precision. Setting needs_review fires this org’s review webhook, if one is configured — the same notification an auto-flagged finding fires at creation time.
If the finding’s resource belongs to a namespace that was analysed for a GitHub pull request, this call also re-posts that PR’s commit status. A commit status is a push, not a poll — GitHub keeps whatever state was last written to a SHA — so triaging here is what clears a check held at pending, and it takes effect immediately rather than waiting for another push to re-trigger the webhook. One resource can gate several PRs, and every one of them is re-posted. This is best-effort: the status change is already saved when it runs, so a GitHub failure is logged and never surfaced in this response. See Clearing a held check.
Response 200 — the updated finding record (same shape as GET /findings)
| Status | Description |
|---|---|
200 | Status updated |
404 | No finding record exists for this (resource_hash, rule_id) pair |
Review feedback
Section titled “Review feedback”The append-only record of what humans decided about findings, and what it argues for. See Review Feedback Loop for the design, the guardrails, and why a pull-request comment is treated as attacker-controlled input.
GET /feedback
Section titled “GET /feedback”List this org’s review feedback, newest first. Requires member.
Query parameters
| Parameter | Description |
|---|---|
rule_id | Only feedback on this rule |
verdict | confirmed / rejected / severity_wrong / missed / unclear |
source | dashboard_triage / github_command / github_review_comment / api |
resource_hash | Only feedback on this resource |
limit / offset | Pagination (default 100, max 1000) |
Response 200
{ "feedback": [ { "feedback_id": "…", "resource_hash": "a3f1c2d4…", "rule_id": "s3-public-acl", "namespace": "gh-acme-infra-42", "severity": "HIGH", "verdict": "rejected", "reason_code": "false_positive", "comment": "data-lake buckets are public by design, tagged public-dataset", "source": "dashboard_triage", "actor": "usr_…", "trusted": true, "provenance": {"provider": null, "repo": null, "pr_number": null, "comment_id": null}, "created_at": "2026-08-24T09:12:00+00:00" } ], "total": 1}trusted is whether this entry may influence a later analysis — decided when it was written and never recomputed. Feedback from an outside contributor on a public pull request, or from a comment that tripped the injection scanner, is false: recorded and shown like any other, never composed into a prompt.
POST /feedback
Section titled “POST /feedback”Record a verdict from a system this server doesn’t otherwise see — a code-review tool, a chat workflow, an internal triage queue. Requires member.
Triage through PATCH /findings/… records feedback by itself; this is not needed for it, and calling both counts one decision twice.
{"resource_hash": "a3f1c2d4…", "rule_id": "s3-public-acl", "verdict": "confirmed", "comment": "confirmed in the incident review"}| Status | Description |
|---|---|
201 | Recorded — returns the feedback document |
404 | No finding exists for this (resource_hash, rule_id) pair |
GET /rule-health
Section titled “GET /rule-health”Per-rule reviewer precision. Requires member.
{ "rules": [ { "rule_id": "s3-public-acl", "title": "Public bucket ACL", "severity": "HIGH", "confirmed": 2, "rejected": 7, "severity_wrong": 0, "unclear": 3, "total": 12, "precision": 0.222, "calibrating": true, "last_feedback_at": "2026-08-24T09:12:00+00:00" } ], "settings": {"calibration_enabled": true, "min_samples": 3, "severity_ceiling": "HIGH", "trusted_only": true}}precision is null until the rule has min_samples decisive verdicts — a ratio over one or two samples is noise. calibrating says whether the rule currently contributes to the analysis prompt; settings is echoed so a client can explain why a rule with feedback isn’t calibrating without hardcoding the deployment’s thresholds.
GET /rule-proposals
Section titled “GET /rule-proposals”Rule changes the feedback argues for. Requires member. Filter with ?status=pending|applied|dismissed.
Each proposal carries the exact edit suggested (changes), the numbers behind it (evidence), and the reviewer comments it came from (reviewer_quotes).
POST /rule-proposals/refresh
Section titled “POST /rule-proposals/refresh”Recompute proposals from current feedback. Requires admin. Idempotent — a rule with an open proposal has it updated rather than duplicated, and an open proposal the current evidence no longer supports is withdrawn. Deterministic and cheap (no LLM call), so calling it on a schedule is fine.
POST /rule-proposals/{proposal_id}/apply
Section titled “POST /rule-proposals/{proposal_id}/apply”Accept a proposal. Requires admin. Edits the rule through the same path a hand-edited one takes, audited as rule.update plus a rule_proposal.apply event attributed to the caller. description_amendment, where present, is appended to the rule’s description rather than replacing it.
| Status | Description |
|---|---|
200 | Applied — returns the decided proposal |
404 | No such proposal, or the rule no longer exists |
409 | Already applied or dismissed, or it carries no applicable change |
POST /rule-proposals/{proposal_id}/dismiss
Section titled “POST /rule-proposals/{proposal_id}/dismiss”Reject a proposal. Requires admin. The rule is untouched.
Review webhook
Section titled “Review webhook”An org-level outbound notification fired whenever a finding enters needs_review — automatically (compute_initial_status, on low LLM confidence or a rule marked requires_review) or manually (PATCH /findings/{resource_hash}/{rule_id} above). Superseded by notification channels, which can reach Slack, allow more than one destination per org, and subscribe to events rather than being hardwired to this one. This endpoint still works and still fires — alongside channels, not instead of them — and there is nothing to migrate. Delivery is best-effort either way: a slow or failing endpoint never fails the request that triggered it, and there’s no retry queue.
Reuses the same (org_id, provider) shape as the GitHub integration (provider: "review_webhook") — one config per org, admin-only, same trust boundary as custom rules and LLM credentials.
PUT /orgs/{org_id}/integrations/review-webhook
Section titled “PUT /orgs/{org_id}/integrations/review-webhook”Create or update. Requires admin role. Upserts — calling again replaces the URL/secret/enabled state entirely; omitting secret clears any previously configured signing secret rather than leaving the old one in place.
Request body
{"url": "https://example.com/hooks/opentremor", "secret": "whsec_...", "enabled": true}| Field | Type | Default | Description |
|---|---|---|---|
url | string | — | POSTed with a JSON body on every needs_review transition |
secret | string | null | null | If set, the request is signed with HMAC-SHA256 over the raw body and sent as X-OpenTremor-Signature: sha256=<hex> — the same scheme GitHub itself uses for inbound webhooks, just outbound here. Encrypted at rest, never returned by GET. |
enabled | bool | true | — |
Response 200
{"url": "https://example.com/hooks/opentremor", "enabled": true, "has_secret": true, "created_at": "2026-01-01T00:00:00Z", "updated_at": null}has_secret is the only signal GET gives about the secret — the value itself is never redisplayed after saving.
GET /orgs/{org_id}/integrations/review-webhook
Section titled “GET /orgs/{org_id}/integrations/review-webhook”Current config. Requires admin role. 404 if none configured.
DELETE /orgs/{org_id}/integrations/review-webhook
Section titled “DELETE /orgs/{org_id}/integrations/review-webhook”Remove it. Requires admin role. 404 if none configured.
Payload sent on delivery
{ "event": "finding.needs_review", "org_id": "8f2a...", "resource_hash": "a3f1c2d4e5b6", "rule_id": "approved-module-only", "resource_address": "aws_db_instance.orders", "severity": "HIGH", "title": "Direct Resource Instead Of Approved Module", "namespaces": ["production-deploy-42"], "trigger": "auto", "timestamp": "2026-07-28T00:00:00Z"}trigger is "auto" (routed at analysis time) or "manual" (a human PATCHed the status directly).