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:
- Read the raw body. Do not parse and re-serialise the JSON first — key order and whitespace are part of what was signed.
- Split
X-AccessWiser-Signatureon commas into itsk=vpairs and taketandv1. Ignore versions you do not know. - Reject if
tis more than 300 seconds from your own clock. Compare againsttfrom the signature, not the timestamp header — they are equal, but onlytis inside the signed string. - Build the signed string as exactly
`${t}` + "." + rawBody— an ASCII full stop, no trailing newline. - Compute HMAC-SHA256 over it with your signing secret, lower-case hex, and compare to
v1with a constant-time comparison. - Only then parse the JSON.
- Check that
customerIdin 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.