REST API

Overview

Pensar provides a REST API for programmatic access to your workspace data. It covers the same pentest, issue, and fix capabilities as the MCP Server — plus attack-surface and webhook-endpoint routes that MCP does not expose — and is designed for automation, scripting, and building custom integrations.

If you want findings pushed to you rather than polled for, register a webhook endpoint instead of looping over GET /issues.

The REST API uses API key authentication. For AI-assistant integrations, consider using the MCP Server which supports OAuth-based authentication and works natively with tools like Claude, Cursor, and Windsurf.

Base URL

https://api.pensar.dev

Authentication

All requests must include a valid Pensar API key. You can provide it in one of two ways:

MethodHeaderExample
x-api-key headerx-api-key: <api_key>x-api-key: sk-id__abc123...
Bearer tokenAuthorization: Bearer <api_key>Authorization: Bearer sk-id__abc123...

The workspace is resolved automatically from the API key, so you do not need to send a workspace identifier. (The X-Workspace-Id header is only used by browser/WorkOS-JWT callers, not by API-key callers.)

Creating an API Key

  1. Navigate to Settings > Integrations > API Keys in the Pensar Console
  2. Click Create API Key
  3. Give it a descriptive name and copy the key value
  4. Store it securely — the key is only shown once

API keys are scoped to a workspace. All API requests authenticate against the workspace associated with the key.

Endpoints

Pentests

List Pentests

GET /pentests

Returns all pentests (scans) in the workspace.

Response:

1[
2 {
3 "id": "51111111-...",
4 "label": "Full Scan",
5 "status": "completed",
6 "scanType": "whitebox-pentest",
7 "branch": "main",
8 "startedAt": "2026-03-17T12:00:00Z",
9 "completedAt": "2026-03-17T12:45:00Z"
10 }
11]

Get Pentest

GET /pentests/:pentestId

Returns detailed information about a specific pentest.

ParameterInTypeRequiredDescription
pentestIdpathstringYesPentest (scan) UUID

Response:

1{
2 "id": "51111111-...",
3 "label": "Full Scan",
4 "status": "completed",
5 "workspaceName": "My Workspace",
6 "scanType": "whitebox-pentest",
7 "branch": "main",
8 "startedAt": "2026-03-17T12:00:00Z",
9 "completedAt": "2026-03-17T12:45:00Z",
10 "errorMessage": null,
11 "issuesCount": 4,
12 "reportReady": true
13}

Dispatch Pentest

POST /pentests

Launch a new pentest in the workspace. The pentest is queued and runs asynchronously.

ParameterInTypeRequiredDescription
branchbodystringNoTarget branch. Defaults to the repository’s default branch.
scanLevelbodystringNo"priority" (quick) or "full" (comprehensive). Defaults to "priority".

Request Body:

1{
2 "branch": "main",
3 "scanLevel": "full"
4}

Response (201):

1{
2 "scanId": "51111111-...",
3 "label": "Full Scan",
4 "status": "queued",
5 "message": "Pentest queued for My Workspace on branch main"
6}

List Pentest Targets

GET /pentests/:pentestId/targets

Returns the targets tested during a pentest. A target is a single endpoint of the attack surface that the pentest exercised. Targets are the entry point for querying the pentest’s execution logs, which are persisted per target rather than per issue (see Target Logs).

ParameterInTypeRequiredDescription
pentestIdpathstringYesPentest (scan) UUID

Response:

1[
2 {
3 "id": "a1b2c3d4-...",
4 "url": "/api/login",
5 "applicationId": "55555555-...",
6 "applicationName": "web-frontend",
7 "status": "completed",
8 "attempt": 1,
9 "startedAt": "2026-03-17T12:01:00Z",
10 "completedAt": "2026-03-17T12:09:00Z",
11 "error": null
12 }
13]

Issues

List Issues

GET /issues

Returns security issues in the workspace with optional filtering.

ParameterInTypeRequiredDescription
scanIdquerystringNoFilter by pentest ID
statusquerystringNoFilter by status: open, closed, false-positive, in-review
severityquerystringNoFilter by severity: critical, high, medium, low
branchquerystringNoFilter by git branch

Response:

1[
2 {
3 "id": "11111111-...",
4 "issueLabel": "VULN-000123",
5 "title": "SQL Injection in login handler",
6 "severity": "critical",
7 "status": "open",
8 "location": "src/auth/login.ts",
9 "url": "https://console.pensar.dev/acme/VULN-000123",
10 "closedAt": null,
11 "closedDisposition": null
12 }
13]

issueLabel is the human-facing reference shown in the console and url links straight to the issue. Every :issueId path below accepts either form.

closedAt and closedDisposition are null on an open finding. The list carries the disposition because without it every closed finding reads as resolved; the free-text close fields are on Get Issue only, since this list is unpaginated.

Get Issue

GET /issues/:issueId

Returns full details of a specific security issue, including description, CWE classification, proof-of-concept, and affected code location.

ParameterInTypeRequiredDescription
issueIdpathstringYesIssue UUID or label (e.g. VULN-000123)

A closed issue also carries the full close record: closedAt, closedDisposition, closedMethod (how it was closed — manual, retest, mcp, api), closedReason, and closedComments. All five are null while the issue is open.

Update Issue

PATCH /issues/:issueId

Update the status of a security issue — close it, mark it as a false positive, or reopen it.

ParameterInTypeRequiredDescription
issueIdpathstringYesIssue UUID or label (e.g. VULN-000123)
statusbodystringNoNew status: open, closed, false-positive, in-review
userFlaggedFalsePositivebodybooleanNoFlag the issue as a false positive
userFlaggedFalsePositiveReasonbodystringNoReason for the false positive flag
closedDispositionbodystringNoStructured close verdict: resolved, wont-fix, out-of-scope, risk-accepted
closedReasonbodystringNoFree-text reason for closing
closedCommentsbodystringNoAdditional comments on closure

Request Body:

1{
2 "status": "closed",
3 "closedDisposition": "risk-accepted",
4 "closedReason": "Compensating control in place",
5 "closedComments": "Signed off by security"
6}

Response (200): the updated issue, including closedAt, closedReason, and closedDisposition.

closedDisposition is the structured verdict; closedReason is the free text beside it. A finding closed without a disposition reads as Resolved everywhere, so send one whenever the close is not a plain fix. Reopening clears the disposition along with the rest of the close record, and false-positive is its own status rather than a disposition, so it never carries one.

other is not accepted — it is being retired from the vocabulary. Any value outside the four is a 400, including null, so omit the field rather than sending one back from a read:

1{
2 "error": "closedDisposition must be one of: resolved, wont-fix, out-of-scope, risk-accepted"
3}

Retest Issue

POST /issues/:issueId/retest

Queues an asynchronous retest of a security issue against its original target.

ParameterInTypeRequiredDescription
issueIdpathstringYesIssue UUID or label (e.g. VULN-000123)

Response (201):

1{
2 "issueId": "11111111-...",
3 "sessionId": "22222222-...",
4 "status": "queued",
5 "message": "Issue retest queued"
6}

The retest runs asynchronously. Rather than polling for the verdict, subscribe to the retest.completed webhook — it carries the verdict and the full issue.

List Issue Retests

GET /issues/:issueId/retests

Returns the issue’s retest history, most recent first. An issue that has never been retested returns an empty array, not a 404.

ParameterInTypeRequiredDescription
issueIdpathstringYesIssue UUID

Response:

1[
2 {
3 "id": "a8e2c451-...",
4 "status": "fixed",
5 "stillExists": false,
6 "confidence": "high",
7 "evidence": "The union-select payload now returns 400 and the query is parameterized at src/db/query.ts:142.",
8 "recommendation": "Close the finding.",
9 "error": null,
10 "queuedAt": "2026-08-27T08:58:02.000Z",
11 "startedAt": "2026-08-27T08:58:41.000Z",
12 "completedAt": "2026-08-27T09:06:18.000Z"
13 }
14]

status is derived from the retest’s timestamps, error, and result rather than stored, so it cannot drift out of sync with them: queued, in-progress, fixed, still-vulnerable, or error. confidence is high, medium, or low, and is the field to build an escalation rule on.


Fixes

List Fixes

GET /issues/:issueId/fixes

Returns all available auto-remediation fixes for an issue.

ParameterInTypeRequiredDescription
issueIdpathstringYesIssue UUID or label (e.g. VULN-000123)

Get Fix

GET /fixes/:fixId

Returns the full fix details including the code diff, explanation, and affected file path.

ParameterInTypeRequiredDescription
fixIdpathstringYesFix UUID

Agent Logs

List Agent Logs

GET /issues/:issueId/logs

Returns agent execution logs for a specific issue.

ParameterInTypeRequiredDescription
issueIdpathstringYesIssue UUID or label (e.g. VULN-000123)
levelquerystringNoFilter by log level: debug, info, warn, error
rolequerystringNoFilter by role: assistant, user, system, tool-call, tool-result
limitqueryintegerNoMaximum number of logs to return

Search Agent Logs

POST /issues/:issueId/logs/search

Search agent logs by text pattern with configurable context.

ParameterInTypeRequiredDescription
issueIdpathstringYesIssue UUID or label (e.g. VULN-000123)
querybodystringYesSearch text pattern
levelbodystringNoFilter by log level
rolebodystringNoFilter by role
contextLinesbodyintegerNoNumber of surrounding context lines to include

Request Body:

1{
2 "query": "SQL injection",
3 "level": "info",
4 "contextLines": 3
5}

Target Logs

A pentest’s execution logs are persisted against the individual targets it tested (see List Pentest Targets), not against the issues it discovered. These endpoints query those logs directly by target id, so you can inspect a target’s full pentest activity even when it produced no issue. The level, role, and contextLines filters behave identically to the issue Agent Logs endpoints.

List Target Logs

GET /targets/:targetId/logs

Returns agent execution logs for a specific pentest target.

ParameterInTypeRequiredDescription
targetIdpathstringYesTarget UUID (from List Pentest Targets)
levelquerystringNoFilter by log level: debug, info, warn, error
rolequerystringNoFilter by role: assistant, user, system, tool-call, tool-result
limitqueryintegerNoMaximum number of logs to return

Search Target Logs

POST /targets/:targetId/logs/search

Search a target’s agent logs by text pattern with configurable context.

ParameterInTypeRequiredDescription
targetIdpathstringYesTarget UUID
querybodystringYesSearch text pattern
levelbodystringNoFilter by log level
rolebodystringNoFilter by role
contextLinesbodyintegerNoNumber of surrounding context lines to include

Attack Surface

These endpoints expose the discovered attack surface of the workspace — the applications (apps) Pensar tracks and the individual endpoints within them. They have no MCP equivalent.

List Apps

GET /apps

Returns the applications in the workspace.

ParameterInTypeRequiredDescription
limitqueryintegerNoMaximum number of apps to return
offsetqueryintegerNoNumber of apps to skip (pagination)

Create App

POST /apps

Creates a new application in the workspace.

ParameterInTypeRequiredDescription
namebodystringYesApplication name
descriptionbodystringYesDescription of the application
typebodystringNoApplication type
frameworkbodystringNoFramework the app is built with
domainIdbodystringNoAssociated domain ID
disallowedActionsbodystringNoActions the testing agent must not perform

Get App

GET /apps/:appId

Returns full details of a specific application.

ParameterInTypeRequiredDescription
appIdpathstringYesApplication UUID

Update App

PATCH /apps/:appId

Updates an application. All body fields are optional.

ParameterInTypeRequiredDescription
appIdpathstringYesApplication UUID
namebodystringNoApplication name
descriptionbodystringNoDescription of the application
typebodystringNoApplication type
frameworkbodystringNoFramework the app is built with
domainIdbodystringNoAssociated domain ID
disallowedActionsbodystringNoActions the testing agent must not perform

Delete App

DELETE /apps/:appId

Deletes an application.

ParameterInTypeRequiredDescription
appIdpathstringYesApplication UUID

List Endpoints

GET /apps/:appId/endpoints

Returns the endpoints discovered for a specific application.

ParameterInTypeRequiredDescription
appIdpathstringYesApplication UUID
typequerystringNoFilter by endpoint type
minRiskScorequerynumberNoOnly return endpoints at or above this risk score
limitqueryintegerNoMaximum number of endpoints to return
offsetqueryintegerNoNumber of endpoints to skip (pagination)

Create Endpoint

POST /apps/:appId/endpoints

Adds an endpoint to an application.

ParameterInTypeRequiredDescription
appIdpathstringYesApplication UUID
endpointbodystringYesThe endpoint path or identifier
descriptionbodystringYesDescription of the endpoint
typebodystringNoEndpoint type

Get Endpoint

GET /endpoints/:endpointId

Returns full details of a specific endpoint.

ParameterInTypeRequiredDescription
endpointIdpathstringYesEndpoint UUID

Update Endpoint

PATCH /endpoints/:endpointId

Updates an endpoint. All body fields are optional.

ParameterInTypeRequiredDescription
endpointIdpathstringYesEndpoint UUID

Delete Endpoint

DELETE /endpoints/:endpointId

Deletes an endpoint.

ParameterInTypeRequiredDescription
endpointIdpathstringYesEndpoint UUID

Search Apps

GET /search/apps

Searches applications in the workspace.

ParameterInTypeRequiredDescription
qquerystringYesSearch query
typequerystringNoFilter by application type
limitqueryintegerNoMaximum number of results to return
offsetqueryintegerNoNumber of results to skip (pagination)

Search Endpoints

GET /search/endpoints

Searches endpoints across all applications in the workspace.

ParameterInTypeRequiredDescription
qquerystringYesSearch query
applicationIdquerystringNoRestrict the search to a single application
typequerystringNoFilter by endpoint type
minRiskScorequerynumberNoOnly return endpoints at or above this risk score
authRequiredquerybooleanNoFilter by whether authentication is required
limitqueryintegerNoMaximum number of results to return
offsetqueryintegerNoNumber of results to skip (pagination)

Webhooks

Register HTTPS endpoints that Pensar pushes signed findings to as they are created, source-mapped, retested, and closed. The payload contract, signature-verification snippets, retry semantics, and endpoint requirements are documented on the Webhooks page; this section is the route reference.

The path is /webhook-endpoints, not /webhooks. /webhooks/* is reserved for inbound provider receivers and returns 404.

Subscribable event names are issue.created, issue.updated, issue.status_changed, retest.completed, and pentest.completed.

List Webhook Endpoints

GET /webhook-endpoints

Returns the workspace’s webhook endpoints, newest first. The signing secret is never included.

Response:

1{
2 "endpoints": [
3 {
4 "id": "f2a71c98-...",
5 "url": "https://hooks.acme.dev/pensar",
6 "events": ["issue.created", "issue.updated", "retest.completed"],
7 "enabled": true,
8 "description": "Vulnerability harness",
9 "health": {
10 "lastAttemptAt": "2026-08-26T20:00:01.468Z",
11 "lastStatus": "success",
12 "lastResponseCode": 200,
13 "consecutiveFailures": 0
14 },
15 "createdAt": "2026-08-26T19:14:02.117Z",
16 "updatedAt": "2026-08-26T19:14:02.117Z"
17 }
18 ]
19}

The health block is updated on every delivery attempt, so this single call tells you whether a receiver is healthy. At 20 consecutive failures the endpoint is disabled automatically.

Create Webhook Endpoint

POST /webhook-endpoints

Registers an endpoint and mints its signing secret. The URL must be HTTPS, must use a hostname rather than an IP literal, and must resolve exclusively to publicly routable addresses — see Endpoint requirements. Duplicate entries in events are collapsed.

ParameterInTypeRequiredDescription
urlbodystringYesHTTPS URL to deliver to. Unique within the workspace.
eventsbodystring[]YesEvents to subscribe to. Must be non-empty.
descriptionbodystringNoFree-text label shown in the console
enabledbodybooleanNoDefaults to true

Response (201):

1{
2 "endpoint": {
3 "id": "f2a71c98-...",
4 "url": "https://hooks.acme.dev/pensar",
5 "events": ["issue.created", "issue.updated", "retest.completed"],
6 "enabled": true,
7 "description": "Vulnerability harness",
8 "health": {
9 "lastAttemptAt": null,
10 "lastStatus": null,
11 "lastResponseCode": null,
12 "consecutiveFailures": 0
13 },
14 "createdAt": "2026-08-26T19:14:02.117Z",
15 "updatedAt": "2026-08-26T19:14:02.117Z"
16 },
17 "secret": "whsec_2f1c9a7e..."
18}

secret is returned by this route and by no other. Store it before you close the response — it cannot be read back, and rotating means deleting and re-registering the endpoint.

A URL Pensar would refuse to fetch is a 400 naming the reason; a URL already registered in the workspace is a 409.

Get Webhook Endpoint

GET /webhook-endpoints/:endpointId

Returns the endpoint plus its 20 most recent delivery attempts.

ParameterInTypeRequiredDescription
endpointIdpathstringYesWebhook endpoint UUID

Response:

1{
2 "endpoint": { "id": "f2a71c98-...", "url": "https://hooks.acme.dev/pensar", "...": "..." },
3 "recentDeliveries": [
4 {
5 "id": "d3f1aa08-...",
6 "event": "issue.created",
7 "status": "success",
8 "attempt": 1,
9 "responseCode": 200,
10 "responseBody": "ok",
11 "error": null,
12 "durationMs": 184,
13 "createdAt": "2026-08-26T20:00:01.284Z",
14 "deliveredAt": "2026-08-26T20:00:01.468Z"
15 }
16 ]
17}

Update Webhook Endpoint

PATCH /webhook-endpoints/:endpointId

Updates an endpoint. All body fields are optional, but at least one must be supplied — an empty body is a 400. A changed url is re-validated. Setting enabled to true resets consecutiveFailures to zero, so a re-enabled endpoint does not trip the auto-disable threshold on its next failure.

ParameterInTypeRequiredDescription
endpointIdpathstringYesWebhook endpoint UUID
urlbodystringNoNew HTTPS delivery URL
eventsbodystring[]NoReplacement subscription list. Must be non-empty.
enabledbodybooleanNoEnable or disable delivery
descriptionbodystring | nullNoFree-text label. null clears it.

Response:

1{
2 "endpoint": { "id": "f2a71c98-...", "enabled": true, "...": "..." }
3}

Delete Webhook Endpoint

DELETE /webhook-endpoints/:endpointId

Deletes the endpoint and, by cascade, its delivery history.

ParameterInTypeRequiredDescription
endpointIdpathstringYesWebhook endpoint UUID

Response:

1{
2 "deleted": true,
3 "id": "f2a71c98-..."
4}

Test Webhook Endpoint

POST /webhook-endpoints/:endpointId/test

Queues a real delivery so you can verify the wiring — URL reachability, signature verification, and your 2xx — without waiting for a real finding. It renders the workspace’s most recent finding as an issue.created event; if the workspace has no findings, it sends a clearly-marked synthetic one instead (label VULN-SAMPLE, nil UUID for every id). The event is always issue.created, whatever the endpoint subscribes to.

ParameterInTypeRequiredDescription
endpointIdpathstringYesWebhook endpoint UUID

Response (202):

1{
2 "deliveryId": "7c4e1b09-...",
3 "endpointId": "f2a71c98-...",
4 "event": "issue.created",
5 "sample": false,
6 "issueId": "1b7e9d40-..."
7}

sample is true when the synthetic event was sent. The delivery is queued, not yet sent — read the delivery log for the outcome. Testing a disabled endpoint is a 409.

List Webhook Deliveries

GET /webhook-endpoints/:endpointId/deliveries

Returns delivery attempts for an endpoint, newest first. One record per attempt, including retries.

ParameterInTypeRequiredDescription
endpointIdpathstringYesWebhook endpoint UUID
limitqueryintegerNoMaximum attempts to return. Defaults to 50, capped at 200.
includePayloadquerybooleanNoInclude the rendered body sent on each attempt. Defaults to false.

Response:

1{
2 "deliveries": [
3 {
4 "id": "d3f1aa08-...",
5 "event": "issue.created",
6 "status": "success",
7 "attempt": 1,
8 "responseCode": 200,
9 "responseBody": "ok",
10 "error": null,
11 "durationMs": 184,
12 "createdAt": "2026-08-26T20:00:01.284Z",
13 "deliveredAt": "2026-08-26T20:00:01.468Z"
14 }
15 ],
16 "limit": 50
17}

status is pending, success, or failed. id is the value that travelled as the X-Pensar-Delivery header, and responseBody is truncated to 2 KB.


Auth

Validate API Key

GET /auth/validate

Validates the API key and returns the associated workspace. Useful for CLI tools to resolve workspace context from a stored API key.

Response:

1{
2 "workspace": {
3 "id": "workspace-uuid",
4 "name": "My Workspace",
5 "slug": "my-workspace"
6 }
7}

Error Handling

The API returns standard HTTP status codes. Error responses include a JSON body with an error field:

1{
2 "error": "Scan not found or not in this workspace"
3}

Other resource-specific variants include "Issue not found or not in this workspace" and "Fix not found or not in this workspace".

Endpoints taking an :issueId distinguish a malformed reference from an unknown one. A value that is neither a UUID nor a VULN-… label is rejected before the lookup runs:

1{
2 "error": "Issue reference must be a UUID or a VULN-000000 label (issueId=not-an-issue)"
3}

A well-formed reference that matches no issue in the workspace returns 404.

Status CodeMeaning
200Success
201Created (e.g., pentest dispatched)
400Bad request (missing required fields, or a malformed issue reference)
401Unauthorized (invalid or missing API key)
403Forbidden (workspace access denied)
404Resource not found
500Internal server error

Next Steps