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 your access token in the Authorization header:
The token is the access token you receive after signing in with GitHub. The API verifies it and identifies your account for the request.

Rate limiting & request IDs

Authenticated endpoints are rate-limited to 120 requests per user per minute; 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) — 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
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
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
Pasted GitHub URLs (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
Rules are ranked severity-first, then by internal (unpublished) frequency, capped at 10; titles, severities, and descriptions come from the rule catalog, not stored findings. 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
Cached at the CDN for 5 minutes (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:
  • scoredlatest holds 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, and algorithm_version. Cached for 5 minutes.
  • pending — the action is queued for the daily worker; latest is null. Not cached (no-store).
  • unknown — in the catalog but never scored; latest is null.
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
Detection runs the same engine as the CLI, so a combination never includes a finding from a rule disabled in Rule settings. 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
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. 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 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. 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
Response

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.
Response — the new status plus the repo’s recomputed latest-scan counts (counts roll up open findings only, so triage changes them):

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.
Up to 500 rows per invocation — a larger backlog drains over successive runs.

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:
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.

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.
This exists because the per-repo endpoint is the wrong shape for a policy editor: eight repositories is eight requests, a real org is hundreds, and a client cannot tell “this repo has no override” apart from “that one request failed” — so at scale the per-repo path renders a policy that is not the policy in force.

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: null targets the org default (the same row PUT /api/rule-settings/{rule_id} writes); a uuid targets that repository’s override.
  • value is "enabled", "disabled", or "inherit"inherit deletes the row so the level above it decides.
Response
Everything is validated before anything is written, so a rejected batch changes nothing: 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.
It reflects the latest public scan of that repository within 90 days, which matches the public-scan retention window. Note that GitHub proxies README images through Camo, which caches aggressively and ignores the response’s 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-l1l3, slsa-source-l1l4); anything else is 400. An unrecognized value would match no rule at all, which would make every repository look clean. Response201 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:
  • 404 when 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.
  • 409 when 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 into running.
  • A run that dies mid-flight leaves the row running; it becomes retryable again after 15 minutes.
Response
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.
Two GitHub calls per pull request (the pull request, then its head commit’s check runs), capped at 200 per invocation and ordered least-recently-checked first — so the cap rotates rather than starving the tail. A pull request that cannot be read keeps its previous answer instead of gaining a guess, which is why failed is reported separately rather than folded into refreshed.