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

# Webhooks

> Push findings to your systems the moment Pensar finds, updates, retests, or closes them

## Overview

A webhook endpoint is an HTTPS URL in your infrastructure that Pensar `POST`s a signed
JSON body to whenever something happens to a finding in your workspace. It is the push
equivalent of polling `GET /issues`, and it is built for harnesses that triage findings
automatically rather than for people reading a dashboard.

Every delivery carries:

* **The whole finding**, not an id you have to go fetch — severity, CWE, CVSS,
  repository, file and line range, endpoint, commit attribution, and a console deep link.
* **An HMAC-SHA256 signature** over the exact bytes we sent, with the timestamp inside
  the signed value so a captured delivery cannot be replayed later.
* **A stable delivery id**, unchanged across retries, so you can use it as an
  idempotency key.

> **Warning**
>
> **A finding's file and line numbers are usually `null` at `issue.created`.** Blackbox
> findings are discovered against a URL, not a source file, and are mapped back to code
> minutes later — which arrives as an `issue.updated`. If anything you build needs a line
> number, read [Findings arrive incomplete](#findings-arrive-incomplete) first.

## Quickstart

#### Register an endpoint

In the Pensar Console, go to **Settings > Integrations > Webhooks** and click
**Add Endpoint**, or call the API:

```bash
curl -X POST https://api.pensar.dev/webhook-endpoints \
  -H "x-api-key: $PENSAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.acme.dev/pensar",
    "events": ["issue.created", "issue.updated", "retest.completed"],
    "description": "Vulnerability harness"
  }'
```

#### Store the signing secret

The response contains a `whsec_…` secret. It is shown **once** and is not recoverable
afterwards — put it in your secret manager before you close the response.

#### Verify the signature before you parse the body

Read the raw request bytes, check `X-Pensar-Signature` against them, and only then
parse the JSON. See [Verifying the signature](#verifying-the-signature) for working
Node and Python implementations.

#### Return 2xx within 10 seconds

Acknowledge first, work afterwards. Enqueue the delivery internally and return `200`
immediately — anything slower than 10 seconds is recorded as a failed delivery and
retried.

## Event catalog

| Event                  | Fires when                                                                                                                              | What it means for your harness                                                                                                                                                                  |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `issue.created`        | A pentest or scan writes a new finding. One delivery per finding, not one per batch.                                                    | The primary signal. Everything except the source location is present.                                                                                                                           |
| `issue.updated`        | A published field on a finding changed. `data.changed` names which ones.                                                                | Most often the file path and line range landing. Also a re-scored severity, an edited description, new commit attribution, or a duplicate moved to a new original (`changed: ["duplicateOf"]`). |
| `issue.status_changed` | A finding moved status: closed, reopened, marked a false positive, or moved into review. `data.previousStatus` says where it came from. | Open, close, or reopen your ticket.                                                                                                                                                             |
| `retest.completed`     | A retest finished, whether it confirmed the fix, found the finding still present, or errored.                                           | The automatic verification verdict, with the `confidence` your escalation rule reads.                                                                                                           |
| `pentest.completed`    | A pentest run finished.                                                                                                                 | A budgeted run is done; the severity histogram tells you what it produced.                                                                                                                      |

A few properties worth knowing before you design around them:

* **One event per finding.** A pentest that produces 200 findings sends 200 separate
  `issue.created` deliveries, each with its own delivery id, so a retry re-sends only the
  finding that failed. Closing 40 findings in one bulk action likewise delivers 40
  separate `issue.status_changed` events.
* **Deliveries are not ordered.** They are independently queued and retried. Key off
  `data.issue.id`, and use `data.issue.updatedAt` to decide which of two events for the
  same finding is newer.
* **A status change and a retest verdict are separate events.** A retest that confirms a
  fix sends `retest.completed` *and* `issue.status_changed`. Neither implies the other:
  a retest can finish without closing anything, and a human can close a finding no retest
  ever ran against.
* **Findings are not deleted.** A duplicate is closed with a `duplicate` disposition, not
  removed. If a finding stops appearing in `GET /issues` without a close, ask us — that is
  not a lifecycle you should have to model.

> **Info**
>
> Subscribing to an event you do not handle costs you nothing, but an endpoint must
> subscribe to at least one. You can change the subscription list at any time with
> `PATCH /webhook-endpoints/:id`.

## Findings arrive incomplete

Pensar finds vulnerabilities two ways. A **whitebox** finding is discovered by reading your
source, so it knows its file and line range from the moment it is created. A **blackbox**
finding is discovered by exercising a running endpoint — it is scoped to a URL, and when
the row is written there is no source file attached to it at all.

A separate agent works backwards from the blackbox finding to the code that produced it and
fills in `repository`, `location`, `startLine`, and `endLine`. That usually lands **minutes**
after creation, and for a finding against a bare domain with no repository behind it, it
never lands at all.

So the same finding produces two deliveries: `issue.created`, then an `issue.updated` whose
`changed` array names the fields that arrived.

```json
{
  "event": "issue.updated",
  "data": {
    "changed": ["location", "startLine", "endLine"],
    "issue": {
      "id": "6f4d2b80-1a93-4e57-bc28-3d905e7a1c64",
      "location": "src/routes/invoices.ts",
      "startLine": 88,
      "endLine": 91
    }
  }
}
```

> **Warning**
>
> A harness that dispatches code-fixing agents on `issue.created` alone will hand them a
> `null` file path for every blackbox finding.

### The pattern we recommend

Split the two jobs across the two events:

| Stage                | Event                                               | Why                                                                                                                                                                         |
| -------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Judge / triage**   | `issue.created`                                     | Severity, CWE, CVSS, description, endpoint, and reproduction context are all present immediately. Confirm or deny the finding here — none of that work needs a line number. |
| **Dispatch a fixer** | `issue.updated`, when `changed` includes `location` | Only now are `repository`, `location`, `startLine`, and `endLine` guaranteed non-`null`. Dispatch against a finding you already judged.                                     |
| **Verify**           | `retest.completed`                                  | Read `status` and `confidence`; escalate to a human when confidence is low.                                                                                                 |

Whitebox findings are source-mapped at creation, so they never emit that `issue.updated`.
If you subscribe to both events, treat them as a single readiness condition: *"I have
judged this finding, and it has a non-null `location`."* Whichever event satisfies the
second half is the one that triggers the dispatch.

## Event payloads

### Envelope

Every delivery has the same five top-level fields. Only `data` differs by event.

| Field            | Type   | Description                                                                                                                                                                                                                  |
| ---------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`             | string | Delivery UUID. Identical to the `X-Pensar-Delivery` header and stable across retries.                                                                                                                                        |
| `event`          | string | One of the five names in the [catalog](#event-catalog).                                                                                                                                                                      |
| `createdAt`      | string | ISO-8601 UTC timestamp of when the delivery was rendered.                                                                                                                                                                    |
| `workspace.id`   | string | Workspace UUID.                                                                                                                                                                                                              |
| `workspace.slug` | string | Workspace slug, as it appears in console URLs.                                                                                                                                                                               |
| `data`           | object | Event-specific body. `{ issue }` for `issue.created`, plus `changed` for `issue.updated` and `previousStatus` for `issue.status_changed`; `{ retest, issue }` for `retest.completed`; `{ pentest }` for `pentest.completed`. |

### The issue object

All three issue events carry the same object under `data.issue`.

| Field               | Type           | Description                                                                                                                                                                              |
| ------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                | string         | Issue UUID. The stable key for the finding.                                                                                                                                              |
| `label`             | string \| null | Human-facing identifier, e.g. `VULN-4F2A9C`.                                                                                                                                             |
| `title`             | string \| null | Short description of the vulnerability.                                                                                                                                                  |
| `description`       | string \| null | Full write-up, including how it was reached.                                                                                                                                             |
| `severity`          | string         | `critical`, `high`, `medium`, or `low`.                                                                                                                                                  |
| `severityLevel`     | number         | The same value ranked numerically: `critical` = 4, `high` = 3, `medium` = 2, `low` = 1. Sort on this.                                                                                    |
| `status`            | string         | `open`, `closed`, `false-positive`, or `in-review`. The same values `GET /issues` returns.                                                                                               |
| `cwe`               | string\[]      | CWE identifiers, e.g. `["CWE-89"]`. Empty array when unclassified, never `null`.                                                                                                         |
| `cvss`              | object \| null | `{ score, vector }`. `vector` may be `null` when only a score was assigned.                                                                                                              |
| `repository`        | object \| null | `{ id, source, owner, name, url, defaultBranch }`. See [Nullable fields](#nullable-fields).                                                                                              |
| `location`          | string \| null | Repository-relative file path. `null` until source mapping runs.                                                                                                                         |
| `startLine`         | number \| null | First line of the vulnerable range.                                                                                                                                                      |
| `endLine`           | number \| null | Last line of the vulnerable range.                                                                                                                                                       |
| `branch`            | string \| null | Branch the finding was observed on.                                                                                                                                                      |
| `application`       | object \| null | `{ id, name }` — the application in your attack surface.                                                                                                                                 |
| `endpoint`          | object \| null | `{ id, url }` — the specific endpoint, for findings scoped to one.                                                                                                                       |
| `introducedBy`      | object \| null | `{ commit, author, email, confidence, at }` when commit attribution ran.                                                                                                                 |
| `pentestId`         | string \| null | The pentest run that produced the finding.                                                                                                                                               |
| `url`               | string         | Absolute console deep link to the finding.                                                                                                                                               |
| `createdAt`         | string         | ISO-8601 UTC timestamp of when the finding was recorded.                                                                                                                                 |
| `updatedAt`         | string         | ISO-8601 UTC timestamp of the last change. Deliveries are unordered — use this to tell which of two events for one finding is newer.                                                     |
| `closedAt`          | string \| null | ISO-8601 UTC timestamp of closure.                                                                                                                                                       |
| `closedDisposition` | string \| null | `resolved`, `duplicate`, `wont-fix`, `out-of-scope`, or `risk-accepted`. `null` on a close carrying no recorded verdict, and on a false positive, which records its verdict in `status`. |
| `duplicateOf`       | string \| null | On a `duplicate` close, the `id` of the finding this one duplicates. `null` on every other finding.                                                                                      |
| `closedMethod`      | string \| null | How the close reached us: `manual`, `retest`, `github`, `linear`, `notion`, `api`, `mcp`…                                                                                                |
| `closedReason`      | string \| null | Free text supplied at closure.                                                                                                                                                           |
| `closedComments`    | string \| null | Longer note supplied at closure.                                                                                                                                                         |

`repository.source` is one of `Github`, `GithubEnterprise`, `Gitlab`, `Bitbucket`,
`AzureDevOps`, or `Zip`. `introducedBy.confidence` is `high`, `medium`, or `low`.

### How a close is described

A closed finding carries six fields, and they are the same six, spelled the same way,
that `GET /issues` returns — the webhook is a push view of the finding, not a second model
of it.

Three of them are worth reading carefully:

* **`status` distinguishes a false positive from a close.** `false-positive` is its own
  status, not a disposition. If you are bucketing findings as open versus not-open, both
  `closed` and `false-positive` are terminal.
* **`closedDisposition` is `null` on a false positive**, because the verdict is already in
  the status. It is also `null` on closes recorded before the field existed.
* **A `duplicate` close names its original in `duplicateOf`.** Track remediation on the
  original: closing a duplicate does not mean the vulnerability is fixed. When an
  original is itself closed as a duplicate, its duplicates move onto the new original
  and each arrives as an `issue.updated` with `changed: ["duplicateOf"]`. Duplicates
  recorded before the field existed carry `null` here.

```json
"status": "closed",
"closedAt": "2026-08-27T09:12:43.000Z",
"closedDisposition": "resolved",
"closedMethod": "retest",
"closedReason": "Issue verified as fixed during retest with high confidence.",
"closedComments": "Retest evidence: the union-select payload now returns 400."
```

### `issue.created`

Fires once per finding, immediately after the finding is written. This example is a
whitebox finding, so its source location is already present.

```json
{
  "id": "d3f1aa08-5c62-4e7b-b0a1-9f2c7e4d1b53",
  "event": "issue.created",
  "createdAt": "2026-08-26T20:00:01.284Z",
  "workspace": {
    "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
    "slug": "acme"
  },
  "data": {
    "issue": {
      "id": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142",
      "label": "VULN-4F2A9C",
      "title": "SQL injection in invoice lookup",
      "description": "The `invoiceId` query parameter is concatenated directly into a SQL string in `getInvoice()`, allowing an authenticated caller to read arbitrary rows from the billing database.",
      "severity": "high",
      "severityLevel": 3,
      "status": "open",
      "cwe": [
        "CWE-89"
      ],
      "cvss": {
        "score": 8.6,
        "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N"
      },
      "repository": {
        "id": "4a2c8e61-9b30-4d57-a1f8-6c3e0d2b5749",
        "source": "Github",
        "owner": "acme",
        "name": "billing-svc",
        "url": "https://github.com/acme/billing-svc",
        "defaultBranch": "main"
      },
      "location": "src/db/query.ts",
      "startLine": 142,
      "endLine": 148,
      "branch": "main",
      "application": {
        "id": "7d5b3f19-8e42-4c06-9b71-2a4f6d8c0e35",
        "name": "Billing API"
      },
      "endpoint": null,
      "introducedBy": {
        "commit": "9f2ad41c8b7e05d3a6c1e9f4b28d70a5c3e6b192",
        "author": "Dana Vance",
        "email": "dana@acme.dev",
        "confidence": "high",
        "at": "2026-05-01T14:22:07.000Z"
      },
      "pentestId": "51c3a8d7-6e04-4192-b5fa-8d7c2e0b9461",
      "url": "https://console.pensar.dev/acme/VULN-4F2A9C",
      "createdAt": "2026-08-26T20:00:00.000Z",
      "updatedAt": "2026-08-26T20:00:00.000Z",
      "closedAt": null,
      "closedDisposition": null,
      "duplicateOf": null,
      "closedMethod": null,
      "closedReason": null,
      "closedComments": null
    }
  }
}
```

### `issue.updated`

Fires when a published field on a finding changes. `data.changed` names which ones, in
payload-field terms; `data.issue` is the same object `issue.created` carries, already
updated. Only fields a subscriber can see are tracked, so internal bookkeeping does not
produce an event.

Tracked: `title`, `description`, `severity`, `cvss`, `location`, `startLine`, `endLine`,
`branch`, `introducedBy`, `application`, `endpoint`. Status changes have their own event
and never appear here.

```json
{
  "id": "0a6e4f27-b153-4c89-9d02-7e5a1b8c3f60",
  "event": "issue.updated",
  "createdAt": "2026-08-26T20:47:35.412Z",
  "workspace": {
    "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
    "slug": "acme"
  },
  "data": {
    "changed": ["location", "startLine", "endLine"],
    "issue": {
      "id": "6f4d2b80-1a93-4e57-bc28-3d905e7a1c64",
      "label": "VULN-99BB01",
      "status": "open",
      "repository": {
        "id": "4a2c8e61-9b30-4d57-a1f8-6c3e0d2b5749",
        "source": "Github",
        "owner": "acme",
        "name": "billing-svc",
        "url": "https://github.com/acme/billing-svc",
        "defaultBranch": "main"
      },
      "location": "src/routes/invoices.ts",
      "startLine": 88,
      "endLine": 91,
      "updatedAt": "2026-08-26T20:47:35.000Z",
      "closedAt": null
    }
  }
}
```

> **Info**
>
> Abbreviated above. `data.issue` always carries [every field](#the-issue-object), not
> just the changed ones.

### `issue.status_changed`

Fires on every status transition, including reopens. `data.previousStatus` is where the
finding came from, in the same three-value vocabulary as `issue.status`.

A close:

```json
{
  "id": "c72b9e05-8a41-4f36-bd19-3e6c0a4d7b28",
  "event": "issue.status_changed",
  "createdAt": "2026-08-27T09:12:44.061Z",
  "workspace": {
    "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
    "slug": "acme"
  },
  "data": {
    "previousStatus": "open",
    "issue": {
      "id": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142",
      "label": "VULN-4F2A9C",
      "status": "closed",
      "updatedAt": "2026-08-27T09:12:43.000Z",
      "closedAt": "2026-08-27T09:12:43.000Z",
      "closedDisposition": "resolved",
      "closedMethod": "retest",
      "closedReason": "Issue verified as fixed during retest with high confidence.",
      "closedComments": "Retest evidence: the union-select payload now returns 400."
    }
  }
}
```

A finding marked a false positive — its own terminal status, with no disposition:

```json
"data": {
  "previousStatus": "open",
  "issue": {
    "status": "false-positive",
    "closedAt": "2026-08-27T11:03:02.000Z",
    "closedDisposition": null,
    "closedMethod": "user-flagged-false-positive",
    "closedReason": "Not reachable from any entry point."
  }
}
```

A finding closed as a duplicate of another:

```json
"data": {
  "previousStatus": "open",
  "issue": {
    "status": "closed",
    "closedAt": "2026-08-27T11:20:15.000Z",
    "closedDisposition": "duplicate",
    "duplicateOf": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142",
    "closedMethod": "api",
    "closedReason": "Same union-select injection as VULN-4F2A9C."
  }
}
```

And a reopen, which is the transition a close-only subscription would miss:

```json
"data": {
  "previousStatus": "closed",
  "issue": {
    "status": "open",
    "closedAt": null,
    "closedDisposition": null,
    "closedMethod": null
  }
}
```

### `retest.completed`

Fires when a retest finishes. `data.retest` carries the verdict and `data.issue` carries
the finding it was run against, so you do not need a second call to act on it.

| Field                   | Type            | Description                                                                                                                                           |
| ----------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `retest.id`             | string \| null  | Retest UUID. `null` in the rare case where the verdict was recorded directly on the finding without a retest row.                                     |
| `retest.status`         | string          | `queued`, `in-progress`, `fixed`, `still-vulnerable`, or `error`. Derived from the timestamps, error, and result, so it can never disagree with them. |
| `retest.confidence`     | string \| null  | `high`, `medium`, or `low`. This is the field to build an escalation rule on.                                                                         |
| `retest.stillExists`    | boolean \| null | Whether the vulnerability reproduced. `null` when the retest errored.                                                                                 |
| `retest.evidence`       | string \| null  | What the agent observed.                                                                                                                              |
| `retest.recommendation` | string \| null  | What the agent suggests doing next.                                                                                                                   |
| `retest.error`          | string \| null  | Populated when `status` is `error`.                                                                                                                   |
| `retest.startedAt`      | string \| null  | ISO-8601 UTC timestamp.                                                                                                                               |
| `retest.completedAt`    | string \| null  | ISO-8601 UTC timestamp.                                                                                                                               |

A fixed verdict, with `data.issue` abbreviated — it carries
[every field](#the-issue-object):

```json
{
  "id": "e14d8b6a-2f05-4937-a8c3-0b7e5d1a9f26",
  "event": "retest.completed",
  "createdAt": "2026-08-27T09:06:18.733Z",
  "workspace": {
    "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
    "slug": "acme"
  },
  "data": {
    "retest": {
      "id": "a8e2c451-0d76-4b39-9e15-7f3a2c8d604b",
      "status": "fixed",
      "confidence": "high",
      "stillExists": false,
      "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,
      "startedAt": "2026-08-27T08:58:41.000Z",
      "completedAt": "2026-08-27T09:06:18.000Z"
    },
    "issue": {
      "id": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142",
      "label": "VULN-4F2A9C",
      "severity": "high",
      "status": "open",
      "location": "src/db/query.ts",
      "startLine": 142,
      "closedAt": null
    }
  }
}
```

The same finding when the vulnerability still reproduces, with the low confidence that
should route it to a human:

```json
"data": {
  "retest": {
    "id": "a8e2c451-0d76-4b39-9e15-7f3a2c8d604b",
    "status": "still-vulnerable",
    "confidence": "low",
    "stillExists": true,
    "evidence": "The injected clause still alters the result set.",
    "recommendation": "Escalate to a human reviewer.",
    "error": null,
    "startedAt": "2026-08-27T08:58:41.000Z",
    "completedAt": "2026-08-27T09:06:18.000Z"
  },
  "issue": { }
}
```

> **Info**
>
> A retest that confirms a fix closes the finding, which sends a separate
> `issue.status_changed` with `closedMethod: "retest"`. The two events are independent:
> neither waits for the other, and either can arrive first.

### `pentest.completed`

Fires when a pentest run finishes, with a severity histogram of everything it produced.

| Field                         | Type           | Description                                                          |
| ----------------------------- | -------------- | -------------------------------------------------------------------- |
| `pentest.id`                  | string         | Pentest UUID. Matches `issue.pentestId` on the findings it produced. |
| `pentest.label`               | string         | Human-facing identifier, e.g. `PENTEST-0042`.                        |
| `pentest.trigger`             | string         | `manual`, `pull-request`, or `commit`.                               |
| `pentest.startedAt`           | string \| null | ISO-8601 UTC timestamp.                                              |
| `pentest.endedAt`             | string \| null | ISO-8601 UTC timestamp.                                              |
| `pentest.durationMs`          | number \| null | `null` when either timestamp is missing.                             |
| `pentest.url`                 | string         | Absolute console deep link to the run.                               |
| `pentest.findings.total`      | number         | Total findings recorded by the run.                                  |
| `pentest.findings.bySeverity` | object         | Counts keyed by `critical`, `high`, `medium`, `low`.                 |

```json
{
  "id": "b60a3d97-4e28-4c51-bf06-9a2d7c3e8154",
  "event": "pentest.completed",
  "createdAt": "2026-08-26T21:04:53.118Z",
  "workspace": {
    "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
    "slug": "acme"
  },
  "data": {
    "pentest": {
      "id": "51c3a8d7-6e04-4192-b5fa-8d7c2e0b9461",
      "label": "PENTEST-0042",
      "trigger": "manual",
      "startedAt": "2026-08-26T19:31:05.000Z",
      "endedAt": "2026-08-26T21:04:52.000Z",
      "durationMs": 5627000,
      "url": "https://console.pensar.dev/acme/pentests/PENTEST-0042",
      "findings": {
        "total": 14,
        "bySeverity": {
          "critical": 1,
          "high": 3,
          "medium": 6,
          "low": 4
        }
      }
    }
  }
}
```

## Nullable fields

Fields we have no value for are explicitly `null` rather than omitted, so you never have to
handle both `null` and `undefined` for the same field.

### `repository`

`repository` is `null` whenever a finding has no repository behind it. A blackbox finding
against a bare domain genuinely has none — nothing in your workspace connects that URL to
source control — and we would rather hand you a `null` than a fabricated repository your
fixer would then clone.

It is also `null` on a finding whose application is not yet linked to a repository. If that
is unexpected, connect the repository to the application in the console and the field
populates on subsequent events.

The value is resolved through the same walk the issue-tracker sync uses, so the repository
on a webhook payload and the repository on the Linear or Jira ticket for that finding can
never disagree.

### `location`, `startLine`, `endLine`

`null` until source mapping runs, which arrives as an `issue.updated`. See
[Findings arrive incomplete](#findings-arrive-incomplete).

### Everything else

`cvss`, `application`, `endpoint`, `introducedBy`, `branch`, `label`, `title`,
`description`, `pentestId`, and every `closed*` field are all nullable. `cwe` is the one
exception that degrades to an empty array rather than `null`.

## Delivery mechanics

### Request format

Every delivery is a `POST` with a JSON body and these headers:

| Header               | Example                                | Description                                                                |
| -------------------- | -------------------------------------- | -------------------------------------------------------------------------- |
| `Content-Type`       | `application/json`                     | Always.                                                                    |
| `X-Pensar-Event`     | `issue.created`                        | The event name, so you can route without parsing the body.                 |
| `X-Pensar-Delivery`  | `d3f1aa08-5c62-4e7b-b0a1-9f2c7e4d1b53` | Delivery UUID. **Stable across retries** — use it as your idempotency key. |
| `X-Pensar-Signature` | `t=1787774401,v1=017e1cc6…`            | Timestamp and HMAC-SHA256 signature. See below.                            |

### Verifying the signature

The `X-Pensar-Signature` header has two comma-separated parts:

```
X-Pensar-Signature: t=<unix seconds>,v1=<hex hmac-sha256>
```

The signed value is the timestamp, a literal `.`, and the raw request body:

```
HMAC-SHA256(secret, `${t}.${rawBody}`)
```

The timestamp is *inside* what was signed, so an attacker who captures a delivery cannot
present it later with a fresh timestamp. That only helps if you check the timestamp too —
both implementations below reject anything more than 300 seconds from your clock, which is
the tolerance Pensar itself uses.

> **Warning**
>
> **Verify the raw bytes, before you parse.** The signature covers exactly what we sent. If
> your framework parses the body into an object and you re-serialize it to verify, key
> order, whitespace, and Unicode escaping will differ and every signature will fail. In
> Express, mount `express.raw({ type: 'application/json' })` on the webhook route. In Flask,
> use `request.get_data()`, not `request.json`. Parse only after the check passes.

#### Node

```javascript
const crypto = require('node:crypto');

const TOLERANCE_SECONDS = 300;

/**
 * @param {string} secret   Your endpoint's `whsec_…` signing secret
 * @param {string} header   The raw `X-Pensar-Signature` header value
 * @param {Buffer|string} rawBody  The exact bytes of the request body
 */
function verifyPensarSignature(secret, header, rawBody) {
  if (typeof header !== 'string') return false;

  const parts = new Map(
    header
      .split(',')
      .map((part) => part.trim().split('='))
      .filter((kv) => kv.length === 2)
      .map(([k, v]) => [k.trim(), v.trim()])
  );

  const timestamp = Number(parts.get('t'));
  const signature = parts.get('v1');
  if (!Number.isInteger(timestamp) || !signature) return false;

  // Reject replays: the timestamp is inside the signed value, so an attacker
  // cannot refresh it without the secret.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');

  // timingSafeEqual throws on a length mismatch, and a wrong-length signature
  // reveals nothing worth hiding.
  if (expected.length !== signature.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

Wire it up so the handler sees the raw body:

```javascript
const express = require('express');

const app = express();

app.post(
  '/pensar',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyPensarSignature(
      process.env.PENSAR_WEBHOOK_SECRET,
      req.get('X-Pensar-Signature'),
      req.body // Buffer, exactly as received
    );
    if (!ok) return res.status(400).send('invalid signature');

    // Acknowledge first; do the work off the request path.
    enqueue({
      deliveryId: req.get('X-Pensar-Delivery'),
      event: req.get('X-Pensar-Event'),
      payload: JSON.parse(req.body.toString('utf8')),
    });
    res.status(200).send('ok');
  }
);
```

#### Python

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_pensar_signature(secret: str, header: str, raw_body: bytes) -> bool:
    """secret: your endpoint's `whsec_…` secret.
    header:   the raw `X-Pensar-Signature` header value.
    raw_body: the exact bytes of the request body."""
    parts = {}
    for item in header.split(","):
        key, _, value = item.strip().partition("=")
        if value:
            parts[key.strip()] = value.strip()

    try:
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False

    # Reject replays: the timestamp is inside the signed value, so an attacker
    # cannot refresh it without the secret.
    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)
```

Wire it up so the handler sees the raw body:

```python
import json
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/pensar")
def pensar_webhook():
    raw_body = request.get_data()  # bytes, exactly as received
    if not verify_pensar_signature(
        os.environ["PENSAR_WEBHOOK_SECRET"],
        request.headers.get("X-Pensar-Signature", ""),
        raw_body,
    ):
        return "invalid signature", 400

    # Acknowledge first; do the work off the request path.
    enqueue(
        delivery_id=request.headers["X-Pensar-Delivery"],
        event=request.headers["X-Pensar-Event"],
        payload=json.loads(raw_body),
    )
    return "ok", 200
```

#### Test vector

Check your implementation against this before you point a real endpoint at it. With the
secret, timestamp, and body below, the signature is exactly the value shown.

```
secret:  whsec_2f1c9a7e4b03d85610fe2c7a9b4d3e08f15c62a7d09b3e4c8a1f7602d5b9c3e4
body:    {"id":"d3f1aa08-5c62-4e7b-b0a1-9f2c7e4d1b53","event":"issue.created"}
header:  t=1787774401,v1=b9ae142977994b2c18ebd47cbbc93f3baa36c58d3266615c340da6f0926b059b
```

The timestamp in that header is in the past, so a correct implementation will reject it on
the tolerance check. To exercise the HMAC alone, compare the `v1=` value against your own
computation of `HMAC-SHA256(secret, "1787774401." + body)` directly.

### Retries and idempotency

An attempt fails if your endpoint returns a non-2xx status, does not respond within 10
seconds, or cannot be reached. A failed attempt is returned to the delivery queue and
retried after the queue's 5-minute visibility timeout.

* **5 attempts** in total, roughly five minutes apart.
* After the fifth failure the delivery is parked in a dead-letter queue and is not retried
  again. It stays visible in the endpoint's delivery log with the last response code and
  error.
* **`X-Pensar-Delivery` is the same on every attempt.** It is minted once when the delivery
  is queued, and it is the primary key of the delivery record. Record it and skip anything
  you have already processed — a 200 that your side timed out on before writing it down is
  exactly what retries are for.

Every attempt is recorded with its status, response code, the first 2 KB of your response
body, and the duration. Read them from `GET /webhook-endpoints/:id/deliveries` or from the
endpoint's page in the console.

> **Info**
>
> Redirects are not followed. A `301` or `302` is a failed delivery, not a hop — register
> the final URL.

### Endpoint requirements

Your URL must satisfy all of the following. It is checked when you register or update the
endpoint, and again immediately before every single delivery — a hostname that resolved
publicly at registration can be re-pointed afterwards, and only the delivery-time check
catches that.

| Requirement                           | Detail                                                                                                                                                     |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **HTTPS only**                        | `http://` is rejected. There is no opt-out.                                                                                                                |
| **A hostname, not an IP literal**     | `https://203.0.113.10/hook` is rejected. A literal address skips DNS, which skips the re-resolution that makes the delivery-time check meaningful.         |
| **Resolves only to public addresses** | Every address the hostname resolves to must be publicly routable. If a record set mixes one public address with one private address, the URL is rejected.  |
| **No credentials in the URL**         | `https://user:pass@host/hook` is rejected — they would be signed into our request and logged with it. Authenticate with a header or a path secret instead. |
| **2xx within 10 seconds**             | Anything else is a failed attempt.                                                                                                                         |
| **No redirects**                      | A 3xx response is a failed attempt.                                                                                                                        |
| **Unique per workspace**              | Registering a URL a second time in the same workspace is rejected. Subscribe one endpoint to several events instead.                                       |

Rejected address ranges include loopback (`127.0.0.0/8`, `::1`), private space
(`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`), link-local — which is where
cloud instance metadata lives (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`),
multicast, and reserved blocks. IPv4 addresses spelled as IPv6 — IPv4-mapped, NAT64
(`64:ff9b::/96`), and 6to4 (`2002::/16`) — are decoded and checked the same way, and
`localhost` is refused without a lookup.

If you need to receive deliveries somewhere not publicly reachable, terminate them at a
public gateway you control and forward inward from there.

### Automatic disabling

Each failed delivery increments the endpoint's consecutive-failure count; each success
resets it to zero.

**At 20 consecutive failures the endpoint is disabled** and an in-platform notification is
raised in your workspace. A permanently dead endpoint should stop consuming delivery
capacity, and someone should be told rather than finding out weeks later.

Re-enable it with `PATCH /webhook-endpoints/:id` and `{"enabled": true}` — that resets the
failure count, so the next single failure does not trip the threshold again. Events that
occurred while the endpoint was disabled are not replayed; backfill from `GET /issues` if
you need them.

## Managing endpoints with the API

All routes are under the [REST API](/console/features/rest-api) base URL,
`https://api.pensar.dev`, and authenticate with the same API key. The path is
`/webhook-endpoints` — `/webhooks` is reserved for inbound provider receivers and will
404\.

| Route                                   | Description                                                           |
| --------------------------------------- | --------------------------------------------------------------------- |
| `GET /webhook-endpoints`                | List the workspace's endpoints. The signing secret is never returned. |
| `POST /webhook-endpoints`               | Register an endpoint. Returns the signing secret **once**.            |
| `GET /webhook-endpoints/:id`            | Endpoint detail plus its 20 most recent delivery attempts.            |
| `PATCH /webhook-endpoints/:id`          | Change `url`, `events`, `enabled`, or `description`.                  |
| `DELETE /webhook-endpoints/:id`         | Remove the endpoint and its delivery history.                         |
| `POST /webhook-endpoints/:id/test`      | Send a test delivery to verify the wiring end to end.                 |
| `GET /webhook-endpoints/:id/deliveries` | Delivery attempts, newest first.                                      |

Creating an endpoint:

```bash
curl -X POST https://api.pensar.dev/webhook-endpoints \
  -H "x-api-key: $PENSAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.acme.dev/pensar",
    "events": ["issue.created", "issue.updated", "issue.status_changed", "retest.completed"],
    "description": "Vulnerability harness"
  }'
```

```json
{
  "endpoint": {
    "id": "f2a71c98-4b06-4d35-9e18-7c0d3a5b6e24",
    "url": "https://hooks.acme.dev/pensar",
    "events": ["issue.created", "issue.updated", "issue.status_changed", "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_2f1c9a7e4b03d85610fe2c7a9b4d3e08f15c62a7d09b3e4c8a1f7602d5b9c3e4"
}
```

> **Warning**
>
> `secret` appears in this response and nowhere else. No list or detail route returns it,
> and it cannot be read back. If you lose it, rotate by deleting and re-registering the
> endpoint.

The `health` block is updated on every delivery attempt, so a single `GET /webhook-endpoints`
tells you whether your receiver is healthy without reading the delivery log. Duplicate event
names in `events` are collapsed, so an endpoint can never double-deliver the same event.

### Testing the wiring

```
POST /webhook-endpoints/:id/test
```

Queues a real delivery — real signing, real retries, a real row in the delivery log — so
you can confirm your receiver works before a finding depends on it. It renders your most
recent finding as an `issue.created` event; if the workspace has no findings yet, it sends
a clearly-marked synthetic one instead, with `VULN-SAMPLE` as the label and the nil UUID
(`00000000-0000-0000-0000-000000000000`) for every id.

```json
{
  "deliveryId": "7c4e1b09-3a26-4d58-9f10-8b5c2e7a3d64",
  "endpointId": "f2a71c98-4b06-4d35-9e18-7c0d3a5b6e24",
  "event": "issue.created",
  "sample": false,
  "issueId": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142"
}
```

`sample` tells you which of the two you got. The response is a `202` — the delivery is
queued, not yet sent; read the delivery log for the outcome. The event is always
`issue.created`, whatever the endpoint subscribes to, and testing a disabled endpoint is a
`409`.

### Reading the delivery log

```
GET /webhook-endpoints/:id/deliveries?limit=50&includePayload=false
```

One record per attempt, newest first. `limit` defaults to 50 and is capped at 200. Pass
`includePayload=true` to get the rendered body back alongside each attempt — useful when
you are reconstructing what you were actually sent.

> **Note**
>
> Delivery attempts are retained for **30 days**, then dropped. This log answers "why did my
> endpoint stop working", which is a question about the recent past — it is not an archive of
> everything we have ever sent you. If you need a durable record of findings, persist the
> events on your side as they arrive.

> **Warning**
>
> The returned `payload` is the delivered event, but it is **not byte-identical** to the body
> we signed. It is stored as JSON, which normalizes key order, so re-serializing it and
> recomputing the HMAC will not reproduce the `X-Pensar-Signature` from that attempt. Use this
> field to inspect *what* was sent, not to re-verify a signature — verification only works
> against the raw body as received, at the moment you receive it.

```json
{
  "deliveries": [
    {
      "id": "d3f1aa08-5c62-4e7b-b0a1-9f2c7e4d1b53",
      "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`, and `id` is the value that travelled as
`X-Pensar-Delivery`. `responseBody` is truncated to 2 KB — enough to debug, not enough to
archive.

## Retesting a finding

Retests are the other half of an automated loop: your fixer opens a pull request, and a
retest tells you whether the vulnerability is actually gone. Both routes live on the
[REST API](/console/features/rest-api).

### Trigger a retest

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

```bash
curl -X POST https://api.pensar.dev/issues/1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142/retest \
  -H "x-api-key: $PENSAR_API_KEY"
```

```json
{
  "issueId": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142",
  "sessionId": "c4e07b12-8a35-4f69-b0d2-1e6a9c3f5847",
  "status": "queued",
  "message": "Issue retest queued"
}
```

The retest runs asynchronously against the finding's original target.

### Read the retest history

```
GET /issues/:issueId/retests
```

Returns every retest of the finding, most recent first. A finding that has never been
retested returns `[]`, not a 404.

```json
[
  {
    "id": "a8e2c451-0d76-4b39-9e15-7f3a2c8d604b",
    "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 row's timestamps, error, and result rather than stored, so it
cannot drift: `queued`, `in-progress`, `fixed`, `still-vulnerable`, or `error`.

> **Info**
>
> **Trigger, then wait for the webhook — do not poll.** A retest takes minutes. Subscribe to
> `retest.completed` and let the verdict come to you; it carries the same verdict fields as
> this route plus the whole finding, so you can act without a second call. Keep
> `GET /issues/:issueId/retests` for reconciling after a restart or for checking whether a
> retest is still in flight.

## Putting it together

A harness that consumes all of this looks roughly like:

#### issue.created — judge

Verify the signature, dedupe on `X-Pensar-Delivery`, and hand the finding to your
triage step. Everything it needs — severity, CWE, CVSS, description, endpoint —
is present. Persist the verdict keyed by `data.issue.id`.

#### issue.updated — dispatch

When `changed` includes `location`, look up the verdict you stored. If the finding was
confirmed and `repository` is non-`null`, dispatch your fixer against `repository`,
`location`, `startLine`, and `endLine`. Whitebox findings already satisfy this at
`issue.created`.

#### POST /issues/:issueId/retest — verify

Once the fix merges, trigger a retest.

#### retest.completed — decide

`status: "fixed"` with `confidence: "high"` closes the loop. `still-vulnerable`, or any
verdict with low confidence, routes to a human.

#### issue.status_changed — clean up

Close your ticket and stop any work still in flight. Read `status` and
`closedDisposition` to record *why*: a `false-positive` and a `resolved` close usually
mean different things on your side. And handle the reverse — a `previousStatus` of
`closed` means the finding is back.

## Next Steps

#### [REST API](/console/features/rest-api)

Full reference for the webhook-endpoint and retest routes, and everything else the API exposes.

#### [Auto Remediation](/console/features/auto-remediation)

Let Pensar open the fix pull request instead of dispatching your own agent.

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

Give an AI assistant read and write access to the same findings.

#### [Hybrid Testing](/console/features/hybrid-testing)

How whitebox and blackbox findings are produced, and why source mapping is a separate stage.