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
Register an endpoint
In the Pensar Console, go to Settings > Integrations > Webhooks and click Add Endpoint, or call the API:
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 for working
Node and Python implementations.
Event catalog
A few properties worth knowing before you design around them:
- One event per finding. A pentest that produces 200 findings sends 200 separate
issue.createddeliveries, 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 separateissue.status_changedevents. - Deliveries are not ordered. They are independently queued and retried. Key off
data.issue.id, and usedata.issue.updatedAtto 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.completedandissue.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
duplicatedisposition, not removed. If a finding stops appearing inGET /issueswithout 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.
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:
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.
The issue object
All three issue events carry the same object under data.issue.
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:
statusdistinguishes a false positive from a close.false-positiveis its own status, not a disposition. If you are bucketing findings as open versus not-open, bothclosedandfalse-positiveare terminal.closedDispositionisnullon a false positive, because the verdict is already in the status. It is alsonullon closes recorded before the field existed.
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.
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.
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:
A finding marked a false positive — its own terminal status, with no disposition:
And a reopen, which is the transition a close-only subscription would miss:
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.
A fixed verdict, with data.issue abbreviated — it carries
every field:
The same finding when the vulnerability still reproduces, with the low confidence that should route it to a human:
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.
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:
Verifying the signature
The X-Pensar-Signature header has two comma-separated parts:
The signed value is the timestamp, a literal ., and the raw request body:
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.
Node
Python
Wire it up so the handler sees the raw body:
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.
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-Deliveryis 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.
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.
Creating an endpoint:
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
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.
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
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.
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
The retest runs asynchronously against the finding’s original target.
Read the retest history
Returns every retest of the finding, most recent first. A finding that has never been
retested returns [], not a 404.
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:
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.
Next Steps
Full reference for the webhook-endpoint and retest routes, and everything else the API exposes.
Let Pensar open the fix pull request instead of dispatching your own agent.
Give an AI assistant read and write access to the same findings.
How whitebox and blackbox findings are produced, and why source mapping is a separate stage.