> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pipefort.com/llms.txt
> Use this file to discover all available pages before exploring further.

# API reference

> The Go HTTP endpoints under /api/*.

The Pipefort API exposes the endpoints below under `/api/*`.

All endpoints except `/api/health`, `/api/webhooks/github`, and the
[public-scan endpoints](#post-apipublic-scan) require a **Supabase JWT** in
the `Authorization` header:

```
Authorization: Bearer <supabase-access-token>
```

The token is the access token the SPA gets from supabase-js after a successful GitHub login. The API verifies it (HS256, `SUPABASE_JWT_SECRET`) and extracts `user_id` for downstream queries.

## Rate limiting & request IDs

Authenticated endpoints are rate-limited to **120 requests per user per
minute** (per serverless instance); over-budget requests get `429` with a
`Retry-After` header. An org-wide "Scan all" stays well inside this — the
limit exists to trip runaway clients, not real use.

Every response carries an **`X-Request-Id`** header (honoring one supplied by
a fronting proxy), and the API logs one structured line per request with the
id, method, path, status, and duration — include the id when reporting an
issue so the request can be found in the logs.

## `GET /api/health`

Unauthenticated health check. Returns `{ "status": "ok" }`.

## `GET /api/repos`

Refreshes the user's repositories from GitHub (across **all** their installations) and persists them. Returns the stored rows.

**Response**

```json theme={null}
{
  "connected": true,
  "repositories": [
    {
      "id": "uuid",
      "full_name": "owner/repo",
      "private": false,
      "html_url": "https://github.com/owner/repo"
    }
  ]
}
```

If the user hasn't connected any GitHub App installations yet, returns `{ "connected": false, "repositories": [] }`.

## `POST /api/scan`

Scans a single repository. The client loops this across repos client-side for org-wide scans, keeping each request short.

**Request**

```json theme={null}
{
  "repo_id": "uuid-from-GET-/api/repos",
  "ruleset": "all"        // or "owasp"
}
```

`ruleset` defaults to `"all"` if omitted.

**Response**

```json theme={null}
{
  "scan_id": "uuid",
  "repo_id": "uuid",
  "workflow_files": 3,
  "counts": {
    "high": 2,
    "medium": 1,
    "low": 0,
    "info": 0
  },
  "findings": 3,
  "new_findings": 1
}
```

`findings` is the number of findings persisted; the rows themselves are read
by the SPA directly from Postgres (RLS-scoped). `new_findings` is how many of
them were [first seen by this scan](/webapp/finding-triage). `counts` is the
severity rollup of **open** findings — triaged findings are persisted but
excluded — and reflects any [rule preferences](/webapp/rule-settings) the user
has set; filtering happens server-side before persistence, so the persisted
findings and `counts` always agree.

## `POST /api/public-scan`

**Unauthenticated.** Powers the [public repo scanner](/webapp/public-scan) on
the landing page: scans a public GitHub repository's workflow files and
returns a partial ("teaser") report. Requires `GITHUB_PUBLIC_SCAN_TOKEN` to be
configured server-side; returns `503` otherwise.

**Request**

```json theme={null}
{ "repo": "owner/repo" }
```

Pasted GitHub URLs (`https://github.com/owner/repo`) are accepted.

**Response**

```json theme={null}
{
  "slug": "k7Qw2mXbP4aZ",
  "repo": "owner/repo",
  "default_branch": "main",
  "workflow_files": 14,
  "findings": 23,
  "counts": { "HIGH": 4, "MEDIUM": 11, "LOW": 6, "INFO": 2 },
  "top_findings": [
    { "severity": "HIGH", "category": "CICD-SEC-4", "rule_id": "expression-injection", "title": "..." }
  ],
  "locked_findings": 20,
  "cached": false,
  "scanned_at": "2026-07-09T18:03:11Z"
}
```

`top_findings` carries at most 3 entries and never includes file, line,
description, or recommendation — the [full report requires signing
in](/webapp/public-scan). `slug` is the share-link id (omitted if persistence
failed); `cached: true` marks a replayed result (results are cached for 1 hour
per repo). Repos with no workflows return `"no_workflows": true`.

**Errors**: `400` invalid input · `404` repository not found **or not public**
(private repos are indistinguishable from missing ones) · `429` rate-limited
(5/min per IP, 30/min global; honors `Retry-After`) · `502` GitHub unreachable
· `503` not configured.

## `GET /api/public-scan/{slug}`

**Unauthenticated.** Replays a stored public scan by its share slug — the
backend for `pipefort.com/scan/{slug}`. Same response shape as
`POST /api/public-scan` (always `cached: true`). `404` for unknown slugs.
Responses are immutable and CDN-cacheable (`Cache-Control: max-age=3600`).

## `GET /api/public-stats`

**Unauthenticated.** The anonymized global aggregate shown on the landing
page: totals plus the ten rules firing most often across all Pipefort scans
(each repository's **latest** scan, tenant and public teaser scans combined).
Only rule identifiers and counts are computed — customer repositories,
organizations, and users are never identified. `recent` lists the latest
anonymous public teaser scans (public repos only).

**Response**

```json theme={null}
{
  "repos_scanned": 214,
  "scans": 389,
  "findings": 5121,
  "repos_with_high": 61,
  "top_rules": [
    {
      "rule_id": "cicd-sec-3-unpinned-action",
      "title": "Unpinned third-party action",
      "category": "CICD-SEC-3",
      "severity": "MEDIUM",
      "occurrences": 812,
      "repos": 143
    }
  ],
  "recent": [
    { "repo": "octo/app", "findings": 7, "counts": { "HIGH": 2 }, "scanned_at": "..." }
  ]
}
```

Rules are ranked severity-first, then by occurrences, capped at 10; titles and
severities come from the rule catalog, not stored findings. Responses are
cached (CDN `s-maxage=300` plus a 5-minute in-process cache), so numbers can
lag by up to \~10 minutes.

## `GET /api/attack-paths`

Returns [toxic combinations](/concepts/attacker-mind) across every repository
the user owns, computed from each repo's latest-scan findings. Powers the
[Attacker Mind dashboard](/webapp/attacker-mind). Repositories with no
combinations are omitted.

**Response**

```json theme={null}
{
  "repos": [
    {
      "repo_id": "uuid",
      "full_name": "acme/demo",
      "html_url": "https://github.com/acme/demo",
      "combos": [
        {
          "id": "pwn-request",
          "title": "Pwn Request — untrusted PR code runs with a writable token",
          "severity": "CRITICAL",
          "scope": "file",
          "file": ".github/workflows/ci.yml",
          "impact": "...",
          "break_chain": "...",
          "break_chain_rule": "cicd-sec-1-ppe-checkout",
          "stages": [
            { "order": 0, "title": "...", "description": "...", "rule_id": "cicd-sec-1-ppe-checkout" }
          ],
          "components": [
            { "rule_id": "cicd-sec-1-ppe-checkout", "finding": { "...": "..." } }
          ]
        }
      ]
    }
  ],
  "total_combos": 1,
  "critical_count": 1,
  "high_count": 0
}
```

Detection runs the shared `pkg/scanner` engine — identical to the CLI — so a
combination never includes a finding from a rule disabled in
[Rule settings](/webapp/rule-settings).

## `GET /api/repositories/{repo_id}/combos`

Returns the [toxic combinations](/concepts/attacker-mind) for a **single**
repository, computed from its latest-scan findings — the per-repo counterpart of
`/api/attack-paths`. Powers the **Attacker Mind** card on the
[repository detail page](/webapp/overview). The user must own the repo (`404`
otherwise). `combos` is never `null` — an empty array means no combinations (and
also covers a repo that has never been scanned).

**Response**

```json theme={null}
{
  "combos": [
    {
      "id": "pwn-request",
      "title": "Pwn Request — untrusted PR code runs with a writable token",
      "severity": "CRITICAL",
      "scope": "file",
      "file": ".github/workflows/ci.yml",
      "impact": "...",
      "break_chain": "...",
      "break_chain_rule": "cicd-sec-1-ppe-checkout",
      "stages": [
        { "order": 0, "title": "...", "description": "...", "rule_id": "cicd-sec-1-ppe-checkout" }
      ],
      "components": [
        { "rule_id": "cicd-sec-1-ppe-checkout", "finding": { "...": "..." } }
      ]
    }
  ],
  "critical_count": 1,
  "high_count": 0
}
```

Like `/api/attack-paths`, detection respects the user's current
[rule settings](/webapp/rule-settings) — globals merged with this repo's
overrides — so a rule turned off after the last scan can't still form a
combination.

## `GET /api/github/callback`

Links a freshly-created GitHub App installation to the signed-in user. GitHub redirects here with `?installation_id=...`; the SPA forwards it with the user's bearer token so the API can associate the two.

**Query params**

| Param             | Required | Description                                                  |
| ----------------- | -------- | ------------------------------------------------------------ |
| `installation_id` | yes      | The numeric installation ID GitHub provides on the redirect. |

**Response**

```json theme={null}
{
  "installation_id": 12345,
  "account": "octocat",
  "account_type": "User"
}
```

## Organizations & invites

Back the [Organizations](/webapp/organizations) feature. Conventions: acting
on an org you're not a member of is a `404` (indistinguishable from a
nonexistent org); member-but-not-admin on admin-only routes is a `403`;
last-admin and personal-org guards are `409`.

| Endpoint                                                 | Who           | What                                                                              |
| -------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------- |
| `GET /api/orgs`                                          | any           | The caller's orgs (creates their personal org on first call).                     |
| `POST /api/orgs` `{name}`                                | any           | Create a team org; caller becomes admin.                                          |
| `PATCH /api/orgs/{org_id}` `{name}`                      | admin         | Rename.                                                                           |
| `GET /api/orgs/{org_id}/pr-gate`                         | member        | The org's [pull-request check policy](/webapp/pr-checks) (`{fail_on, new_only}`). |
| `PUT /api/orgs/{org_id}/pr-gate` `{fail_on, new_only}`   | admin         | Update the PR-check policy.                                                       |
| `GET /api/orgs/{org_id}/members`                         | member        | Members with emails and roles.                                                    |
| `PUT /api/orgs/{org_id}/members/{user_id}/role` `{role}` | admin         | Change a role (`409` on the last admin).                                          |
| `DELETE /api/orgs/{org_id}/members/{user_id}`            | admin or self | Kick / leave (`409` on the last admin or a personal org).                         |
| `GET /api/orgs/{org_id}/invites`                         | member        | Pending invites.                                                                  |
| `POST /api/orgs/{org_id}/invites` `{email, role}`        | admin         | Invite by email (`409` if already a member or already invited).                   |
| `DELETE /api/orgs/{org_id}/invites/{invite_id}`          | admin         | Revoke a pending invite.                                                          |
| `GET /api/invites`                                       | any           | Pending invites addressed to the caller's JWT email.                              |
| `POST /api/invites/{invite_id}/accept`                   | invitee       | Join; the JWT email must match (`404` otherwise).                                 |

List endpoints elsewhere in this reference (`/api/repos`, `/api/attack-paths`,
`/api/rule-settings*`) accept an `?org=<uuid>` query parameter, validated
against membership (`403` for non-members) and defaulting to the caller's
personal org.

## `PUT /api/repositories/{repo_id}/monitor`

Toggles [monitoring](/webapp/monitored-repos) for one repository the user owns
(`404` otherwise). Monitored repos are scanned automatically when code is
pushed to their default branch.

**Request**

```json theme={null}
{ "monitored": true }
```

**Response**

```json theme={null}
{ "monitored": true }
```

## `POST /api/webhooks/github`

Receives GitHub App webhook deliveries — the trigger behind
[monitored repos](/webapp/monitored-repos). **Not** bearer-authenticated:
authentication is the `X-Hub-Signature-256` HMAC computed with the
deployment's `GITHUB_WEBHOOK_SECRET` (`401` on a bad or missing signature,
`503` when the secret isn't configured).

Only `push` events to a repo's default branch trigger scans; every other
delivery (`ping`, other events, other branches, branch deletions, replayed
delivery IDs) is acknowledged with `200` and a short JSON reason. A push scan
runs once per Pipefort user who monitors the pushed repo:

```json theme={null}
{ "scans": 1, "failed": 0 }
```

## `PUT /api/repositories/{repo_id}/findings/{fingerprint}/status`

Triages one finding by its cross-scan fingerprint — see
[Finding triage](/webapp/finding-triage). The ledger row is created at scan
time, so triaging something never scanned (or another user's repo) is a `404`.

**Request**

```json theme={null}
{ "status": "dismissed", "reason": "tolerated in CI" }
```

`status` is one of `open` (reopen), `dismissed`, `accepted`, `false_positive`;
`reason` is optional free text.

**Response** — the new status plus the repo's recomputed latest-scan counts
(scans.counts is the **open**-findings rollup, so triage changes it):

```json theme={null}
{ "status": "dismissed", "counts": { "high": 0, "medium": 1, "low": 2, "info": 0 } }
```

## Notification settings

Back the [Notifications](/webapp/notifications) card in Settings. Reads go
through supabase-js (RLS-scoped `notification_settings` table); the endpoints
below handle writes and test delivery.

### `PUT /api/notification-settings`

Upserts the user's Slack webhook config. The URL must be an
`https://hooks.slack.com/…` incoming webhook — anything else is a `400` (the
server posts to this URL itself and restricts destinations to Slack).

```json theme={null}
{ "slack_webhook_url": "https://hooks.slack.com/services/T…/B…/…", "enabled": true }
```

### `DELETE /api/notification-settings`

Removes the config. `204` on success.

### `POST /api/notification-settings/test`

Sends a test message to the configured webhook. `404` when nothing is
configured, `502` when Slack refuses the delivery, `200 {"delivered": true}`
on success.

## Rule settings

The seven endpoints below back the [Rule settings](/webapp/rule-settings) page (global toggles) and the **Rule overrides** card on each repository detail page (per-repo overrides). Reads return **sparse** rows — rules with no entry are treated as default-enabled per the catalog.

### `GET /api/rules`

Returns the canonical rule catalog (static — same data the SPA renders).

```json theme={null}
{
  "rules": [
    {
      "id": "best-prac-2-missing-timeout",
      "category": "BEST-PRAC-2",
      "title": "Job timeout not configured",
      "default_severity": "LOW",
      "surface": "workflow",
      "description": "...",
      "doc_url": "/rules/best-prac-2"
    }
  ]
}
```

`surface` is `"workflow"` or `"repo-settings"`.

### `GET /api/rule-settings`

Returns the user's sparse global preferences. Missing rule IDs are default-enabled.

```json theme={null}
{
  "settings": [
    { "rule_id": "best-prac-2-missing-timeout", "enabled": false, "updated_at": "..." }
  ]
}
```

### `PUT /api/rule-settings/{rule_id}`

Upserts a global preference. Returns `204 No Content`. Body:

```json theme={null}
{ "enabled": false }
```

Validates `rule_id` against the catalog; unknown slugs return `400`.

### `DELETE /api/rule-settings/{rule_id}`

Clears the global row, reverting the rule to default-enabled. Idempotent (`204` even if no row existed).

### `GET /api/repositories/{repo_id}/rule-overrides`

Returns the sparse list of override rows for one repo. The user must own the repo (verified via the same path `POST /api/scan` uses) — `404` otherwise.

```json theme={null}
{
  "overrides": [
    { "rule_id": "best-prac-2-missing-timeout", "enabled": true, "updated_at": "..." }
  ]
}
```

### `PUT /api/repositories/{repo_id}/rule-overrides/{rule_id}`

Upserts an override for `(user, repo, rule)`. Body `{ "enabled": bool }`. Returns `204`.

### `DELETE /api/repositories/{repo_id}/rule-overrides/{rule_id}`

Clears the override, reverting the rule to inherit from the global setting. Idempotent.

## Reading data directly (no API call)

Reading scans, findings, repositories, installations, and `rule_settings` does **not** go through the Go API — the SPA queries Postgres directly via supabase-js. Row-level security (`auth.uid() = user_id`) scopes every result to the signed-in user.

The API only handles **writes** (persisting scan results and rule preferences), **GitHub-side reads** (which need an installation token), and the **installation link** callback.
