API and webhooks

Put your scans where you work

A small JSON API for reading your own scans, and a signed webhook when one finishes. Enough to open a ticket, post to a channel, or drop a score into a client dashboard without anybody opening a browser.

Create a key

Authentication

Create a key in your portal, under Developer. It is shown once — we keep only a fingerprint of it, so if you lose it you revoke it and make another.

curl https://api.keslo.co.uk/v1/me \
  -H "Authorization: Bearer keslo_sk_…"

Keys come in two kinds. A read key can see your scans and nothing else, and is the one to paste into a build pipeline. A write key can also start scans. Your session cookie will not work here and a key will not work on the website — two credentials, two surfaces, no overlap.

Endpoints

EndpointNeedsWhat it returns
GET /v1/meRead keyConfirm a key works and see what it can do. The first call to make.
GET /v1/scansRead keyYour scans, newest first. Takes ?limit= (up to 100), ?hostname= and ?cursor=.
GET /v1/scans/{id}Read keyOne scan with its score and every finding — the rule, the severity, how sure we are, and up to three affected pages each.
GET /v1/sitesRead keyThe sites you have scanned, with the latest score and the one before it.
POST /v1/scansWrite keyStart a scan. Only for a domain you have verified on your account — see below.

Paging

/v1/scans returns a nextCursor, or null when there is no more. Pass it back as ?cursor=. It is opaque — do not parse it. Paging this way rather than by page number means a scan starting between two of your requests cannot make you see one twice and miss another.

Starting a scan

curl -X POST https://api.keslo.co.uk/v1/scans \
  -H "Authorization: Bearer keslo_sk_…" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.co.uk"}'

You get 202 and a scan id straight away; the scan itself takes minutes. Poll /v1/scans/{id} until status is completed, or set up a webhook and let us tell you.

This only works on a domain you have verified on your account. A person running one scan of a site is no more intrusive than any other speed-testing tool. A script pointed at a list of domains is a different thing, and we are not willing to be that whoever is paying — so automated scanning needs the owner’s permission, proved once with a DNS record, a file or a meta tag. One-off scans of any site remain free on the website.

Rate limits

Sixty requests a minute per key. Over that you get 429 with a Retry-After header. Starting scans is limited separately and more tightly, because that limit protects the website being scanned rather than us.

Errors

Every failure is JSON with an error code you can branch on and a message written for a person. Codes are stable; messages are not, so match on the code.

{ "error": "not-verified", "message": "You have not verified …" }

401 means the key is missing or no longer valid. 403 means the key is fine and the thing you asked for is not allowed — a read key trying to start a scan, or an unverified domain. 404covers both “no such scan” and “not yours”, deliberately: telling those apart would let a key be used to discover which scan ids exist.

Webhooks

Add an endpoint in your portal and we will post JSON to it when one of your scans settles. There are 2 events:

  • scan.completed a scan finished
  • scan.failed a scan could not be completed

The body

{
  "event": "scan.completed",
  "deliveryId": "6f1c9d0e-3b2a-4f77-9a1e-0c2d4b8e5f31",
  "occurredAt": "2026-07-27T09:14:22.118Z",
  "test": false,
  "scan": {
    "id": "b4d1e2a8-77c3-4a19-9f0b-2e6d5c8a1043",
    "hostname": "example.co.uk",
    "url": "https://example.co.uk/",
    "status": "completed",
    "completedAt": "2026-07-27T09:14:21.902Z",
    "score": 68,
    "findings": {
      "total": 19, "critical": 1, "high": 4,
      "medium": 8, "low": 5, "info": 1
    }
  },
  "links": {
    "report": "https://keslo.co.uk/scan/b4d1e2a8-…",
    "api": "https://api.keslo.co.uk/v1/scans/b4d1e2a8-…"
  }
}

Counts rather than the findings themselves. A report can carry hundreds, and re-running our rules over stored evidence can change them after the fact — so a snapshot posted today would disagree with the report next month. The links.api URL is the live answer.

testis true when you press “send a test” in the portal. A test carries one of your own real finished scans rather than invented data, because the thing worth testing is whether your code can parse what we actually send — which means it is otherwise indistinguishable from a real event, and this field is how you tell.

Checking it came from us

Each delivery carries Keslo-Signature: t=…,v1=…. The v1 value is an HMAC-SHA256 of <timestamp>.<raw body>using your endpoint’s signing secret. Sign the raw bytes, before any JSON parsing — a re-serialised body will not match.

const crypto = require("node:crypto");

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    String(header).split(",").map((part) => part.trim().split("="))
  );
  if (!parts.v1) return false;

  // Reject anything older than 5 minutes, so a captured delivery
  // cannot be replayed at leisure. Written this way round so that a missing
  // timestamp — which makes age NaN — fails rather than passes.
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!(age <= 300)) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(parts.t + "." + rawBody)
    .digest("hex");

  // timingSafeEqual throws on a length mismatch, so check that first.
  const mine = Buffer.from(expected);
  const theirs = Buffer.from(parts.v1);
  return mine.length === theirs.length && crypto.timingSafeEqual(mine, theirs);
}

Also sent: Keslo-Event, Keslo-Delivery and Keslo-Attempt, so you can route and deduplicate without parsing the body.

Retries

Anything outside 2xx is a failure. We retry after 30 seconds, then 2 minutes, then 10 minutes, then 1 hour, then 6 hours 6 attempts in all — for a timeout, a 5xx, a 429 or a redirect. We do not retry a 4xx that means refusal, such as 404 or 403: your endpoint understood us and said no, and asking five more times only fills your log.

Retries reuse the same deliveryId and the same bytes, so deduplicate on that id and you can treat a repeat as a no-op. We post to the exact address you gave and do not follow redirects. If ten deliveries in a row are given up on we switch the endpoint off and say so in your portal — saving it again turns it back on.

What it will not do

  • Scan a domain you have not verified. Explained above. It is a consent decision, not a pricing one.
  • Tell you how to fix things. The API returns what we found and why it matters — the same as the free report. Step-by-step fixes are not a field here because they are not yet a field anywhere, and we would rather leave it out than ship an empty one.
  • Send uptime alerts. Uptime monitoring emails you today. It is the obvious next pair of webhook events and it is not built yet.
  • Promise never to change. This is v1 and it is new. Additive changes — new fields, new events — will arrive without warning, so write a receiver that ignores what it does not recognise. Anything that would break a working integration gets a new version and an email to everyone holding a key.

Something missing, or something here that is wrong? Tell us.