Core concepts
Webhooks
Subscribe an HTTPS endpoint to scan lifecycle events instead of polling. Each subscription is bound to a specific API key.
Endpoints
| Method | Path | Scope |
|---|---|---|
GET | /v1/webhooks | webhooks:write |
POST | /v1/webhooks | webhooks:write |
GET | /v1/webhooks/{id} | webhooks:write |
DELETE | /v1/webhooks/{id} | webhooks:write |
Create a subscription
curl https://api.pentify.io/v1/webhooks \
-H "Authorization: Bearer $PENTIFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pentify-hook",
"events": ["scan.completed", "scan.failed"]
}'{
"id": "whk_01HFY3...",
"url": "https://example.com/pentify-hook",
"events": ["scan.completed", "scan.failed"],
"secret": "whsec_g7Xq...",
"created_at": "2026-04-29T14:11:09Z"
}Save the secret
The
whsec_* value is shown exactly once. Store it — you will need it to verify signatures on every delivery.Event shape
{
"id": "evt_01HFY3...",
"type": "scan.completed",
"created_at": "2026-04-29T18:00:00Z",
"data": {
"scan_id": "scn_01HFY3...",
"target": "example.com",
"status": "succeeded",
"findings_count": 12,
"report_url": "https://api.pentify.io/v1/scans/scn_01HFY3.../report"
}
}Event types
| Event | Fires when |
|---|---|
scan.queued | A new scan was accepted. |
scan.running | Engine started executing the scan. |
scan.completed | Scan succeeded, report ready. |
scan.failed | Scan failed. data.reason carries the cause. |
scan.cancelled | Scan was cancelled by the user. |
target.verified | Target ownership proof succeeded. |
Delivery
- HTTP
POSTwithapplication/jsonbody and apentify-signatureheader. - 5-second timeout.
2xxconsidered successful. - Retried with exponential backoff for non-2xx — 1m, 5m, 30m, 2h, 12h, then the event lands in the dead-letter queue.
- Every delivery costs 1 token.
Verifying signatures
Every delivery carries a pentify-signature header. Verify it before trusting the payload.
pentify-signature: t=1714410069,v1=5cf3a7e1d2b4...tis the unix timestamp (seconds) when Pentify signed the request.v1isHEX(HMAC_SHA256(secret, "{t}.{rawBody}")).
Rules: reject anything older than 5 minutes (replay window), compute HMAC over the raw body (not a re-serialised version), and compare in constant time.
import crypto from "node:crypto";
export function verifyPentifySignature({
header,
rawBody,
secret,
toleranceSec = 300,
}) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("=")),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) {
throw new Error("expired_signature");
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
if (
expected.length !== parts.v1.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
) {
throw new Error("invalid_signature");
}
}Note
The official SDKs ship
webhooks.verify(...) helpers that wrap the above. See TypeScript, Python, Go.Common errors
validation_error—urlis not HTTPS, orevents[]is empty.conflict— duplicate subscription (same URL + same event).
See Errors.