Webhooks

Overview

A webhook endpoint is an HTTPS URL in your infrastructure that Pensar POSTs 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.

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

Quickstart

1

Register an endpoint

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

$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"
> }'
2

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.

3

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 for working Node and Python implementations.

4

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

EventFires whenWhat it means for your harness
issue.createdA 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.updatedA 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, or new commit attribution.
issue.status_changedA 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.completedA 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.completedA 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.

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.

1{
2 "event": "issue.updated",
3 "data": {
4 "changed": ["location", "startLine", "endLine"],
5 "issue": {
6 "id": "6f4d2b80-1a93-4e57-bc28-3d905e7a1c64",
7 "location": "src/routes/invoices.ts",
8 "startLine": 88,
9 "endLine": 91
10 }
11 }
12}

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:

StageEventWhy
Judge / triageissue.createdSeverity, 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 fixerissue.updated, when changed includes locationOnly now are repository, location, startLine, and endLine guaranteed non-null. Dispatch against a finding you already judged.
Verifyretest.completedRead 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.

FieldTypeDescription
idstringDelivery UUID. Identical to the X-Pensar-Delivery header and stable across retries.
eventstringOne of the five names in the catalog.
createdAtstringISO-8601 UTC timestamp of when the delivery was rendered.
workspace.idstringWorkspace UUID.
workspace.slugstringWorkspace slug, as it appears in console URLs.
dataobjectEvent-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.

FieldTypeDescription
idstringIssue UUID. The stable key for the finding.
labelstring | nullHuman-facing identifier, e.g. VULN-4F2A9C.
titlestring | nullShort description of the vulnerability.
descriptionstring | nullFull write-up, including how it was reached.
severitystringcritical, high, medium, or low.
severityLevelnumberThe same value ranked numerically: critical = 4, high = 3, medium = 2, low = 1. Sort on this.
statusstringopen, closed, false-positive, or in-review. The same values GET /issues returns.
cwestring[]CWE identifiers, e.g. ["CWE-89"]. Empty array when unclassified, never null.
cvssobject | null{ score, vector }. vector may be null when only a score was assigned.
repositoryobject | null{ id, source, owner, name, url, defaultBranch }. See Nullable fields.
locationstring | nullRepository-relative file path. null until source mapping runs.
startLinenumber | nullFirst line of the vulnerable range.
endLinenumber | nullLast line of the vulnerable range.
branchstring | nullBranch the finding was observed on.
applicationobject | null{ id, name } — the application in your attack surface.
endpointobject | null{ id, url } — the specific endpoint, for findings scoped to one.
introducedByobject | null{ commit, author, email, confidence, at } when commit attribution ran.
pentestIdstring | nullThe pentest run that produced the finding.
urlstringAbsolute console deep link to the finding.
createdAtstringISO-8601 UTC timestamp of when the finding was recorded.
updatedAtstringISO-8601 UTC timestamp of the last change. Deliveries are unordered — use this to tell which of two events for one finding is newer.
closedAtstring | nullISO-8601 UTC timestamp of closure.
closedDispositionstring | nullresolved, 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.
closedMethodstring | nullHow the close reached us: manual, retest, github, linear, notion, api, mcp
closedReasonstring | nullFree text supplied at closure.
closedCommentsstring | nullLonger 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 five fields, and they are the same five, spelled the same way, that GET /issues returns — the webhook is a push view of the finding, not a second model of it.

Two 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.
1"status": "closed",
2"closedAt": "2026-08-27T09:12:43.000Z",
3"closedDisposition": "resolved",
4"closedMethod": "retest",
5"closedReason": "Issue verified as fixed during retest with high confidence.",
6"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.

1{
2 "id": "d3f1aa08-5c62-4e7b-b0a1-9f2c7e4d1b53",
3 "event": "issue.created",
4 "createdAt": "2026-08-26T20:00:01.284Z",
5 "workspace": {
6 "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
7 "slug": "acme"
8 },
9 "data": {
10 "issue": {
11 "id": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142",
12 "label": "VULN-4F2A9C",
13 "title": "SQL injection in invoice lookup",
14 "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.",
15 "severity": "high",
16 "severityLevel": 3,
17 "status": "open",
18 "cwe": [
19 "CWE-89"
20 ],
21 "cvss": {
22 "score": 8.6,
23 "vector": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N"
24 },
25 "repository": {
26 "id": "4a2c8e61-9b30-4d57-a1f8-6c3e0d2b5749",
27 "source": "Github",
28 "owner": "acme",
29 "name": "billing-svc",
30 "url": "https://github.com/acme/billing-svc",
31 "defaultBranch": "main"
32 },
33 "location": "src/db/query.ts",
34 "startLine": 142,
35 "endLine": 148,
36 "branch": "main",
37 "application": {
38 "id": "7d5b3f19-8e42-4c06-9b71-2a4f6d8c0e35",
39 "name": "Billing API"
40 },
41 "endpoint": null,
42 "introducedBy": {
43 "commit": "9f2ad41c8b7e05d3a6c1e9f4b28d70a5c3e6b192",
44 "author": "Dana Vance",
45 "email": "dana@acme.dev",
46 "confidence": "high",
47 "at": "2026-05-01T14:22:07.000Z"
48 },
49 "pentestId": "51c3a8d7-6e04-4192-b5fa-8d7c2e0b9461",
50 "url": "https://console.pensar.dev/acme/VULN-4F2A9C",
51 "createdAt": "2026-08-26T20:00:00.000Z",
52 "updatedAt": "2026-08-26T20:00:00.000Z",
53 "closedAt": null,
54 "closedDisposition": null,
55 "closedMethod": null,
56 "closedReason": null,
57 "closedComments": null
58 }
59 }
60}

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.

1{
2 "id": "0a6e4f27-b153-4c89-9d02-7e5a1b8c3f60",
3 "event": "issue.updated",
4 "createdAt": "2026-08-26T20:47:35.412Z",
5 "workspace": {
6 "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
7 "slug": "acme"
8 },
9 "data": {
10 "changed": ["location", "startLine", "endLine"],
11 "issue": {
12 "id": "6f4d2b80-1a93-4e57-bc28-3d905e7a1c64",
13 "label": "VULN-99BB01",
14 "status": "open",
15 "repository": {
16 "id": "4a2c8e61-9b30-4d57-a1f8-6c3e0d2b5749",
17 "source": "Github",
18 "owner": "acme",
19 "name": "billing-svc",
20 "url": "https://github.com/acme/billing-svc",
21 "defaultBranch": "main"
22 },
23 "location": "src/routes/invoices.ts",
24 "startLine": 88,
25 "endLine": 91,
26 "updatedAt": "2026-08-26T20:47:35.000Z",
27 "closedAt": null
28 }
29 }
30}

Abbreviated above. data.issue always carries every field, 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:

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

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

1"data": {
2 "previousStatus": "open",
3 "issue": {
4 "status": "false-positive",
5 "closedAt": "2026-08-27T11:03:02.000Z",
6 "closedDisposition": null,
7 "closedMethod": "user-flagged-false-positive",
8 "closedReason": "Not reachable from any entry point."
9 }
10}

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

1"data": {
2 "previousStatus": "closed",
3 "issue": {
4 "status": "open",
5 "closedAt": null,
6 "closedDisposition": null,
7 "closedMethod": null
8 }
9}

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.

FieldTypeDescription
retest.idstring | nullRetest UUID. null in the rare case where the verdict was recorded directly on the finding without a retest row.
retest.statusstringqueued, in-progress, fixed, still-vulnerable, or error. Derived from the timestamps, error, and result, so it can never disagree with them.
retest.confidencestring | nullhigh, medium, or low. This is the field to build an escalation rule on.
retest.stillExistsboolean | nullWhether the vulnerability reproduced. null when the retest errored.
retest.evidencestring | nullWhat the agent observed.
retest.recommendationstring | nullWhat the agent suggests doing next.
retest.errorstring | nullPopulated when status is error.
retest.startedAtstring | nullISO-8601 UTC timestamp.
retest.completedAtstring | nullISO-8601 UTC timestamp.

A fixed verdict, with data.issue abbreviated — it carries every field:

1{
2 "id": "e14d8b6a-2f05-4937-a8c3-0b7e5d1a9f26",
3 "event": "retest.completed",
4 "createdAt": "2026-08-27T09:06:18.733Z",
5 "workspace": {
6 "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
7 "slug": "acme"
8 },
9 "data": {
10 "retest": {
11 "id": "a8e2c451-0d76-4b39-9e15-7f3a2c8d604b",
12 "status": "fixed",
13 "confidence": "high",
14 "stillExists": false,
15 "evidence": "The union-select payload now returns 400 and the query is parameterized at src/db/query.ts:142.",
16 "recommendation": "Close the finding.",
17 "error": null,
18 "startedAt": "2026-08-27T08:58:41.000Z",
19 "completedAt": "2026-08-27T09:06:18.000Z"
20 },
21 "issue": {
22 "id": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142",
23 "label": "VULN-4F2A9C",
24 "severity": "high",
25 "status": "open",
26 "location": "src/db/query.ts",
27 "startLine": 142,
28 "closedAt": null
29 }
30 }
31}

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

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

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.

FieldTypeDescription
pentest.idstringPentest UUID. Matches issue.pentestId on the findings it produced.
pentest.labelstringHuman-facing identifier, e.g. PENTEST-0042.
pentest.triggerstringmanual, pull-request, or commit.
pentest.startedAtstring | nullISO-8601 UTC timestamp.
pentest.endedAtstring | nullISO-8601 UTC timestamp.
pentest.durationMsnumber | nullnull when either timestamp is missing.
pentest.urlstringAbsolute console deep link to the run.
pentest.findings.totalnumberTotal findings recorded by the run.
pentest.findings.bySeverityobjectCounts keyed by critical, high, medium, low.
1{
2 "id": "b60a3d97-4e28-4c51-bf06-9a2d7c3e8154",
3 "event": "pentest.completed",
4 "createdAt": "2026-08-26T21:04:53.118Z",
5 "workspace": {
6 "id": "9c1f5e42-3d7a-4b18-9a2e-1f0c8d6b4a30",
7 "slug": "acme"
8 },
9 "data": {
10 "pentest": {
11 "id": "51c3a8d7-6e04-4192-b5fa-8d7c2e0b9461",
12 "label": "PENTEST-0042",
13 "trigger": "manual",
14 "startedAt": "2026-08-26T19:31:05.000Z",
15 "endedAt": "2026-08-26T21:04:52.000Z",
16 "durationMs": 5627000,
17 "url": "https://console.pensar.dev/acme/pentests/PENTEST-0042",
18 "findings": {
19 "total": 14,
20 "bySeverity": {
21 "critical": 1,
22 "high": 3,
23 "medium": 6,
24 "low": 4
25 }
26 }
27 }
28 }
29}

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.

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:

HeaderExampleDescription
Content-Typeapplication/jsonAlways.
X-Pensar-Eventissue.createdThe event name, so you can route without parsing the body.
X-Pensar-Deliveryd3f1aa08-5c62-4e7b-b0a1-9f2c7e4d1b53Delivery UUID. Stable across retries — use it as your idempotency key.
X-Pensar-Signaturet=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.

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.

1const crypto = require('node:crypto');
2
3const TOLERANCE_SECONDS = 300;
4
5/**
6 * @param {string} secret Your endpoint's `whsec_…` signing secret
7 * @param {string} header The raw `X-Pensar-Signature` header value
8 * @param {Buffer|string} rawBody The exact bytes of the request body
9 */
10function verifyPensarSignature(secret, header, rawBody) {
11 if (typeof header !== 'string') return false;
12
13 const parts = new Map(
14 header
15 .split(',')
16 .map((part) => part.trim().split('='))
17 .filter((kv) => kv.length === 2)
18 .map(([k, v]) => [k.trim(), v.trim()])
19 );
20
21 const timestamp = Number(parts.get('t'));
22 const signature = parts.get('v1');
23 if (!Number.isInteger(timestamp) || !signature) return false;
24
25 // Reject replays: the timestamp is inside the signed value, so an attacker
26 // cannot refresh it without the secret.
27 const now = Math.floor(Date.now() / 1000);
28 if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
29
30 const expected = crypto
31 .createHmac('sha256', secret)
32 .update(`${timestamp}.`)
33 .update(rawBody)
34 .digest('hex');
35
36 // timingSafeEqual throws on a length mismatch, and a wrong-length signature
37 // reveals nothing worth hiding.
38 if (expected.length !== signature.length) return false;
39 return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
40}

Wire it up so the handler sees the raw body:

1const express = require('express');
2
3const app = express();
4
5app.post(
6 '/pensar',
7 express.raw({ type: 'application/json' }),
8 (req, res) => {
9 const ok = verifyPensarSignature(
10 process.env.PENSAR_WEBHOOK_SECRET,
11 req.get('X-Pensar-Signature'),
12 req.body // Buffer, exactly as received
13 );
14 if (!ok) return res.status(400).send('invalid signature');
15
16 // Acknowledge first; do the work off the request path.
17 enqueue({
18 deliveryId: req.get('X-Pensar-Delivery'),
19 event: req.get('X-Pensar-Event'),
20 payload: JSON.parse(req.body.toString('utf8')),
21 });
22 res.status(200).send('ok');
23 }
24);

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.

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.

RequirementDetail
HTTPS onlyhttp:// is rejected. There is no opt-out.
A hostname, not an IP literalhttps://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 addressesEvery 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 URLhttps://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 secondsAnything else is a failed attempt.
No redirectsA 3xx response is a failed attempt.
Unique per workspaceRegistering 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 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.

RouteDescription
GET /webhook-endpointsList the workspace’s endpoints. The signing secret is never returned.
POST /webhook-endpointsRegister an endpoint. Returns the signing secret once.
GET /webhook-endpoints/:idEndpoint detail plus its 20 most recent delivery attempts.
PATCH /webhook-endpoints/:idChange url, events, enabled, or description.
DELETE /webhook-endpoints/:idRemove the endpoint and its delivery history.
POST /webhook-endpoints/:id/testSend a test delivery to verify the wiring end to end.
GET /webhook-endpoints/:id/deliveriesDelivery attempts, newest first.

Creating an endpoint:

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

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.

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

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.

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.

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.

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

status is pending, success, or failed, 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.

Trigger a retest

POST /issues/:issueId/retest
$curl -X POST https://api.pensar.dev/issues/1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142/retest \
> -H "x-api-key: $PENSAR_API_KEY"
1{
2 "issueId": "1b7e9d40-2c85-4f13-8a6d-5e0c3b7a9142",
3 "sessionId": "c4e07b12-8a35-4f69-b0d2-1e6a9c3f5847",
4 "status": "queued",
5 "message": "Issue retest queued"
6}

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.

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

status is derived from the row’s timestamps, error, and result rather than stored, so it cannot drift: queued, in-progress, fixed, still-vulnerable, or error.

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:

1

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.

2

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.

3

POST /issues/:issueId/retest — verify

Once the fix merges, trigger a retest.

4

retest.completed — decide

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

5

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