Webhooks
A webhook is Page Lookout calling you: when one of your organization's scan runs finishes, it
sends a signed HTTPS POST to every endpoint you registered, so your systems can react - open a
ticket, post to a chat, fetch the findings - without polling.
Setting up an endpoint
An organization admin adds endpoints on the Integrations screen of the dashboard, up to five per organization. An endpoint must be:
- an
https://URL on the standard port (443), at most 2048 characters, with no user name, password or#fragment; - a public host name - not an IP address, not
localhostor another internal name, and not a name that resolves to a private address. The address is checked when you save the endpoint and again before every delivery.
Saving a new endpoint shows its signing secret once - whsec_ followed by 43 characters.
Store it with your receiver: it is what proves a request came from Page Lookout. The dashboard
later shows only its last four characters.
Test on the endpoint's row sends a signed webhook.ping right away - use it
to check that your receiver verifies the signature before any real event depends on it.
The events
| Event | When |
|---|---|
scan.completed | a scan run of the organization finished: completed, partially_completed or failed |
webhook.ping | an admin pressed Test on the endpoint in the dashboard. Sent once, never retried |
The full schemas are in the API reference:
scan.completed and
webhook.ping. Both bodies share an
envelope: id (the event id), type, apiVersion (1), createdAt, and data.
scan.completed
{
"id": "aaaaaaaa-0000-4000-8000-000000000001",
"type": "scan.completed",
"apiVersion": 1,
"createdAt": "2026-09-24T10:00:00.000Z",
"data": {
"organization": { "id": "11111111-1111-4111-8111-111111111111", "slug": "acme" },
"site": { "id": "22222222-2222-4222-8222-222222222222", "hostname": "shop.example.com" },
"scanRun": {
"id": "33333333-3333-4333-8333-333333333333",
"status": "partially_completed",
"trigger": "scheduled",
"queuedAt": "2026-09-24T09:00:00.000Z",
"startedAt": "2026-09-24T09:00:05.000Z",
"finishedAt": "2026-09-24T10:00:00.000Z",
"policyVersion": 3
},
"summary": {
"findingCounts": {
"total": 12,
"bySeverity": { "info": 0, "low": 4, "medium": 6, "high": 2, "critical": 0 }
},
"issueCount": 7,
"taskCounts": { "total": 40, "completed": 38, "failed": 2, "cancelled": 0 },
"failedModules": ["consent"],
"alertsRaised": 1,
"comparison": {
"state": "computed",
"counts": {
"new": 2,
"persistent": 10,
"changed": 0,
"resolved": 3,
"unscanned": 0,
"incomparable": 0
}
}
},
"links": {
"scanRun": "https://app.example.invalid/o/acme/sites/22222222-2222-4222-8222-222222222222/scans/33333333-3333-4333-8333-333333333333"
}
}
}
data.scanRun.statusis how the run ended. Afailedrun is reported too: itsstartedAtmay benull(it never started), and itssummary.comparisonisnull(a failed run is never compared).createdAtis when the run finished, not when this attempt was sent.summaryis counts only. The findings themselves are not in the body - nor URLs, evidence, e-mail addresses or the names you gave the organization and the site. Fetch what you need with an API key, usingdata.scanRun.id.links.scanRunopens the run in the dashboard, or isnullwhen none can be linked.
webhook.ping
{
"id": "aaaaaaaa-0000-4000-8000-000000000002",
"type": "webhook.ping",
"apiVersion": 1,
"createdAt": "2026-09-24T10:00:00.000Z",
"data": {
"organization": { "id": "11111111-1111-4111-8111-111111111111", "slug": "acme" },
"endpoint": { "id": "44444444-4444-4444-8444-444444444444" }
}
}
The request
Every delivery is a POST of at most 16384 bytes of JSON, with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | PageLookout-Webhooks/1 |
X-PageLookout-Event | the event type: scan.completed or webhook.ping |
X-PageLookout-Delivery-Id | the delivery id: one per endpoint per event, the same on every attempt of that delivery |
X-PageLookout-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> - see below |
Idempotency-Key | webhook-delivery/<delivery id>, the same on every attempt |
Requests come from addresses that change, so do not allowlist by IP address - verify the signature instead.
Verifying the signature
X-PageLookout-Signature is t=<unix seconds>,v1=<hex>, where the hex is the HMAC-SHA256 of the
string <t>.<raw body>:
- the key is the endpoint's whole signing secret as UTF-8,
whsec_prefix included; - the message is the value of
t, a dot, and the exact bytes of the body as received - verify before you parse the JSON, because a re-serialised body does not match; - refuse a
tmore than 300 seconds from your clock. The timestamp is signed too, so a captured request cannot be replayed later. Every attempt is signed afresh, so a retry an hour later still arrives inside the window; - compare in constant time.
Refuse a request whose signature does not verify with 401, and do nothing with it.
Node.js
// Verify a Page Lookout webhook delivery - Node.js 18 or later, no dependencies.
import { Buffer } from 'node:buffer';
import { createHmac, timingSafeEqual } from 'node:crypto';
// Refuse a signature made more than five minutes from this machine's clock.
const TOLERANCE_SECONDS = 300;
/**
* True when `signatureHeader` - the X-PageLookout-Signature header - signs `rawBody` with
* `secret` and was made within the tolerance.
*
* - `secret` is the endpoint's whole signing secret, `whsec_` prefix included.
* - `rawBody` is the exact bytes received (a Buffer or a string), before any JSON parsing:
* a re-serialised body does not match.
*/
export function verifyWebhook(secret, signatureHeader, rawBody, nowSeconds = Date.now() / 1000) {
const parts = new Map();
for (const item of String(signatureHeader ?? '').split(',')) {
const at = item.indexOf('=');
if (at > 0) {
parts.set(item.slice(0, at).trim(), item.slice(at + 1).trim());
}
}
const timestamp = parts.get('t') ?? '';
const signature = parts.get('v1') ?? '';
if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) {
return false;
}
if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return timingSafeEqual(Buffer.from(signature, 'hex'), expected);
}
With node:http, read the body into one Buffer and verify it before parsing:
import { createServer } from 'node:http';
import { verifyWebhook } from './verify-webhook.mjs';
const SECRET = process.env.PAGE_LOOKOUT_WEBHOOK_SECRET;
createServer((request, response) => {
const chunks = [];
request.on('data', (chunk) => chunks.push(chunk));
request.on('end', () => {
const body = Buffer.concat(chunks);
if (!verifyWebhook(SECRET, request.headers['x-pagelookout-signature'], body)) {
response.writeHead(401).end();
return;
}
response.writeHead(204).end();
handle(JSON.parse(body.toString('utf8'))); // after answering - see "Answering" below
});
}).listen(8080);
In Express, use express.raw({ type: 'application/json' }) on the webhook route, so req.body
is the raw Buffer.
Python
"""Verify a Page Lookout webhook delivery - Python 3.8 or later, standard library only."""
import hashlib
import hmac
import re
import time
# Refuse a signature made more than five minutes from this machine's clock.
TOLERANCE_SECONDS = 300
def verify_webhook(secret, signature_header, raw_body, now=None):
"""True when signature_header - the X-PageLookout-Signature header - signs raw_body
with secret and was made within the tolerance.
- secret is the endpoint's whole signing secret, whsec_ prefix included (str).
- raw_body is the exact bytes received (bytes), before any JSON parsing:
a re-serialised body does not match.
"""
parts = {}
for item in (signature_header or "").split(","):
key, sep, value = item.partition("=")
if sep:
parts[key.strip()] = value.strip()
timestamp = parts.get("t", "")
signature = parts.get("v1", "")
if not re.fullmatch(r"[0-9]+", timestamp) or not re.fullmatch(r"[0-9a-f]{64}", signature):
return False
current = time.time() if now is None else now
if abs(current - int(timestamp)) > TOLERANCE_SECONDS:
return False
message = timestamp.encode("ascii") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
In Flask, pass request.get_data(); in Django, request.body. Both are the raw bytes.
Answering, retries and duplicates
Answer with any 2xx within 10 seconds, then do the work. An answer that has not finished
within 10 seconds is a timeout and is retried - even if your receiver has already acted on it.
| Your answer | What happens |
|---|---|
any 2xx | delivered |
408, 429 or any 5xx | retried |
| a timeout, a refused connection, a TLS or DNS error | retried |
410 Gone to a scan.completed delivery | the endpoint is switched off at once |
410 Gone to a webhook.ping | a failed test - the endpoint stays on |
any other status, or a redirect (3xx) | not retried - redirects are never followed |
A scan.completed delivery is attempted at most 6 times. After attempt n fails, the next
waits:
| After attempt | Wait |
|---|---|
| 1 | 60 seconds |
| 2 | 300 seconds (5 min) |
| 3 | 1800 seconds (30 min) |
| 4 | 7200 seconds (2 h) |
| 5 and later | 28800 seconds (8 h) |
So the last attempt lands about ten and a half hours after the scan ended. A webhook.ping is
attempted once.
Delivery is at least once: deduplicate. The same event can arrive more than once - a retry
after a timeout, or a redelivery. Use the body's id (the event id: the same for every endpoint
and every attempt) to recognise an event you have handled; X-PageLookout-Delivery-Id and
Idempotency-Key identify one event at one endpoint, the same on every attempt. Events can also
arrive out of order: use data.scanRun.finishedAt, not arrival time, to order them.
Switching off, and back on
An endpoint is switched off automatically after 10 scan.completed deliveries in a row
end failed (every attempt used), or at once when it answers a scan.completed delivery with
410 Gone. A webhook.ping never switches an endpoint off. The dashboard shows it as
"Switched off automatically", with the reason. Nothing more is sent to it until an admin switches
it back on, which also resets the count. A delivery that succeeds resets the count too.
Deliveries on the endpoint's row lists every delivery with its attempts. A failed one can be sent again with Redeliver, once the endpoint is switched on. A delivery that has succeeded or failed is kept for 90 days after it was created, with its attempts, and then deleted; one that is still being attempted is kept until it finishes. Deleting an endpoint deletes its deliveries.
Rotating the signing secret
Rotate secret on the endpoint's row replaces the secret and shows the new one once. The change is immediate: from that moment every attempt - retries of earlier events included - is signed with the new secret only. To change over without losing a delivery:
- let your receiver accept a signature made with either the old or the new secret (verify against both);
- rotate, and store the new secret with the receiver;
- remove the old secret.
If your receiver refused deliveries in between, answer 503 while you are changing over - those
are retried and re-signed - or send the failed ones again with Redeliver afterwards.
Payload versions
Every body carries apiVersion, currently 1. A new field in a body does not change it:
ignore the fields you do not know. A change that would break a receiver - a field removed,
renamed or given another meaning - is a new apiVersion. Today every endpoint receives version
1. A new payload version will be opt-in per endpoint and announced at least 6 months in advance
(Versioning).