/api/*.
All endpoints except /api/health, /api/webhooks/github, and the
public-scan endpoints require a Supabase JWT in
the Authorization header:
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 get429 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": 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
ruleset defaults to "all" if omitted.
Response
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
https://github.com/owner/repo) are accepted.
Response
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
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
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
/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
| Param | Required | Description |
|---|---|---|
installation_id | yes | The numeric installation ID GitHub provides on the redirect. |
Organizations & invites
Back the Organizations feature. Conventions: acting on an org you’re not a member of is a404 (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 ({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). |
/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
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:
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 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):
Notification settings
Back the Notifications card in Settings. Reads go through supabase-js (RLS-scopednotification_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).
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).
surface is "workflow" or "repo-settings".
GET /api/rule-settings
Returns the user’s sparse global preferences. Missing rule IDs are default-enabled.
PUT /api/rule-settings/{rule_id}
Upserts a global preference. Returns 204 No Content. Body:
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.
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, andrule_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.