Webhooks

A scan webhook is the call AccessWiser makes to a URL you choose when that scan finishes. This page covers what a delivery looks like, how to verify its signature and how retries work.

Ask for a webhook

Pass a webhookUrl when you create a scan and we will call it when the scan reaches a terminal state, so your job does not have to poll:

curl -X POST https://app.accesswiser.com/api/v1/scans \
  -H "Authorization: Bearer awsk_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-site.example.com/pricing",
    "scanClass": "evidence",
    "webhookUrl": "https://ci.example.com/hooks/accesswiser"
  }'

The URL must be https and must resolve to a public address. We validate it while handling your request, so a callback we cannot deliver to fails the create with a 400 rather than leaving you waiting for a call that will never come. Redirects are not followed.

What a delivery looks like

POST /hooks/accesswiser HTTP/1.1
Content-Type: application/json
User-Agent: AccessWiser-Webhook/1
X-AccessWiser-Event: scan.completed
X-AccessWiser-Delivery: 0f2a9c31-7b4e-4f6a-8d21-9c0b5e7a1234
X-AccessWiser-Timestamp: 1785495269
X-AccessWiser-Signature: t=1785495269,v1=9c1f...
{
  "event": "scan.completed",
  "customerId": "b6f0d0a2-1d4e-4b8f-9a10-2c3d4e5f6a7b",
  "scanId": "6f1c0f6e-6c2c-4a3b-9f5a-2a1d3b4c5d6e",
  "status": "completed",
  "url": "https://your-site.example.com/pricing",
  "scanClass": "evidence",
  "healthScore": 82,
  "summary": {
    "totalIssues": 14,
    "critical": 1,
    "serious": 4,
    "moderate": 6,
    "minor": 3,
    "incomplete": 2
  },
  "error": null,
  "completedAt": "2026-07-31T09:14:29.884Z",
  "disclaimer": "Automated testing detects only a portion of ..."
}

The event is scan.completed or scan.failed. X-AccessWiser-Delivery is stable across retries of the same delivery, so use it to deduplicate.

The payload carries the counts, not the findings — a webhook body has to stay small enough to deliver and retry reliably. Take the delivery as the signal that the scan is done, then read the findings from GET /scans/{id}.

Verifying the signature

Signing uses HMAC-SHA256 with a secret that is unique to your account (available alongside your API keys in your account) — never a secret shared between customers, so nobody else can mint a delivery your receiver would accept. The steps, in order:

  1. Read the raw body. Do not parse and re-serialise the JSON first — key order and whitespace are part of what was signed.
  2. Split X-AccessWiser-Signature on commas into its k=v pairs and take t and v1. Ignore versions you do not know.
  3. Reject if t is more than 300 seconds from your own clock. Compare against t from the signature, not the timestamp header — they are equal, but only t is inside the signed string.
  4. Build the signed string as exactly `${t}` + "." + rawBody — an ASCII full stop, no trailing newline.
  5. Compute HMAC-SHA256 over it with your signing secret, lower-case hex, and compare to v1 with a constant-time comparison.
  6. Only then parse the JSON.
  7. Check that customerId in the body is your account id. The signature proves the delivery came from us; this proves it was meant for you.
const crypto = require("crypto");

// Verify an AccessWiser scan webhook.
//   rawBody         the request body as received, NOT re-serialised JSON
//   signatureHeader the X-AccessWiser-Signature value, e.g. "t=...,v1=..."
//   secret          your account's webhook signing secret
function verify(rawBody, signatureHeader, secret, toleranceSec = 300) {
  const parts = {};
  for (const pair of signatureHeader.split(",")) {
    const i = pair.indexOf("=");
    if (i === -1) continue;
    parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
  }

  // 1. The timestamp is inside the signature, so it cannot be moved forward
  //    by an attacker replaying a captured delivery.
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  // 2. The signed string is `${t}.${rawBody}` — a full stop, no newline.
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  // 3. Constant-time compare. A plain === leaks the digest byte by byte.
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(String(parts.v1 || ""), "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: keep the RAW body, then verify before parsing.
app.post(
  "/hooks/accesswiser",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body.toString("utf8");
    const ok = verify(raw, req.get("X-AccessWiser-Signature") || "", SECRET);
    if (!ok) return res.status(401).end();

    const payload = JSON.parse(raw);
    // 4. The signature proves it came from us; this proves it was meant for you.
    if (payload.customerId !== MY_CUSTOMER_ID) return res.status(401).end();

    // X-AccessWiser-Delivery is stable across retries — deduplicate on it.
    res.status(200).end();
  },
);

Retries

Up to four attempts in total, backing off 1s, 5s then 25s, each with a 10-second timeout. We retry on a timeout, on 429 and on 5xx. Any other 4xx is not retried — you understood us and refused. Delivery is best effort; GET /scans/{id} remains authoritative.