Pentify
Core concepts

Webhooks

Subscribe an HTTPS endpoint to scan lifecycle events instead of polling. Each subscription is bound to a specific API key.

Endpoints

MethodPathScope
GET/v1/webhookswebhooks:write
POST/v1/webhookswebhooks: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

EventFires when
scan.queuedA new scan was accepted.
scan.runningEngine started executing the scan.
scan.completedScan succeeded, report ready.
scan.failedScan failed. data.reason carries the cause.
scan.cancelledScan was cancelled by the user.
target.verifiedTarget ownership proof succeeded.

Delivery

  • HTTP POST with application/json body and a pentify-signature header.
  • 5-second timeout. 2xx considered 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...
  • t is the unix timestamp (seconds) when Pentify signed the request.
  • v1 is HEX(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_errorurl is not HTTPS, or events[] is empty.
  • conflict — duplicate subscription (same URL + same event).

See Errors.