> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.pensar.dev/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.pensar.dev/_mcp/server.

# REST API

> Programmatic access to Pensar pentests, issues, fixes, and attack surface

## 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](/console/integrations/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](/console/features/webhooks) instead of looping over `GET /issues`.

> **Info**
>
> The REST API uses API key authentication. For AI-assistant integrations, consider using the [MCP Server](/console/integrations/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:

| Method               | Header                            | Example                                  |
| -------------------- | --------------------------------- | ---------------------------------------- |
| **x-api-key header** | `x-api-key: <api_key>`            | `x-api-key: sk-id__abc123...`            |
| **Bearer token**     | `Authorization: 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

> **Warning**
>
> 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:**

```json
[
  {
    "id": "51111111-...",
    "label": "Full Scan",
    "status": "completed",
    "scanType": "whitebox-pentest",
    "branch": "main",
    "startedAt": "2026-03-17T12:00:00Z",
    "completedAt": "2026-03-17T12:45:00Z"
  }
]
```

#### Get Pentest

```
GET /pentests/:pentestId
```

Returns detailed information about a specific pentest.

| Parameter   | In   | Type   | Required | Description         |
| ----------- | ---- | ------ | -------- | ------------------- |
| `pentestId` | path | string | Yes      | Pentest (scan) UUID |

**Response:**

```json
{
  "id": "51111111-...",
  "label": "Full Scan",
  "status": "completed",
  "workspaceName": "My Workspace",
  "scanType": "whitebox-pentest",
  "branch": "main",
  "startedAt": "2026-03-17T12:00:00Z",
  "completedAt": "2026-03-17T12:45:00Z",
  "errorMessage": null,
  "issuesCount": 4,
  "reportReady": true
}
```

#### Dispatch Pentest

```
POST /pentests
```

Queue a hosted pentest in the authenticated workspace. It runs asynchronously; use the returned `scanId` with `GET /pentests/:pentestId` to check its status. An identical active launch may be reused, in which case the response identifies that existing scan.

| Parameter           | In   | Type      | Required | Description                                                                                                                                                                                                                                                            |
| ------------------- | ---- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `endpointIds`       | body | string\[] | No       | Exact list of 1–1000 discovered endpoint UUIDs in this workspace. Requires full depth; cannot be combined with `branch`.                                                                                                                                               |
| `scanLevel`         | body | string    | No       | `"priority"` or `"full"`. Defaults to `"full"` when `endpointIds` is supplied, otherwise `"priority"`. Only `"full"` is accepted with `endpointIds`.                                                                                                                   |
| `branch`            | body | string    | No       | Must match the connected repository's configured target branch. Omitting it uses that configured branch; this does not select a different branch for one run. Cannot be combined with `endpointIds`.                                                                   |
| `knowledgeEntryIds` | body | string\[] | No       | Up to 20 active [knowledge base](#knowledge-base) entry UUIDs to give the run as context, the same as @-mentioning entries at launch in the Console. Works with or without `endpointIds`; an archived, stale, or unknown entry returns `400` before a scan is created. |

**Select exact endpoints:**

First list applications with `GET /apps`, then retrieve their discovered endpoints with `GET /apps/:appId/endpoints` (paginate with `limit` and `offset`). Use the endpoint `id` values, not URLs, application IDs, or the target IDs returned by a previous pentest. Endpoints from multiple applications may be selected when they belong to the same workspace.

```bash
curl --fail-with-body https://api.pensar.dev/pentests \
  -H "Authorization: Bearer $PENSAR_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "endpointIds": [
      "7e4fe67a-61e5-4aa5-b93a-6871b470aaef",
      "59e9b6d5-d13b-4912-9e46-a1e801c1d053"
    ],
    "scanLevel": "full"
  }'
```

Replace the example UUIDs with IDs from your workspace. `scanLevel` may be omitted for this request because exact endpoint launches default to full depth.

**How scope is selected:**

* With `endpointIds`, only those discovered endpoints become scan targets. The server derives their applications, removes duplicate IDs, and does not narrow the list using a repository commit or priority depth. Even a list containing every current endpoint remains an explicit list.
* Without `endpointIds`, the server resolves eligible workspace applications. These launches, including Apex `pensar pentests dispatch`, are not attributed to a repository and do not narrow targets against a previous pentest, even if a commit SHA is recorded. `scanLevel` defaults to `"priority"`; depth is separate from diff selection. For repository-baseline comparisons, use the [CI dispatch integration](/console/features/ci-integration#how-the-ci-diff-is-calculated). To test the changes between two specific commits, send `baseSha` and `commitSha` to `POST /ci/dispatch` ([compare two explicit commits](/console/features/ci-integration#compare-two-explicit-commits)); this endpoint does not accept them.
* Selected applications must be ready to test: they need a linked domain with pentesting enabled, domain verification when required, and discovered endpoints. A rejected selected application does not silently reduce the requested scope.

**Launch without an explicit endpoint list:**

```json
{
  "scanLevel": "full"
}
```

To include `branch`, supply the configured target branch, for example `{"branch":"main","scanLevel":"full"}` when that repository is configured to analyze `main`.

**Response (201):**

```json
{
  "scanId": "51111111-1111-4111-8111-111111111111",
  "label": "PEN-000123",
  "status": "queued",
  "message": "Pentest queued for My Workspace"
}
```

**Validation:** Malformed JSON, unknown fields, an empty or oversized endpoint list, invalid UUIDs, missing or deleted endpoints, endpoints outside the workspace, and incompatible `branch` or `scanLevel` values return `400` before a scan is created. The endpoint list is never replaced with workspace-wide scope after validation fails. For example:

```json
{
  "error": "One or more endpoint IDs were not found in this workspace"
}
```

Send only the documented launch fields. Application IDs, custom objectives, model overrides, and credential selections are not accepted by this endpoint.

For command-line examples, including reading endpoint IDs from a file or stdin, see [Apex CLI: launch selected endpoints](/apex/cli-commands/pensar-pentests#launch-selected-endpoints). AI assistants can use [MCP `dispatch_pentest`](/console/integrations/mcp-server).

#### 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](#list-target-logs)).

| Parameter   | In   | Type   | Required | Description         |
| ----------- | ---- | ------ | -------- | ------------------- |
| `pentestId` | path | string | Yes      | Pentest (scan) UUID |

**Response:**

```json
[
  {
    "id": "a1b2c3d4-...",
    "url": "/api/login",
    "applicationId": "55555555-...",
    "applicationName": "web-frontend",
    "status": "completed",
    "attempt": 1,
    "startedAt": "2026-03-17T12:01:00Z",
    "completedAt": "2026-03-17T12:09:00Z",
    "error": null
  }
]
```

---

### Issues

#### List Issues

```
GET /issues
```

Returns security issues in the workspace with optional filtering.

| Parameter  | In    | Type   | Required | Description                                                       |
| ---------- | ----- | ------ | -------- | ----------------------------------------------------------------- |
| `scanId`   | query | string | No       | Filter by pentest ID                                              |
| `status`   | query | string | No       | Filter by status: `open`, `closed`, `false-positive`, `in-review` |
| `severity` | query | string | No       | Filter by severity: `critical`, `high`, `medium`, `low`           |
| `branch`   | query | string | No       | Filter by git branch                                              |

**Response:**

```json
[
  {
    "id": "11111111-...",
    "issueLabel": "VULN-000123",
    "title": "SQL Injection in login handler",
    "severity": "critical",
    "status": "open",
    "location": "src/auth/login.ts",
    "url": "https://console.pensar.dev/acme/VULN-000123",
    "closedAt": null,
    "closedDisposition": null,
    "duplicateOf": null
  }
]
```

`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`, `closedDisposition` and `duplicateOf` 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](#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.

| Parameter | In   | Type   | Required | Description                              |
| --------- | ---- | ------ | -------- | ---------------------------------------- |
| `issueId` | path | string | Yes      | Issue UUID or label (e.g. `VULN-000123`) |

`title` is the fix pull request's title when one exists; `name` is always the
finding's own name. `startLineNumber` and `endLineNumber` are the affected lines
in `location`, and are `null` when the finding has no line range.

A closed issue also carries the full close record: `closedAt`,
`closedDisposition`, `duplicateOf`, `closedMethod` (how it was closed —
`manual`, `retest`, `mcp`, `api`), `closedReason`, and `closedComments`. All six
are `null` while the issue is open, and `duplicateOf` is set only on a
`duplicate` close.

#### Update Issue

```
PATCH /issues/:issueId
```

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

| Parameter                        | In   | Type           | Required         | Description                                                                                            |
| -------------------------------- | ---- | -------------- | ---------------- | ------------------------------------------------------------------------------------------------------ |
| `issueId`                        | path | string         | Yes              | Issue UUID or label (e.g. `VULN-000123`)                                                               |
| `status`                         | body | string         | No               | New status: `open`, `closed`, `false-positive`, `in-review`                                            |
| `userFlaggedFalsePositive`       | body | boolean        | No               | Flag the issue as a false positive                                                                     |
| `userFlaggedFalsePositiveReason` | body | string         | No               | Reason for the false positive flag                                                                     |
| `closedDisposition`              | body | string         | No               | Structured close verdict: `resolved`, `duplicate`, `wont-fix`, `out-of-scope`, `risk-accepted`         |
| `duplicateOf`                    | body | string         | With `duplicate` | The issue this one duplicates, as a UUID or label (e.g. `VULN-000123`)                                 |
| `closedReason`                   | body | string         | No               | Free-text reason for closing                                                                           |
| `closedComments`                 | body | string         | No               | Additional comments on closure                                                                         |
| `endpointId`                     | body | string or null | No               | Endpoint UUID in this workspace. Omit to leave unchanged; `null` clears both endpoint and application. |

**Request Body:**

```json
{
  "status": "closed",
  "closedDisposition": "risk-accepted",
  "closedReason": "Compensating control in place",
  "closedComments": "Signed off by security"
}
```

**Response (200):** the updated issue, including `closedAt`, `closedReason`, `closedDisposition`, `duplicateOf`, `endpointId`, and `applicationId`.

Setting `endpointId` also updates `applicationId` to the endpoint's application.
A malformed UUID returns `400`; an endpoint outside this workspace or missing
returns `404`. Another finding with the same name and endpoint in the same
pentest returns `409`.

A status change, or a change to `duplicateOf`, is also applied to the finding's linked GitHub, Linear and Notion tickets, the same as a change made in the console.

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

To close a finding as a duplicate, send `closedDisposition: "duplicate"` with
`duplicateOf` naming the original, which must be another issue in the same
workspace:

```json
{
  "status": "closed",
  "closedDisposition": "duplicate",
  "duplicateOf": "VULN-000045",
  "closedReason": "Same missing guard on the invoice controller"
}
```

`duplicateOf` is required with `duplicate` and refused with any other
disposition, and the issue must end up `closed`. The original cannot itself be
closed as a duplicate, so point at the original it names instead. An original
outside the workspace is a `404`; the other cases are a `400`. Reopening the
issue, or changing its disposition, clears the link.

Closing an issue that has duplicates of its own as a duplicate moves those
duplicates onto the new original, so every link points at an issue that is
not itself a duplicate. Deleting an original reopens its duplicates, since
nothing would be left tracking the vulnerability.

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

```json
{
  "error": "closedDisposition must be one of: resolved, duplicate, wont-fix, out-of-scope, risk-accepted"
}
```

#### Retest Issue

```
POST /issues/:issueId/retest
```

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

| Parameter | In   | Type   | Required | Description                              |
| --------- | ---- | ------ | -------- | ---------------------------------------- |
| `issueId` | path | string | Yes      | Issue UUID or label (e.g. `VULN-000123`) |

**Response (201):**

```json
{
  "issueId": "11111111-...",
  "sessionId": "22222222-...",
  "status": "queued",
  "message": "Issue retest queued"
}
```

The retest runs asynchronously. Rather than polling for the verdict, subscribe to the `retest.completed` [webhook](/console/features/webhooks) — 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.

| Parameter | In   | Type   | Required | Description |
| --------- | ---- | ------ | -------- | ----------- |
| `issueId` | path | string | Yes      | Issue UUID  |

**Response:**

```json
[
  {
    "id": "a8e2c451-...",
    "status": "fixed",
    "stillExists": false,
    "confidence": "high",
    "evidence": "The union-select payload now returns 400 and the query is parameterized at src/db/query.ts:142.",
    "recommendation": "Close the finding.",
    "error": null,
    "queuedAt": "2026-08-27T08:58:02.000Z",
    "startedAt": "2026-08-27T08:58:41.000Z",
    "completedAt": "2026-08-27T09:06:18.000Z"
  }
]
```

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

| Parameter | In   | Type   | Required | Description                              |
| --------- | ---- | ------ | -------- | ---------------------------------------- |
| `issueId` | path | string | Yes      | Issue 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.

| Parameter | In   | Type   | Required | Description |
| --------- | ---- | ------ | -------- | ----------- |
| `fixId`   | path | string | Yes      | Fix UUID    |

---

### Agent Logs

#### List Agent Logs

```
GET /issues/:issueId/logs
```

Returns agent execution logs for a specific issue.

| Parameter | In    | Type    | Required | Description                                                               |
| --------- | ----- | ------- | -------- | ------------------------------------------------------------------------- |
| `issueId` | path  | string  | Yes      | Issue UUID or label (e.g. `VULN-000123`)                                  |
| `level`   | query | string  | No       | Filter by log level: `debug`, `info`, `warn`, `error`                     |
| `role`    | query | string  | No       | Filter by role: `assistant`, `user`, `system`, `tool-call`, `tool-result` |
| `limit`   | query | integer | No       | Maximum number of logs to return                                          |

#### Search Agent Logs

```
POST /issues/:issueId/logs/search
```

Search agent logs by text pattern with configurable context.

| Parameter      | In   | Type    | Required | Description                                    |
| -------------- | ---- | ------- | -------- | ---------------------------------------------- |
| `issueId`      | path | string  | Yes      | Issue UUID or label (e.g. `VULN-000123`)       |
| `query`        | body | string  | Yes      | Search text pattern                            |
| `level`        | body | string  | No       | Filter by log level                            |
| `role`         | body | string  | No       | Filter by role                                 |
| `contextLines` | body | integer | No       | Number of surrounding context lines to include |

**Request Body:**

```json
{
  "query": "SQL injection",
  "level": "info",
  "contextLines": 3
}
```

---

### Target Logs

A pentest's execution logs are persisted against the individual targets it
tested (see [List Pentest Targets](#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](#agent-logs) endpoints.

#### List Target Logs

```
GET /targets/:targetId/logs
```

Returns agent execution logs for a specific pentest target.

| Parameter  | In    | Type    | Required | Description                                                               |
| ---------- | ----- | ------- | -------- | ------------------------------------------------------------------------- |
| `targetId` | path  | string  | Yes      | Target UUID (from List Pentest Targets)                                   |
| `level`    | query | string  | No       | Filter by log level: `debug`, `info`, `warn`, `error`                     |
| `role`     | query | string  | No       | Filter by role: `assistant`, `user`, `system`, `tool-call`, `tool-result` |
| `limit`    | query | integer | No       | Maximum 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.

| Parameter      | In   | Type    | Required | Description                                    |
| -------------- | ---- | ------- | -------- | ---------------------------------------------- |
| `targetId`     | path | string  | Yes      | Target UUID                                    |
| `query`        | body | string  | Yes      | Search text pattern                            |
| `level`        | body | string  | No       | Filter by log level                            |
| `role`         | body | string  | No       | Filter by role                                 |
| `contextLines` | body | integer | No       | Number 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.

| Parameter | In    | Type    | Required | Description                         |
| --------- | ----- | ------- | -------- | ----------------------------------- |
| `limit`   | query | integer | No       | Maximum number of apps to return    |
| `offset`  | query | integer | No       | Number of apps to skip (pagination) |

#### Create App

```
POST /apps
```

Creates a new application in the workspace.

| Parameter           | In   | Type   | Required | Description                                |
| ------------------- | ---- | ------ | -------- | ------------------------------------------ |
| `name`              | body | string | Yes      | Application name                           |
| `description`       | body | string | Yes      | Description of the application             |
| `type`              | body | string | No       | Application type                           |
| `framework`         | body | string | No       | Framework the app is built with            |
| `domainId`          | body | string | No       | Associated domain ID                       |
| `disallowedActions` | body | string | No       | Actions the testing agent must not perform |

#### Get App

```
GET /apps/:appId
```

Returns full details of a specific application.

| Parameter | In   | Type   | Required | Description      |
| --------- | ---- | ------ | -------- | ---------------- |
| `appId`   | path | string | Yes      | Application UUID |

#### Update App

```
PATCH /apps/:appId
```

Updates an application. All body fields are optional.

| Parameter           | In   | Type   | Required | Description                                |
| ------------------- | ---- | ------ | -------- | ------------------------------------------ |
| `appId`             | path | string | Yes      | Application UUID                           |
| `name`              | body | string | No       | Application name                           |
| `description`       | body | string | No       | Description of the application             |
| `type`              | body | string | No       | Application type                           |
| `framework`         | body | string | No       | Framework the app is built with            |
| `domainId`          | body | string | No       | Associated domain ID                       |
| `disallowedActions` | body | string | No       | Actions the testing agent must not perform |

#### Delete App

```
DELETE /apps/:appId
```

Deletes an application.

| Parameter | In   | Type   | Required | Description      |
| --------- | ---- | ------ | -------- | ---------------- |
| `appId`   | path | string | Yes      | Application UUID |

#### List Endpoints

```
GET /apps/:appId/endpoints
```

Returns the endpoints discovered for a specific application.

| Parameter | In    | Type    | Required | Description                              |
| --------- | ----- | ------- | -------- | ---------------------------------------- |
| `appId`   | path  | string  | Yes      | Application UUID                         |
| `type`    | query | string  | No       | Filter by endpoint type                  |
| `limit`   | query | integer | No       | Maximum number of endpoints to return    |
| `offset`  | query | integer | No       | Number of endpoints to skip (pagination) |

#### Create Endpoint

```
POST /apps/:appId/endpoints
```

Adds an endpoint to an application.

| Parameter     | In   | Type   | Required | Description                     |
| ------------- | ---- | ------ | -------- | ------------------------------- |
| `appId`       | path | string | Yes      | Application UUID                |
| `endpoint`    | body | string | Yes      | The endpoint path or identifier |
| `description` | body | string | Yes      | Description of the endpoint     |
| `type`        | body | string | No       | Endpoint type                   |

#### Get Endpoint

```
GET /endpoints/:endpointId
```

Returns full details of a specific endpoint.

| Parameter    | In   | Type   | Required | Description   |
| ------------ | ---- | ------ | -------- | ------------- |
| `endpointId` | path | string | Yes      | Endpoint UUID |

#### Update Endpoint

```
PATCH /endpoints/:endpointId
```

Updates an endpoint. All body fields are optional.

| Parameter    | In   | Type   | Required | Description   |
| ------------ | ---- | ------ | -------- | ------------- |
| `endpointId` | path | string | Yes      | Endpoint UUID |

#### Delete Endpoint

```
DELETE /endpoints/:endpointId
```

Deletes an endpoint.

| Parameter    | In   | Type   | Required | Description   |
| ------------ | ---- | ------ | -------- | ------------- |
| `endpointId` | path | string | Yes      | Endpoint UUID |

#### Search Apps

```
GET /search/apps
```

Searches applications in the workspace.

| Parameter | In    | Type    | Required | Description                            |
| --------- | ----- | ------- | -------- | -------------------------------------- |
| `q`       | query | string  | Yes      | Search query                           |
| `type`    | query | string  | No       | Filter by application type             |
| `limit`   | query | integer | No       | Maximum number of results to return    |
| `offset`  | query | integer | No       | Number of results to skip (pagination) |

#### Search Endpoints

```
GET /search/endpoints
```

Searches endpoints across all applications in the workspace.

| Parameter       | In    | Type    | Required | Description                                  |
| --------------- | ----- | ------- | -------- | -------------------------------------------- |
| `q`             | query | string  | Yes      | Search query                                 |
| `applicationId` | query | string  | No       | Restrict the search to a single application  |
| `type`          | query | string  | No       | Filter by endpoint type                      |
| `authRequired`  | query | boolean | No       | Filter by whether authentication is required |
| `limit`         | query | integer | No       | Maximum number of results to return          |
| `offset`        | query | integer | No       | Number of results to skip (pagination)       |

---

### Knowledge Base

The workspace knowledge base is the context Pensar agents use during pentests: technology notes, testing history, and false positive patterns. A `false_positive_pattern` entry suppresses matching findings.

> **Info**
>
> Any API key with `knowledge:read` can list and read entries. Creating, updating, and archiving require a **named user** (a signed-in user credential): a workspace API key has no author to attribute the change to and receives `403`. Every entry records the user who created it in `createdBy`.

An entry has this shape:

```json
{
  "id": "33333333-3333-4333-8333-333333333333",
  "title": "Auth stack",
  "content": "Sessions are issued by the /auth service as signed cookies.",
  "category": "technology_context",
  "status": "active",
  "source": "user",
  "tags": ["auth"],
  "confidence": null,
  "createdBy": "44444444-4444-4444-8444-444444444444",
  "createdAt": "2026-10-01T12:00:00.000Z",
  "updatedAt": "2026-10-01T12:00:00.000Z"
}
```

`category` is one of `false_positive_pattern`, `technology_context`, `testing_history`, `user_note`, or `agent_learning`. `status` is `active`, `stale`, or `archived`; only active entries are given to agents.

#### List Knowledge Entries

```
GET /knowledge
```

Returns a JSON array of entries, newest first.

| Parameter  | In    | Type   | Required | Description                                                     |
| ---------- | ----- | ------ | -------- | --------------------------------------------------------------- |
| `category` | query | string | No       | Filter by category                                              |
| `status`   | query | string | No       | Filter by status                                                |
| `tags`     | query | string | No       | Comma-separated tags; matches entries carrying every listed tag |

#### Create Knowledge Entry

```
POST /knowledge
```

Requires a named user. Returns the created entry with `201`; `source` is always `"user"`.

| Parameter  | In   | Type      | Required | Description        |
| ---------- | ---- | --------- | -------- | ------------------ |
| `title`    | body | string    | Yes      | Entry title        |
| `content`  | body | string    | Yes      | Entry body         |
| `category` | body | string    | Yes      | Entry category     |
| `tags`     | body | string\[] | No       | Tags for filtering |

#### Get Knowledge Entry

```
GET /knowledge/:id
```

| Parameter | In   | Type   | Required | Description |
| --------- | ---- | ------ | -------- | ----------- |
| `id`      | path | string | Yes      | Entry UUID  |

#### Update Knowledge Entry

```
PATCH /knowledge/:id
```

Requires a named user. Send at least one body field; omitted fields are unchanged and `tags` replaces the existing list. Returns the updated entry.

| Parameter  | In   | Type      | Required | Description        |
| ---------- | ---- | --------- | -------- | ------------------ |
| `id`       | path | string    | Yes      | Entry UUID         |
| `title`    | body | string    | No       | Entry title        |
| `content`  | body | string    | No       | Entry body         |
| `category` | body | string    | No       | Entry category     |
| `status`   | body | string    | No       | Entry status       |
| `tags`     | body | string\[] | No       | Tags for filtering |

#### Archive Knowledge Entry

```
DELETE /knowledge/:id
```

Requires a named user. Archives the entry (sets `status` to `"archived"`) rather than deleting it, and returns the archived entry.

| Parameter | In   | Type   | Required | Description |
| --------- | ---- | ------ | -------- | ----------- |
| `id`      | path | string | Yes      | Entry UUID  |

An unknown category or status returns `400`; an entry outside the workspace returns `404`.

---

### 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](/console/features/webhooks) page; this section is the route reference.

> **Warning**
>
> 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:**

```json
{
  "endpoints": [
    {
      "id": "f2a71c98-...",
      "url": "https://hooks.acme.dev/pensar",
      "events": ["issue.created", "issue.updated", "retest.completed"],
      "enabled": true,
      "description": "Vulnerability harness",
      "health": {
        "lastAttemptAt": "2026-08-26T20:00:01.468Z",
        "lastStatus": "success",
        "lastResponseCode": 200,
        "consecutiveFailures": 0
      },
      "createdAt": "2026-08-26T19:14:02.117Z",
      "updatedAt": "2026-08-26T19:14:02.117Z"
    }
  ]
}
```

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](/console/features/webhooks#endpoint-requirements). Duplicate entries in `events` are collapsed.

| Parameter     | In   | Type      | Required | Description                                           |
| ------------- | ---- | --------- | -------- | ----------------------------------------------------- |
| `url`         | body | string    | Yes      | HTTPS URL to deliver to. Unique within the workspace. |
| `events`      | body | string\[] | Yes      | Events to subscribe to. Must be non-empty.            |
| `description` | body | string    | No       | Free-text label shown in the console                  |
| `enabled`     | body | boolean   | No       | Defaults to `true`                                    |

**Response (201):**

```json
{
  "endpoint": {
    "id": "f2a71c98-...",
    "url": "https://hooks.acme.dev/pensar",
    "events": ["issue.created", "issue.updated", "retest.completed"],
    "enabled": true,
    "description": "Vulnerability harness",
    "health": {
      "lastAttemptAt": null,
      "lastStatus": null,
      "lastResponseCode": null,
      "consecutiveFailures": 0
    },
    "createdAt": "2026-08-26T19:14:02.117Z",
    "updatedAt": "2026-08-26T19:14:02.117Z"
  },
  "secret": "whsec_2f1c9a7e..."
}
```

> **Warning**
>
> `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.

| Parameter    | In   | Type   | Required | Description           |
| ------------ | ---- | ------ | -------- | --------------------- |
| `endpointId` | path | string | Yes      | Webhook endpoint UUID |

**Response:**

```json
{
  "endpoint": { "id": "f2a71c98-...", "url": "https://hooks.acme.dev/pensar", "...": "..." },
  "recentDeliveries": [
    {
      "id": "d3f1aa08-...",
      "event": "issue.created",
      "status": "success",
      "attempt": 1,
      "responseCode": 200,
      "responseBody": "ok",
      "error": null,
      "durationMs": 184,
      "createdAt": "2026-08-26T20:00:01.284Z",
      "deliveredAt": "2026-08-26T20:00:01.468Z"
    }
  ]
}
```

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

| Parameter     | In   | Type           | Required | Description                                       |
| ------------- | ---- | -------------- | -------- | ------------------------------------------------- |
| `endpointId`  | path | string         | Yes      | Webhook endpoint UUID                             |
| `url`         | body | string         | No       | New HTTPS delivery URL                            |
| `events`      | body | string\[]      | No       | Replacement subscription list. Must be non-empty. |
| `enabled`     | body | boolean        | No       | Enable or disable delivery                        |
| `description` | body | string \| null | No       | Free-text label. `null` clears it.                |

**Response:**

```json
{
  "endpoint": { "id": "f2a71c98-...", "enabled": true, "...": "..." }
}
```

#### Delete Webhook Endpoint

```
DELETE /webhook-endpoints/:endpointId
```

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

| Parameter    | In   | Type   | Required | Description           |
| ------------ | ---- | ------ | -------- | --------------------- |
| `endpointId` | path | string | Yes      | Webhook endpoint UUID |

**Response:**

```json
{
  "deleted": true,
  "id": "f2a71c98-..."
}
```

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

| Parameter    | In   | Type   | Required | Description           |
| ------------ | ---- | ------ | -------- | --------------------- |
| `endpointId` | path | string | Yes      | Webhook endpoint UUID |

**Response (202):**

```json
{
  "deliveryId": "7c4e1b09-...",
  "endpointId": "f2a71c98-...",
  "event": "issue.created",
  "sample": false,
  "issueId": "1b7e9d40-..."
}
```

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

| Parameter        | In    | Type    | Required | Description                                                          |
| ---------------- | ----- | ------- | -------- | -------------------------------------------------------------------- |
| `endpointId`     | path  | string  | Yes      | Webhook endpoint UUID                                                |
| `limit`          | query | integer | No       | Maximum attempts to return. Defaults to 50, capped at 200.           |
| `includePayload` | query | boolean | No       | Include the rendered body sent on each attempt. Defaults to `false`. |

**Response:**

```json
{
  "deliveries": [
    {
      "id": "d3f1aa08-...",
      "event": "issue.created",
      "status": "success",
      "attempt": 1,
      "responseCode": 200,
      "responseBody": "ok",
      "error": null,
      "durationMs": 184,
      "createdAt": "2026-08-26T20:00:01.284Z",
      "deliveredAt": "2026-08-26T20:00:01.468Z"
    }
  ],
  "limit": 50
}
```

`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:**

```json
{
  "workspace": {
    "id": "workspace-uuid",
    "name": "My Workspace",
    "slug": "my-workspace"
  }
}
```

## Error Handling

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

```json
{
  "error": "Scan not found or not in this workspace"
}
```

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:

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

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

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

## Next Steps

#### [Webhooks](/console/features/webhooks)

Have findings pushed to your systems, signed, as they are created and source-mapped.

#### [MCP Server](/console/integrations/mcp-server)

Connect AI assistants to Pensar via the Model Context Protocol. (The REST API is a superset — it also exposes attack-surface endpoints.)

#### [CI/CD Integration](/console/features/ci-integration)

Automate pentesting in your CI/CD pipeline.

#### [Billing & Usage](/console/features/billing)

Understand credit consumption for API-triggered pentests.

#### [Getting Started](/console/getting-started)

Set up your workspace and connect your first repository.