/api/*.
All endpoints except /api/health, /api/webhooks/github, and the
public-scan endpoints require your access token in
the Authorization header:
Rate limiting & request IDs
Authenticated endpoints are rate-limited to 120 requests per user per minute; 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) — 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/capabilities
Which optional deployment-level integrations are configured. Requires auth, but
reports server configuration rather than org state — every caller gets the same
answer.
pr_webhooks is whether the GitHub App webhook secret is set. The console reads
it so it can tell an absence apart from a zero: without it nothing ever records
that a fix merged, so the Insights funnel
shows its verified stage as — instead of 0.
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
directly from the database, scoped to your account. 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. Returns 503 if the public scanner isn’t
configured on the deployment.
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: coarse headline milestones plus the ten rules firing most often across
all Pipefort scans (each repository’s latest scan, tenant and public
teaser scans combined). Headline figures are public marketing strings
("1k+", "10k+") — exact counts are never exposed by this endpoint, and
customer repositories, organizations, and users are never identified.
recent lists the latest anonymous public teaser scans (public repos only).
Response
top_rules is empty until enough repositories
have been scanned. Responses are cached (CDN s-maxage=300 plus a 5-minute
in-process cache).
GET /api/public/actions
Unauthenticated. The public directory of scored marketplace actions —
the latest scored version per action, joined to its repository metadata.
Powers the action risk-score explorer at
pipefort.com/actions.
Query parameters
Response
s-maxage=300).
GET /api/public/actions/{owner}/{name}
Unauthenticated. Full risk-score detail for one action. Returns 404
when the action has never been seen. Case-insensitive on owner/name.
status is one of:
scored—latestholds the newest scored version with its full explanation payload:signals(the raw scoring inputs),breakdown(post-cap per-family deductions:static,metadata,tag,advisory),finding_counts, andalgorithm_version. Cached for 5 minutes.pending— the action is queued for the daily worker;latestisnull. Not cached (no-store).unknown— in the catalog but never scored;latestisnull.
versions lists every scored version, newest first, each with the refs
observed at that commit. See
what the families mean.
Response (trimmed, status: "scored")
POST /api/public/actions/{owner}/{name}/request
Unauthenticated. Request an on-demand score for an action not yet in the
catalog. No request body. The repository is validated (must exist and be
public), then scored synchronously (~5–10 s) using the same analysis as the
daily worker. When GitHub is unreachable the request degrades to enqueueing
for the daily worker instead of failing.
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
coverage is every combination the engine ships, whether or not it matched —
the denominator repos is the numerator of. Without it, a client cannot tell
an exhaustive search that found nothing from a search that never ran, because
both produce an empty repos array.
A combination with
searched_repos: 0 and blinded_repos: 0 was not evaluated
anywhere — the organization has no finished scans. Treat that as “unknown”,
not as “clean”.
Each component’s finding carries its fingerprint. A combination itself has
no stable identity to age — it is correlated on every read — so the fingerprint
is what lets a client join back to the finding ledger and date the chain from
its ingredients.
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.
The response carries the same coverage array, scoped to this repository:
searched_repos and blinded_repos are 1/0 or 0/1. A repository that
has never been scanned returns coverage: [] rather than a list of combinations
claiming to have been searched.
GET /api/actions/ranking
Returns marketplace actions ranked worst-first by their
risk score, for API consumers building their own
dashboards (the web app renders per-repo scores directly). One row per action —
its latest scored version. Requires authentication.
Query parameters
Response — a JSON array, lowest score first:
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
Response
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.
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
POST /api/webhooks/github
Receives GitHub App webhook deliveries — the trigger behind
monitored repos. Not bearer-authenticated:
each delivery is authenticated by its GitHub signature (401 on a bad or
missing signature).
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,
snoozed; reason is optional free text.
expires_at (RFC3339) is required for accepted and snoozed and ignored
for every other status. It must be in the future and no more than two years
out; a request without it is a 400. This is what makes an accepted risk
temporary rather than a dismissal by another name.
GET /api/cron/expire-triage
Returns lapsed accepted / snoozed findings to the queue as open and
recomputes the affected repositories’ latest-scan counts. Scheduled daily;
authenticated by the CRON_SECRET bearer token, not a user session, and 503
when no cron secret is configured.
Notification settings
Back the Notifications card in Settings. Your current settings are read directly (scoped to your account); 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 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. The policy editor reads and writes by rule across every repository, so it uses the two org-scoped endpoints at the end of this section rather than looping the per-repo ones.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.
GET /api/rule-overrides
Every repo-scoped override in the org, in one request. Optional
?rule_id=<id> narrows it to a single rule; an unknown slug returns 400.
Org-scoped rows only — global toggles come from GET /api/rule-settings.
PUT /api/rule-overrides:batch
Applies up to 500 policy changes in a single transaction. Either all of
them land or none do.
Request
repository_id: nulltargets the org default (the same rowPUT /api/rule-settings/{rule_id}writes); a uuid targets that repository’s override.valueis"enabled","disabled", or"inherit"—inheritdeletes the row so the level above it decides.
A repository in another org returns the same
404 as one that does not exist,
so a batch cannot be used to probe for repository ids.
Workflow hardener
POST /api/public/harden
Unauthenticated. Takes a pasted GitHub Actions workflow and returns the
hardened YAML — the same in-memory auto-fixer the app’s Open fix PR button
uses. Nothing is stored. Shares the public teaser scan’s per-IP and global rate
limits.
Request
categories is optional; omit it (or send []) to apply every available fix.
Response
hunks is which lines of this response’s diff the category is responsible
for, in 1-based line numbers: old_* indexes original, new_* indexes
hardened, and a pure insertion has old_lines: 0. It is what lets a client
untick a fix and revert exactly its lines, or hover one to isolate them —
without re-posting once per category to work it out by subtraction.
A category that was not applied has no hunks: this diff does not contain
its fix. unattributed_hunks is normally empty; when it is not, the categories
do not account for every changed line, and a client should say so rather than
present its checkboxes as complete.
Errors: 400 empty or oversized body, 422 not a parseable Actions workflow,
429 rate limited, 504 timed out resolving action versions (retry, or drop
CICD-SEC-3 from categories).
GET /api/badge/{owner}/{repo}[.svg]
The embeddable README badge. Unauthenticated, reads the public-scan store only,
and always returns a 200 SVG — even for an invalid or never-scanned repo —
so a README never renders a broken image.
max-age, so badges refresh on the order of hours regardless.
Bulk remediation campaigns
Org-wide fix-PR campaigns with per-repo tracking — see Bulk remediation campaigns. Like scanning, the campaign is client-driven: the browser loops the per-repo endpoint with bounded concurrency so each request stays short.POST /api/campaigns
Creates a campaign over the selected repositories. Admin only (403 for
members); resolves the org like other org-scoped calls (?org= or the personal
org). Repos outside the org are silently skipped; 400 if none remain — and in
that case nothing is persisted, so no empty campaign is left behind.
Request
ruleset must be one of all, owasp, slsa, or a specific SLSA level
(slsa-build-l1…l3, slsa-source-l1…l4); anything else is 400. An
unrecognized value would match no rule at all, which would make every
repository look clean.
Response — 201 with the campaign and its per-repo rows (all pending):
POST /api/campaigns/{campaign_id}/repos/{repo_id}/remediate
Remediates one repository in a campaign (admin only): fetches and scans
its workflow files in memory, opens (or reuses) one fix PR/MR per remediable
file, and persists the per-repo status plus the campaign’s rollup. Every
fixable rule for a given file is applied in a single commit, so prs_opened
counts files remediated, not findings.
Before anything is opened, the repository’s row is claimed — moved from
pending to running — which is what makes the endpoint safe to retry:
404when the campaign is unknown, or the repo isn’t part of it, or it belongs to a different organization. This is checked before any PR is opened.409when the repository is already being remediated, or already finished in this campaign. A double-click or client retry is a no-op rather than a second set of pull requests. A repo that failed can be retried — re-POSTing it claims the row again and puts the campaign back intorunning.- A run that dies mid-flight leaves the row
running; it becomes retryable again after 15 minutes.
error can also be set alongside succeeded: that is a partial
remediation — some files got PRs while others failed (an expired token,
branch protection rejecting a push). The dashboard renders those repos as
Partial rather than as a clean success.
GET /api/campaigns/{campaign_id}
The tracker: campaign, per-repo statuses, and the derived rollup. Any org
member may read; 404 for non-members.
Reading data directly (no API call)
Reading scans, findings, repositories, installations, and rule settings does not go through these endpoints — the dashboard reads them directly from the database, scoped to your account so you only ever see your own data. The API only handles writes (persisting scan results and rule preferences), GitHub-side reads (which need an installation token), and the installation link callback.GET /api/cron/refresh-fix-prs
Asks GitHub what is happening inside every fix pull request with no recorded
outcome, and stores the answer (see
the fix tracker). Scheduled
daily; authenticated by the CRON_SECRET bearer token, not a user session,
and 503 when no cron secret or GitHub App is configured.
failed is reported separately rather than folded into refreshed.