Skip to main content
The Pipefort API exposes the endpoints below under /api/*. All endpoints except /api/health, /api/webhooks/github, and the public-scan endpoints 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
{
  "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
{
  "repo_id": "uuid-from-GET-/api/repos",
  "ruleset": "all"        // or "owasp"
}
ruleset defaults to "all" if omitted. Response
{
  "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. counts is the severity rollup of open findings — triaged findings are persisted but excluded — and reflects any rule preferences 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 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
{ "repo": "owner/repo" }
Pasted GitHub URLs (https://github.com/owner/repo) are accepted. Response
{
  "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. 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
{
  "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 across every repository the user owns, computed from each repo’s latest-scan findings. Powers the Attacker Mind dashboard. Repositories with no combinations are omitted. Response
{
  "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.

GET /api/repositories/{repo_id}/combos

Returns the toxic combinations 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. 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
{
  "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 — 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
ParamRequiredDescription
installation_idyesThe numeric installation ID GitHub provides on the redirect.
Response
{
  "installation_id": 12345,
  "account": "octocat",
  "account_type": "User"
}

Organizations & invites

Back the 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.
EndpointWhoWhat
GET /api/orgsanyThe caller’s orgs (creates their personal org on first call).
POST /api/orgs {name}anyCreate a team org; caller becomes admin.
PATCH /api/orgs/{org_id} {name}adminRename.
GET /api/orgs/{org_id}/pr-gatememberThe org’s pull-request check policy ({fail_on, new_only}).
PUT /api/orgs/{org_id}/pr-gate {fail_on, new_only}adminUpdate the PR-check policy.
GET /api/orgs/{org_id}/membersmemberMembers with emails and roles.
PUT /api/orgs/{org_id}/members/{user_id}/role {role}adminChange a role (409 on the last admin).
DELETE /api/orgs/{org_id}/members/{user_id}admin or selfKick / leave (409 on the last admin or a personal org).
GET /api/orgs/{org_id}/invitesmemberPending invites.
POST /api/orgs/{org_id}/invites {email, role}adminInvite by email (409 if already a member or already invited).
DELETE /api/orgs/{org_id}/invites/{invite_id}adminRevoke a pending invite.
GET /api/invitesanyPending invites addressed to the caller’s JWT email.
POST /api/invites/{invite_id}/acceptinviteeJoin; 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 for one repository the user owns (404 otherwise). Monitored repos are scanned automatically when code is pushed to their default branch. Request
{ "monitored": true }
Response
{ "monitored": true }

POST /api/webhooks/github

Receives GitHub App webhook deliveries — the trigger behind 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:
{ "scans": 1, "failed": 0 }

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

Triages one finding by its cross-scan fingerprint — see Finding triage. The ledger row is created at scan time, so triaging something never scanned (or another user’s repo) is a 404. Request
{ "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):
{ "status": "dismissed", "counts": { "high": 0, "medium": 1, "low": 2, "info": 0 } }

Notification settings

Back the 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).
{ "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 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).
{
  "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.
{
  "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:
{ "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.
{
  "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.