Pencheff

Supply chain

API reference

Authentication, targets, scans, findings, assets, and MCP tools.

ScopeFeatured

Jump into setup guides, feature references, reporting conventions, API documentation, methodology pages, and workflow-specific playbooks.

OutputUnified evidence

Findings, reports, dashboards, exports, integrations, and retests all read from the same normalized record.

MethodDeterministic first

Pencheff favors repeatable checks, then uses AI for triage, enrichment, orchestration, and remediation where it adds signal.

From the Pencheff docs

API overview

/reference/overview

Base URL:

  • Production: https://app.pencheff.com/api
  • Local dev: http://localhost:8000

All endpoints require a bearer token issued by Clerk. See Authentication.

Endpoints at a glance

NamespaceEndpointsDocs
/authLogin, signup, logoutauthentication
/targetsCRUD for scan targetstargets
/scansTrigger scans, stream progress, fetch resultsscans
/findingsList, suppress, verify, comment, assignfindings
/schedulesCron-driven recurring scansschedules
/assetsAttack surface inventoryassets
/integrationsSlack, Teams, PagerDuty, Splunk, webhookintegrations
/sboms/{scan_id}SBOM browse + downloadsboms
/dependencies/{scan_id}SCA dep inventorydependencies
/proxyIntercepting proxy sessionsproxy

MCP tools (plugin side)

When you run Pencheff as an MCP server, the following 81 MCP tools are available — see MCP tool reference for the full list.

Response conventions

  • Always JSON (Content-Type: application/json)
  • Timestamps in ISO 8601 UTC ("2026-04-21T14:23:00+00:00")
  • UUIDs for all primary keys
  • Errors: { "detail": "..." } for client errors, { "detail": "ClassName: message" } for server errors
  • Validation errors follow FastAPI's shape: { "detail": [{ "loc": [...], "msg": "..." }, ...] }

Rate limits

  • 60 requests/min per user (SaaS)
  • 30 concurrent scans per org on the Pro plan

Hit X-RateLimit-Remaining in the response headers to see how close you are.

From the Pencheff docs

Scans API

/reference/scans

POST /scans

Trigger a new scan.

POST /scans
{
  "target_id": "...",
  "profile": "standard",
  "consent_payload": {
    "authorization_text": "I am authorised to test example.com as of 2026-05-06 and I accept the disclosed actions.",
    "acknowledged": true
  }
}

profile is one of quick, standard, or deep. Older specialised names (engage, compliance, api-only, cicd, sca, iac, supply-chain, network-va, hackme, continuous, compliance-full) are still accepted and coerced to the matching tier at the runner — see Picking a profile for the fold-in table.

consent_payload is required when swarm mode is active (default). The API returns 422 Unprocessable Entity if the field is absent, if authorization_text is shorter than 50 characters, or if acknowledged is not true. The payload is persisted on Scan.consent_payload (JSONB) and included in every audit export. Shape (Pydantic schema ConsentPayload):

type ConsentPayload = {
  authorization_text: string;  // >= 50 characters
  acknowledged: boolean;       // must be true
};

GET /scans

List scans for the current org (paginated).

GET /scans/{id}

Fetch a single scan — includes summary counts, grade, progress.

GET /scans/{id}/progress (SSE)

Server-Sent Events stream of live progress ticks during an active scan. Events:

  • stage_start: recon_passive
  • stage_done: recon_passive: 47 endpoints
  • finding: HIGH SQLi on /api/users
  • finished

GET /scans/{id}/findings

Returns every finding for the scan with CVSS, EPSS, KEV, risk_score, compliance mapping.

GET /scans/{id}/linked-repos

Returns the repositories attached to this URL scan's target. Source-code findings (Semgrep · Bandit · gosec · Brakeman · ESLint · OSV · secret-scan) live on each repo's own assessment page (/repos/{repository_id}) — the URL scan deliberately does not mix SAST findings into its own results list. Use this endpoint to render a "Linked repositories" sidebar with deep-links.

type LinkedRepo = {
  repository_id: string;
  full_name: string;
  provider: string | null;
  scan_url: string;   // e.g. "/repos/abc-123"
};

Returns [] for scans whose target has no attached repos. Available on URL targets only.

GET /scans/{a}/compare/{b}

Compare two completed scans of the same workspace and return a structured diff. Particularly useful for LLM red-team scans where you want to gate a PR on safety regressions, or A/B different model versions on the same suite.

GET /scans/3a35.../compare/9359...
Authorization: Bearer <token>

Response:

type ScanCompare = {
  baseline: { name: string; summary: RedTeamSummary };
  candidate: { name: string; summary: RedTeamSummary };
  regressions: Finding[];     // candidate-only
  fixes: Finding[];           // baseline-only
  common_failures: Finding[]; // present in both
  counts: { regressions: number; fixes: number; common_failures: number };
  keys: { regressions: string[]; fixes: string[]; common_failures: string[] };
  scan_a: { id: string; profile: string; grade: string | null;
            score: number | null; created_at: string };
  scan_b: { id: string; profile: string; grade: string | null;
            score: number | null; created_at: string };
};

The dedup key is endpoint|parameter|technique|title, so re-running the identical suite against an unchanged target produces zero regressions. The web UI exposes the same diff at /scans/compare?a=…&b=… with a JUnit-XML download for the regressions list — wire it into CI to fail builds on new findings.

POST /scans/{id}/share (LLM only)

Issues a Fernet-encrypted token granting public read access to the scan's LLM-flavored report. Available only when the underlying target is kind: "llm".

POST /scans/3a35.../share?ttl_seconds=604800
Authorization: Bearer <token>

Response:

{
  "token": "gAAAAA...",
  "expires_in": 604800,
  "url_path": "/share/llm/gAAAAA..."
}

The companion public route GET /share/llm/{token} renders the scan as HTML (default), Markdown, CSV, or JSON depending on the ?download= query param. Token expiry is the only revocation — let it expire to revoke. PII is redacted in evidence snippets before rendering, regardless of the public/private path.

GET /scans/{id}/llm-traces

Returns the full LLM call trace for a scan. Each row corresponds to one chat-completions call made by a swarm agent during the scan.

Authentication required. Returns [] for scans run with SWARM_ENABLED=false.

type ScanLLMTrace = {
  id: string;
  scan_id: string;
  agent: string;             // e.g. "InjectionAgent", "ChainAgent"
  turn: number;              // agent conversation turn number
  request_messages: object[];  // full messages array sent to the LLM
  response: object;          // raw LLM response including tool-call blocks
  input_tokens: number;
  output_tokens: number;
  cache_read_tokens: number;
  reasoning: string | null;  // thinking/reasoning block if present
  created_at: string;
};

GET /scans/{id}/evidence/{finding_id}.png

Returns the Playwright evidence screenshot captured by EvidenceCaptureAgent for the specified finding. PII is redacted before storage.

Authentication required. Returns 404 Not Found if no screenshot exists for that finding (e.g. the finding was below high severity, the agent did not run, or the scan used the legacy single-agent path).

GET /scans/3a35.../evidence/f7b2c4....png
Authorization: Bearer <token>

Response: image/png binary. Cache the response client-side — screenshots do not change after the scan completes.

DELETE /scans/{id}

Cancel an in-flight scan or remove a finished one.

Scan object

type Scan = {
  id: string;
  target_id: string;
  status: "queued" | "running" | "completed" | "failed" | "cancelled";
  profile: string;
  progress_pct: number;      // 0-100
  current_stage: string | null;
  summary: {
    critical: number; high: number; medium: number; low: number; info: number;
    suppressed: number;
  } | null;
  grade: "A" | "B" | "C" | "D" | "F" | null;
  score: number | null;
  started_at: string | null;
  finished_at: string | null;
};

From the Pencheff docs

Findings API

/reference/findings

GET /scans/{scan_id}/findings

Return every finding for a scan. Filter with query params:

  • ?severity=critical
  • ?category=injection
  • ?owasp_category=A03
  • ?verified_only=true
  • ?include_suppressed=true
  • ?sort=risk_score (default; use cvss_score or created_at)

GET /findings/{id}

Fetch a single finding with full evidence, comments, assignments, tags.

PATCH /findings/{id}

Update status. Valid fields:

{
  "verification_status": "true_positive" | "false_positive" | "true_negative" | "false_negative",
  "suppressed": true,
  "suppress_reason": "accepted_risk" | "wont_fix" | "false_positive" | "duplicate" | "out_of_scope",
  "suppress_notes": "string",
  "resolved_at": "2026-04-21T…Z",
  "sla_days": 7
}

Collaboration

  • POST /findings/{id}/comments — add a comment
  • GET /findings/{id}/comments — list comments
  • POST /findings/{id}/assign{"assignee_user_id": "..."}
  • POST /findings/{id}/tags{"tag": "p0-fix"}
  • DELETE /findings/{id}/tags/{tag} — remove a tag

Prioritisation fields

Every Finding includes the unified prioritisation surface:

FieldTypeSource
risk_scorefloat (0–100)computed at insert from CVSS × EPSS × KEV × SSVC × reachability
ssvc_decisionstringone of act, attend, track_star, track
reachabilitystringone of exploited, reachable, present, unknown — see Reachability classifier
epssfloat (0–1) | nullEPSS feed; populated for SCA findings
kevboolCISA KEV catalog membership

Sort the list endpoint by risk_score:

GET /scans/{scan_id}/findings?sort=risk_score   (default)

The unified, cross-table queue lives at /unified-findings.

POST /findings/{id}/propose_fix

Generates a draft FixProposal for the finding. SCA findings get a deterministic version-bump diff; SAST/DAST findings synthesise a unified diff via the operator-configured patch-synthesis backend. See Auto-fix PRs.

The route accepts kind{sast, dast}; SCA findings ride the dast kind and Pencheff detects the SCA payload from evidence and routes internally.

POST /findings/{id}/triage

Pro tier. Triage 2.0 — exploitability walkthrough returning { walkthrough, blast_radius, exploit_scenario, fix_outline, confidence }. Cached on finding.ai_triage; pass ?force=true to regenerate. See Triage 2.0.

From the Pencheff docs

API keys (PENCHEFF_API_KEY)

/reference/api-keys

PENCHEFF_API_KEY tokens give scripts, CI pipelines, and scheduled jobs programmatic access to the Pencheff API without holding a Clerk session.

Each key is always pinned to one organisation and may additionally be pinned to a single workspace. Permissions are granted as a list of fine-grained category:action scopes — a key can only call endpoints whose required scope it holds.

Key format

pcf_live_<43+ url-safe base64 chars>

The first eight characters after pcf_live_ form the lookup prefix displayed in the dashboard (pcf_live_aB3xZ9k1…). The full secret is shown once at creation — copy it then; it cannot be recovered.

Creating a key

In the dashboard: Settings → API keys → New key. Programmatically (a session-only endpoint — you must be signed in):

curl -X POST https://api.pencheff.com/api/v1/api-keys \
  -H "Authorization: Bearer $CLERK_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "GitHub Actions — production CI",
    "org_id": "org_01H...",
    "workspace_id": "ws_01H...",
    "scopes": ["scans:write", "findings:read", "reports:export"],
    "expires_at": "2027-05-07T00:00:00Z"
  }'

Response:

{
  "id": "ak_01H...",
  "name": "GitHub Actions — production CI",
  "key": "pcf_live_aB3xZ9k1...43chars...",
  "prefix": "aB3xZ9k1",
  "org_id": "org_01H...",
  "workspace_id": "ws_01H...",
  "scopes": ["findings:read", "reports:export", "scans:write"],
  "effective_scopes": ["findings:read", "reports:export", "scans:write"],
  "expires_at": "2027-05-07T00:00:00Z",
  "created_at": "2026-05-07T22:14:00Z"
}

The key field is only present in the create response. Save it now.

Using a key

Send it as a bearer token. The Authorization header is the only accepted location — query-string ?token= is rejected for keys (URLs leak into logs and browser history).

export PENCHEFF_API_KEY="pcf_live_aB3xZ9k1...43chars..."

curl https://api.pencheff.com/scans \
  -H "Authorization: Bearer $PENCHEFF_API_KEY"

The active workspace is read from the key's pin. If the key is workspace-scoped, requests with a conflicting X-Workspace-Id header are rejected with 403. If the key is org-scoped only (workspace_id: null), every request must include X-Workspace-Id and the workspace must belong to the key's org.

Permission model

Default-deny

API-keyed requests are rejected by default on every endpoint that does not explicitly declare a required scope. There is no fallback to "all permissions" — even a key with *:* cannot reach an endpoint that opts out of API-key access.

Session-only endpoints

A separate set of endpoints rejects API-keyed requests outright (HTTP 403). These never accept a key, regardless of scopes:

CategoryReason
api-keysA leaked key cannot mint more keys
authSign-in / signup / onboarding
billingStripe customer state, plan changes
brandingWorkspace branding
orgsMember roles, invites, org settings
workspacesWorkspace creation / rename

Org-wide vs. workspace-scoped keys

  • workspace_id: null — the key acts on any workspace in its org. The caller still has to pass X-Workspace-Id to pick one per request. Only org owners and admins may mint these.
  • workspace_id: <id> — the key is pinned to that workspace. Any other X-Workspace-Id is rejected. Any user (member or above) in the org may mint these for workspaces they belong to.

Membership re-check

Every request re-validates that the issuing user is still a member of the key's org. If an admin removes the user from the org, all of that user's keys for that org stop working immediately — there is no cache.

Scope catalog

Wildcards are supported when granting:

  • scans:* — both read and write on scans
  • *:read — read everything that exposes a read scope
  • *:* — every scope in the catalog (admin-equivalent)

The matcher always normalises the required scope to the concrete form declared by the endpoint, so scans:* will satisfy scans:write.

ScopeWhat it grants
assets:readList assets in the inventory
assets:writeTrigger ASM discovery, modify or delete assets
comments:readRead finding comments
comments:writeCreate or edit finding comments, assign findings, manage tags
dashboard:readRead dashboard metrics: heatmap, trend, KEV exposure, fix conversion
dependencies:readRead SCA dependency data
engagements:readRead engagement metadata and unified findings
engagements:writeCreate, close, or rotate engagement pairing codes
findings:readList and read findings
findings:writeTriage, recheck, suppress, reopen, change status
fix_proposals:readRead fix proposal status, diffs, and usage stats
fix_proposals:writeGenerate, apply, revert auto-fix proposals; bulk-fix
integrations:readRead integration configuration
integrations:writeCreate, modify, delete, or test integrations
intruder:readRead intruder payload sets, attacks, and results
intruder:writeCreate payload sets and run intruder attacks
notes:readRead engagement notes
notes:writeCreate, modify, or delete engagement notes
proxy:readRead proxy session state and per-scan history
proxy:writeStart or stop proxy sessions
repeater:readRead repeater tabs and saved responses
repeater:writeCreate, modify, or send repeater requests
repos:readRead repositories, repo scans, repo findings, repo SBOMs
repos:writeConnect repos, trigger repo scans, generate SBOMs
reports:exportGenerate reports (PDF, DOCX, HTML)
reports:readRead existing reports and download files
scans:readList and read scans, get progress, view findings
scans:writeInitiate, configure, cancel, or rerun scans
schedules:readRead scheduled scans
schedules:writeCreate, modify, or delete scheduled scans
sboms:readRead SBOMs
targets:readRead targets
targets:writeCreate, modify, or delete targets
traffic:readRead recorded HTTP traffic
traffic:writeTag or modify traffic rows
unified_findings:readRead the unified-finding queue

Coverage matrix

The default-deny dependency layer rejects API-keyed requests on any endpoint that doesn't explicitly declare a required scope. Every scope listed above is wired into at least one HTTP endpoint. All of the following routers participate:

  • /scans/*, /findings/*, /targets/*, /reports/*, /assets/*
  • /integrations/*, /schedules/*, /engagements/*
  • /repos/* (except /repos/install-url and /repos/callback, which are GitHub App handshake endpoints — session-only)
  • /sboms/*, /dependencies/*
  • /repeater/*, /intruder/*, /proxy/*, /traffic/*
  • /notes/*, /comments/*, /findings/{id}/assign, /findings/{id}/tags
  • /fix-proposals/*, /fix-tasks/*, /scans/{id}/fix-all, /repo-scans/{id}/fix-all, /findings/{kind}/{id}/propose_fix, /findings/{kind}/{id}/fix_proposal, /usage/fix-llm
  • /dashboard/*
  • /unified-findings/*

The current authoritative list is also available via:

curl https://api.pencheff.com/api/v1/api-keys/scopes \
  -H "Authorization: Bearer $CLERK_JWT"

Listing, updating, revoking

# List all your keys (does NOT return the secret)
curl /api/v1/api-keys -H "Authorization: Bearer $CLERK_JWT"

# Update name / scopes / expiry (cannot reissue the secret)
curl -X PATCH /api/v1/api-keys/$ID \
  -H "Authorization: Bearer $CLERK_JWT" \
  -d '{"scopes": ["scans:read"]}'

# Revoke (immediate; can't be undone)
curl -X DELETE /api/v1/api-keys/$ID \
  -H "Authorization: Bearer $CLERK_JWT"

Every create / update / revoke action is recorded in the audit_logs table tagged with the key ID for forensic traceability.

Plan limits

A single user can hold up to 50 active (non-revoked) keys across all their orgs. Revoke keys you no longer use to free a slot.

Recipes

CI/CD pipeline (read scans, export reports)

Mint a workspace-pinned key with the minimum needed scopes:

{
  "name": "Buildkite — main",
  "org_id": "org_01H...",
  "workspace_id": "ws_prod",
  "scopes": ["scans:write", "scans:read", "findings:read", "reports:export"],
  "expires_at": "2027-05-07T00:00:00Z"
}

The GitLab CI and Azure DevOps templates (apps/gitlab-ci, apps/azure-devops) currently run the local pencheff CLI, which does not call the hosted backend. If your pipeline talks to the hosted Pencheff API directly (custom curl steps, a thin internal CI agent, etc.), pass the key as the Authorization: Bearer … header — that is the only thing the API checks.

Read-only finding sync to a SIEM

{
  "name": "Splunk forwarder",
  "org_id": "org_01H...",
  "workspace_id": "ws_prod",
  "scopes": ["findings:read", "unified_findings:read"],
  "expires_at": null
}

One-org-many-workspaces automation

Org admins can mint a single org-scoped key and let the caller pick the workspace at request time:

{
  "name": "ACME bot — fan-out scanner",
  "org_id": "org_01H...",
  "workspace_id": null,
  "scopes": ["scans:write", "findings:read"]
}

The script must then send X-Workspace-Id on every request:

curl https://api.pencheff.com/scans \
  -H "Authorization: Bearer $PENCHEFF_API_KEY" \
  -H "X-Workspace-Id: ws_staging"

Security notes

  • Keys are stored as SHA-256 of the full token. The plaintext is shown only once at creation.
  • The full token has 256 bits of entropy from secrets.token_urlsafe, so plain SHA-256 (no bcrypt) is sufficient — the comparison is still constant-time (hmac.compare_digest).
  • Keys must be sent in the Authorization header. Query-string token passing is rejected for pcf_live_* to keep secrets out of logs and browser history.
  • A revoked key returns 401 on the very next request — there is no TTL.
  • A leaked key exposes only what its scopes allow on its pinned org / workspace. It cannot mint more keys, change billing, or modify org membership.

Related

Keep exploring Resources.