Scan API

Run automated accessibility scans against sites you own, from your own tooling. Post a URL, get a scan id back, then poll for the result or receive a signed webhook when it finishes.

https://app.accesswiser.com/api/v1

What automated scanning can and cannot tell you

Read this before you wire the API into anything that reports a status. Automated testing detects only a portion of possible accessibility barriers — industry studies put it at roughly a third to a half. It cannot judge whether alt text is meaningful, whether reading order makes sense, or whether a form is genuinely usable with a screen reader. Those need manual testing and testing with assistive technology.

A scan therefore cannot establish compliance and this API never claims to. There is no compliance field, no compliance status and no compliance score in any response. healthScoreis an internal progress indicator, nothing more. A green pipeline means your automated checks passed — it does not mean your site conforms to WCAG 2.2 AA or complies with the ADA, Section 508 or the EAA, and it is not legal advice.

Every scan-bearing response carries this string in its disclaimer field. If you surface our numbers in a dashboard, a PR comment or a report, surface this alongside them:

Automated testing detects only a portion of possible accessibility barriers and cannot replace manual testing with assistive technology. healthScore is an internal progress indicator, not a legal determination, certification or guarantee, and running this scan does not by itself make a site conform to the ADA, WCAG 2.2 AA, Section 508 or the EAA.

For the fuller picture, see what AccessWiser does and doesn't do.

What the Scan API is for

The Scan API runs the same accessibility scanner the dashboard runs, triggered from your own code instead of a button. It is built for the places a person is not sitting there clicking: a CI job on every deploy, a nightly check of your key templates, or your own internal tooling.

It is a server-to-server API with a secret key. A scan spends your account's allowance or credits — real money — so the key must never appear in page source, client-side JavaScript or a mobile app. Browsers are deliberately locked out: no response grants a cross-origin reader, and a preflight is answered with 405.

The shape is asynchronous, because rendering and analysing a page takes longer than a request should wait:

  • POST /scans answers 202 Accepted with a scan id, and the work runs in the background.
  • GET /scans/{id} is the authoritative source of the result. Poll it until terminal is true.
  • A signed webhook is available so you don't have to poll. It is a convenience, not the system of record — a receiver that misses every delivery loses nothing but latency.

One rule sits above all of it: you can only scan a domain your account has registered and proved control of. That is enforced on our servers on every request, not just written in a policy.

Getting started

The Scan API is available on every plan, including Free. What changes with the plan is how many scans a month you get — see scan classes, allowances and credits and pricing. Every request is checked against your plan's scanning entitlement; a plan without it is answered 403 plan_required.

Not the same as the "API access" line on the pricing page. That entitlement is the Business-plan widget and analytics API — a different credential and a different set of endpoints. The Scan API documented here is separate and is not restricted to Business.

1. Create a secret scan API key

Keys are created from your signed-in AccessWiser account. A key needs a label (1–60 characters, e.g. CI pipeline) and a lifetime — 7, 30, 60 or 90 days, or never expires. You can hold up to 10 live keys at once and revoke any of them at any time. If you can't find the screen in your account, email us and we will sort you out.

New keys default to 7 days. This changed: the expiry used to be optional, and a key created without one never expired. A short life limits what a leaked key is worth, so the default is now the shortest option and a permanent key is something you choose on purpose.

If you are wiring this into CI, decide which you want before you build around it. A request made with an expired key is refused with 401 and error.reason of expired — the same shape as a revoked key, so a pipeline that already handles one handles the other. Nothing warns you in advance, and an expired key cannot be extended: create a new one, deploy the secret, then revoke the old one.

The secret looks like awsk_… and is shown exactly once. We store only a keyed hash of it, so a lost key is replaced, never recovered — not even by us. Treat it like a password: environment variable or secret store, never a commit.

Not the same as your widget key. The public aw_… key that goes in your page source is visible to every visitor and is rejected by this API before any lookup happens, with reason: "widget_key". If it were accepted, anyone viewing your page source could spend your scan credits.

2. Register and verify your domain

Registering a domain on your account is an assertion. Verifying it is evidence, and the API wants the evidence. Publish the DNS TXT record your account generates for that domain — either on the _accesswiser subdomain or on the bare domain — and run the check:

Name:  _accesswiser.example.com
Type:  TXT
Value: accesswiser-site-verification=<the token shown in your account>

The token is unique to your account and that domain, so no other account can verify with it. Verifying a domain also covers its subdomains, since they share the same zone of authority. Until the check passes, every scan request for that host is refused with 403 domain_not_verified— before anything is sent to the target site. Every scan and every refused attempt is written to your account's audit log with the key that made it.

3. Make your first request

Send an Idempotency-Key from CI (a git SHA works well). It is optional, but it is what stops a pipeline retry from paying for a second scan.

curl -X POST https://app.accesswiser.com/api/v1/scans \
  -H "Authorization: Bearer awsk_YOUR_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: build-${GITHUB_SHA}" \
  -d '{
    "url": "https://example.com/pricing",
    "scanClass": "quick"
  }'

You get back:

HTTP/1.1 202 Accepted
Location: /api/v1/scans/6f1c0f6e-6c2c-4a3b-9f5a-2a1d3b4c5d6e
X-Scan-Class: quick
X-Scan-Allowance-Limit: 400
X-Scan-Allowance-Remaining: 397
X-Scan-Allowance-Reset: 2026-08-01T00:00:00.000Z
X-Scan-Credits-Remaining: 0

{
  "scanId": "6f1c0f6e-6c2c-4a3b-9f5a-2a1d3b4c5d6e",
  "status": "running",
  "scanClass": "quick",
  "url": "https://example.com/pricing",
  "chargedTo": "allowance",
  "allowanceRemaining": 397,
  "credits": 0,
  "statusUrl": "/api/v1/scans/6f1c0f6e-6c2c-4a3b-9f5a-2a1d3b4c5d6e",
  "disclaimer": "Automated testing detects only a portion of ..."
}

Then poll statusUrl until terminal is true.

Authentication

Every request carries your secret key as a bearer token:

Authorization: Bearer awsk_YOUR_SECRET_KEY

An X-API-Key header is also read, mainly so that pasting the wrong key gives you a clear answer rather than a baffling 401 — Authorization: Bearer is the documented form. A rejected request answers 401 with a WWW-Authenticate challenge and a machine-readable error.reason of missing, malformed, widget_key, unknown, revoked or expired.

Endpoints

Two of them. Both live under https://app.accesswiser.com/api/v1, both answer JSON, and both send Cache-Control: private, no-store.

POST /scans — create a scan

Request bodies are capped at 4 KB. Fields:

POST /scans request body fields
FieldTypeNotes
urlstringRequired. The page to scan. http and https only; if you omit the scheme, https:// is added. The host must be registered and verified on your account.
scanClass"quick" | "evidence"Optional, defaults to "quick". Any other value is a 400 — it is never silently downgraded, because that would bill the wrong pot. Both classes run the same analysis today; the class decides which allowance and credit pot pays. See scan classes, allowances and credits.
restaurantbooleanOptional, defaults to false. Adds the restaurant-vertical detectors (PDF menus, menus published as images of text, third-party ordering and reservation flows) on top of the standard checks.
ecommercebooleanOptional, defaults to false. Adds the online-store detectors (cart and checkout update announcements, redundant address entry, authentication alternatives) on top of the standard checks. These findings are review items for a person to verify, not automated failures.
webhookUrlstringOptional. An https URL we call when the scan reaches a terminal state. Validated when you create the scan, not at delivery time, so an unusable callback fails your request with a 400 instead of silently never calling back.

Optional header: Idempotency-Key, 1–255 printable ASCII characters. Replaying a key that already produced a scan returns that same scan with "idempotent": true and an Idempotency-Replayed: true header, and costs nothing. A key whose first request is still in flight gets 409 idempotency_key_in_flight.

A successful create is 202 Accepted with a Location header. Note chargedTo, which tells you whether the scan came out of your monthly allowance or out of purchased credits.

GET /scans/{id} — status and result

The id is a UUID; anything else is a 404. So is a scan belonging to another account — deliberately, so this endpoint cannot be used to discover that someone else's scan exists.

curl https://app.accesswiser.com/api/v1/scans/6f1c0f6e-6c2c-4a3b-9f5a-2a1d3b4c5d6e \
  -H "Authorization: Bearer awsk_YOUR_SECRET_KEY"
{
  "scanId": "6f1c0f6e-6c2c-4a3b-9f5a-2a1d3b4c5d6e",
  "status": "completed",
  "scanClass": "quick",
  "url": "https://example.com/pricing",
  "healthScore": 82,
  "summary": {
    "totalIssues": 14,
    "critical": 1,
    "serious": 4,
    "moderate": 6,
    "minor": 3,
    "incomplete": 2
  },
  "findings": [
    {
      "findingId": "9d7f2a10-4c3b-4e5d-8a6f-1b2c3d4e5f60",
      "ruleId": "menu-image",
      "impact": "critical",
      "criteria": [
        { "id": "1.1.1", "level": "A" },
        { "id": "1.4.5", "level": "AA" }
      ],
      "section508": true,
      "en301549": true,
      "help": "The menu is published as an image, so its text is unavailable to a screen reader.",
      "helpUrl": "https://www.w3.org/WAI/WCAG22/Understanding/images-of-text.html",
      "selector": "main > img:nth-of-type(2)",
      "html": "<img src=\"/menu.png\" width=\"900\">",
      "fixSummary": "Publish the menu as HTML text, or provide the same content in text alongside the image.",
      "category": "restaurant"
    }
  ],
  "findingsPagination": {
    "limit": 50,
    "offset": 0,
    "returned": 14,
    "total": 14,
    "impact": null,
    "hasMore": false,
    "nextUrl": null
  },
  "engineVersion": "axe-core@4.10.2",
  "error": null,
  "createdAt": "2026-07-31T09:14:02.117Z",
  "startedAt": "2026-07-31T09:14:02.402Z",
  "completedAt": "2026-07-31T09:14:29.884Z",
  "terminal": true,
  "disclaimer": "Automated testing detects only a portion of ..."
}
GET /scans/{id} response fields
FieldTypeNotes
status"pending" | "running" | "completed" | "failed"The scan's current state.
terminalbooleantrue once the status is completed or failed. Poll on this rather than hard-coding the status vocabulary.
healthScorenumber | nullnull unless the scan completed. An internal progress indicator only — not a legal determination, certification or guarantee, and not a compliance score.
summaryobject | nullnull unless the scan completed. Counts only: totalIssues, critical, serious, moderate, minor, incomplete. The per-issue detail is in findings.
findingsobject[] | nullOne page of what the scan found, in our own schema — no engine result envelope, no nodes or tags arrays. Each finding carries findingId, ruleId, impact, criteria (the WCAG 2.2 success criteria it fails, each with its own level — a finding routinely spans levels, so there is no single level for the finding), section508, en301549, help, helpUrl, selector, html, fixSummary and category. null — not []— until the scan completes, because an empty array from a running scan would read as “we looked and found nothing”.
findingsPaginationobject | nulllimit, offset, returned, total, the impact filter in force, hasMore, and a ready-made nextUrl. Control it with ?limit= (1–200, default 50), ?offset= and ?impact=critical,serious. Values outside those bounds are a 400, never a silent correction. The order is stable and repeatable but not ranked by severity — filter for the severities you act on rather than reading page one.
engineVersionstring | nullWhich version of the scanning engine produced the result.
errorstring | nullPopulated only when status is "failed".
createdAt / startedAt / completedAtstring | nullISO-8601 timestamps. startedAt and completedAt are null until they happen.
disclaimerstringPresent on every scan-bearing response. Surface it wherever you surface the numbers.

A completed scan returns its findingsas well as the counts — the element, the WCAG 2.2 success criteria it fails with a level on each criterion, and what to change. They come back in AccessWiser's own schema rather than the underlying test engine's, so there is no result envelope to unpick. Findings are paginated: follow findingsPagination.nextUrl until it is null.

A finding is a barrier we detected. An empty page is not evidence that a page has none — automated testing finds a portion of what is there, and cannot replace manual testing with assistive technology.

A scan that our workers cannot finish inside the time limit is marked failed and is not charged: it stops counting against your monthly allowance, and if it was paid for with a credit, the credit is returned.

Response headers

Allowance state rides on headers as well as the body, so a CI job can read it without parsing JSON — including from a 429, whose body is not much use to you.

  • X-Scan-Class — which pot the numbers below refer to.
  • X-Scan-Allowance-Limit, X-Scan-Allowance-Remaining, X-Scan-Allowance-Reset— this month's allowance for that class and when it resets.
  • X-Scan-Credits-Remaining — purchased credits of that class, a separate pot.
  • On a rate-limited response: Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

Errors

Every failure has the same shape: an error object with a stable code, a human message, and sometimes extra context. Branch on the code, never on the message.

{
  "error": {
    "code": "domain_not_verified",
    "message": "\"example.com\" is registered but not verified. Publish the DNS TXT record shown under your domains and run the check.",
    "domain": "example.com"
  }
}
Scan API error codes
CodeHTTPWhen
unauthorized401No key, a malformed key, a revoked or expired key — or your public aw_ widget key, which is refused before any lookup. The reason is echoed in error.reason.
invalid_request400Missing or invalid url, an unrecognised scanClass, a non-deliverable webhookUrl, a malformed Idempotency-Key, or a body that is not valid JSON.
payload_too_large413The request body is over 4 KB.
plan_required403Your plan does not include accessibility scanning.
domain_not_allowed403The target host is not one of the domains registered on your account (or you have registered none).
domain_not_verified403The domain is registered but the DNS TXT proof has not been published and checked.
no_allowance429This month's allowance for that scan class is used up and you hold no credits of that class. The body carries scansUsed, scanLimit, credits and resetsAt.
rate_limited429A per-IP or per-key rate limit was hit. Back off using the Retry-After header.
concurrency_limit429 / 503429 when your account already has the maximum scans in flight; 503 when the scanner as a whole is at capacity. Both carry Retry-After.
idempotency_key_in_flight409Another request with the same Idempotency-Key is still being processed. Retry after a moment.
not_found404No scan with that id on your account. A scan belonging to another account returns 404, never 403.
method_not_allowed405Wrong verb, including any browser preflight — this is a server-to-server API and grants no cross-origin access.
internal_error500Something broke on our side.

Webhooks

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://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://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.

Rate limits and concurrency

Current limits. They exist so that one account cannot degrade the service for everyone else, and so the sites we scan — including yours — are never hit harder than they should be.

  • 60 requests per minute per key, across all /v1 endpoints.
  • 10 scan creations per minute per key, on top of the above. Creating a scan is bounded harder than reading one, because each create launches an outbound render.
  • 30 requests per minute per IP, applied before your key is even looked at, so credential guessing is bounded too.
  • 3 scans in flight per account. A fourth gets 429 concurrency_limit with Retry-After: 30.
  • A deployment-wide concurrency ceiling as well. When the scanner is saturated you get 503 concurrency_limit with Retry-After: 60. Treat it as "come back shortly", not as a failed build.

Always honour Retry-After. Working around the limits — rotating accounts, splitting quota, scripting around the published numbers — is a breach of the Acceptable Use Policy. If your legitimate needs exceed them, talk to us instead.

Scan classes, allowances and credits

There are two scan classes, and you choose explicitly with scanClass — it is never inferred from your plan or from the target. They are entitlement classes: they decide which pot pays for the run, and the class is recorded on the scan and echoed in the API response, the allowance headers and the webhook payload.

  • quick — the default. The everyday check you run on every deploy.
  • evidence — draws on a separate, scarcer allowance, for the runs you want tagged as ones you intend to keep as a dated record of the work.

Both classes run the same analysis today. The scanner renders the page in a real browser wherever one is available and falls back to a static analysis otherwise, and that is true regardless of class. Choosing evidence charges a different pot and labels the run; it does not currently run deeper checks, produce an extra artefact, or change how long the result is kept. If that changes, this page changes with it.

The two classes draw on separate monthly allowances and separate credit pots. A specific page scan can never spend an evidence credit, and vice versa. Monthly allowance per class:

Monthly scan allowance by plan and scan class
PlanSpecific page scans / monthFull site scans / month
Free20
Starter51
Basic203
Pro5010
Business8020

Allowances reset at the start of each month; the exact moment is in X-Scan-Allowance-Reset and in the resetsAt field of a no_allowance error.

Credits top up one class and are bought as one-time packs from your dashboard. They are spent only after that month's allowance is exhausted, so you never burn a paid credit while free allowance remains, and they stay spendable for 12 months from purchase. Packs are sold for both the quick and evidence classes. The evidence pack costs more per scan and does not buy a different scan — runScan() is never given the class, so the analysis is identical. What it buys is metering: a separate pot and a labelled, dated run. Pack sizes and prices are on the pricing page. The chargedTo field on a create response tells you which pot paid: "allowance" or "credit".

A failed scan is free. It stops counting against the monthly allowance, and a credit spent on it is returned to the pot it came from.

When both pots are empty for a class, creating a scan of that class returns 429 no_allowance, carrying scansUsed, scanLimit, credits and resetsAt.

Acceptable use

Use of this API is bound by the Acceptable Use Policy and the Terms and Conditions. The short version, because it shapes how the API behaves:

  • Scan only domains you own or are authorised to test, and that you have registered and verified on your account. The API enforces the verification; holding the authorisation is still your responsibility.
  • Do not use scan output to assemble accessibility demand letters, shortlist defendants, or otherwise prospect for litigation against sites you do not own.
  • Do not detect our scanner in order to serve it a page different from the one your real visitors get. Scan history is dated and retained, and that pattern reads as concealment.
  • Do not work around rate limits or quotas. Talk to us instead.

Questions, or something the docs don't cover? contact@accesswiser.com.

The Scan API helps you find and fix WCAG 2.2 AA issues and keep a dated record of the work. Automated testing detects only a portion of possible accessibility barriers and cannot replace manual testing with assistive technology, so a scan result — including healthScore — is not a determination of conformance or compliance with the ADA, WCAG 2.2 AA, Section 508, the EAA or any other law, and nothing on this page is legal advice.