Pwnkemon

Webhook callbacks

Instead of polling for scan completion, you can give Pwnkemon a URL to POST the final report to. Webhooks fire once per scan, on both success and failure.

Setting a webhook

Pass callback_url when creating the scan:

POST /api/scans
Authorization: Bearer pt_...
Content-Type: application/json

{
  "target": "example.com",
  "scan_type": "full",
  "tier": "standard",
  "callback_url": "https://your-app.com/webhooks/pwnkemon",
  "max_credits": 20
}

Delivery

When the scan completes (status: completed, failed, or cancelled), Pwnkemon makes a single HTTPS POST to your URL with the full report payload:

POST https://your-app.com/webhooks/pwnkemon
User-Agent: Pwnkemon/1.0
Content-Type: application/json
X-Pwnkemon-Webhook-Timestamp: 1748345600
X-Pwnkemon-Signature: t=1748345600,v1=5257a8…

{
  "scan_id": "8fda1c2e-...",
  "status": "completed",
  "target": "example.com",
  "tier": "standard",
  "credits_used": 5,
  "findings_count": 5,
  "summary": {
    "risk_rating": "medium",
    "summary": "...",
    "attack_chains": ["..."]
  }
}

Verifying the signature

Every delivery is signed with HMAC-SHA256 using a per-account secret you fetch from Dashboard → API tokens → Webhook signing secret. You must verify the signature before trusting the body. Without verification, anyone who learns or guesses your callback URL can forge "critical finding" alerts or suppress real ones.

The signed payload is:

signed_payload = "<timestamp>" + "." + <raw_request_body>

Compute HMAC_SHA256(secret, signed_payload) and constant-time compare to the v1= value from the X-Pwnkemon-Signature header. Also reject if the timestamp is more than ~5 minutes off wall-clock (replay defence).

Python (Flask)

import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = "whsec_..."  # from /dashboard/tokens
TOLERANCE_SECONDS = 300

@app.route("/webhooks/pwnkemon", methods=["POST"])
def hook():
    sig_header = request.headers.get("X-Pwnkemon-Signature", "")
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    ts = parts.get("t")
    sig = parts.get("v1")
    if not ts or not sig:
        abort(400)

    # Replay window
    if abs(time.time() - int(ts)) > TOLERANCE_SECONDS:
        abort(400)

    expected = hmac.new(
        SECRET.encode(),
        ts.encode() + b"." + request.get_data(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)

    # Body is now trusted.
    payload = request.get_json()
    return "", 204

Node (Express)

import express from "express";
import crypto from "crypto";

const app = express();
const SECRET = "whsec_..."; // from /dashboard/tokens
const TOLERANCE_SECONDS = 300;

// IMPORTANT: use a raw-body parser, NOT express.json(), or
// re-serialised JSON will not match the signed bytes.
app.post(
  "/webhooks/pwnkemon",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.headers["x-pwnkemon-signature"] || "";
    const parts = Object.fromEntries(
      header.split(",").map((p) => p.split("=", 2)),
    );
    if (!parts.t || !parts.v1) return res.sendStatus(400);

    if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_SECONDS) {
      return res.sendStatus(400);
    }

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${parts.t}.`)
      .update(req.body)
      .digest("hex");

    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(parts.v1),
    )) {
      return res.sendStatus(401);
    }

    const payload = JSON.parse(req.body.toString());
    res.sendStatus(204);
  },
);

Retries

If your endpoint returns a non-2xx status, Pwnkemon retries up to three times with exponential backoff (1s, 2s, 4s). After that the delivery is dropped — we don't queue indefinitely. Each retry is independently signed with a fresh timestamp.

You can always re-fetch the report via GET /api/scans/{scan_id} if a delivery fails.

Rotating the secret

If you suspect your secret has leaked, click Rotate in the dashboard. The old secret is invalidated immediately; webhooks delivered between rotation and your verifier config update will fail the signature check on your side. Plan accordingly — rotate during a maintenance window or update both ends atomically.

Failure payloads

If a scan fails (e.g. exceeds cost cap, agent error), the webhook still fires with a reduced payload (signed the same way):

{
  "scan_id": "8fda1c2e-...",
  "status": "failed",
  "error": "Credit budget exceeded"
}

Plan availability

Webhook callbacks are available on Starter and above. Free-tier scans ignore the callback_url field silently.