AcessiScan

API documentation

Every endpoint lives under /api/v1, speaks JSON, and returns UTC timestamps. Authenticate with Authorization: Bearer <key> or X-API-Key.

Authentication

Create a key on the API keys page. Keys are stored as SHA-256 hashes, so the plaintext is shown once and cannot be recovered. Each key carries scopes; a call without the right scope returns 403 insufficient_scope.

curl https://accessi-scan.ltcillinois.org/api/v1/scans \
  -H "Authorization: Bearer ascn_your_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: nightly-2026-06-03-home" \
  -d '{"url":"https://example.org","viewport":"desktop"}'

Scopes

API scopes and what they permit
ScopeAllows
scans:create
Start scans
Queue an accessibility scan of a URL and read back its status. A scan that hits the shared cache returns a finished report immediately.
reports:read
Read reports and findings
Read completed reports, their per-element findings, and the historical series for a URL. Read-only.
webhooks:write
Manage webhooks
Register and remove webhook endpoints that receive scan.started, scan.completed and scan.failed events, signed with HMAC-SHA256 — and manage who is notified through MyLTC when a site regresses or a check cannot run. Both are "tell somebody when something happens", so they share a scope rather than splitting one decision across two.
admin:write
Administer the service
Cross-tenant administration: list tenants and scans, invalidate the shared cache, and read the audit log. Grant sparingly.

Endpoints

POST/api/v1/scansscans:create

Queue an accessibility scan.

Validates the URL against the SSRF policy, then either queues a scan (202 Accepted with a job id) or, when an identical public scan was run in the last 24 hours, returns the cached report immediately (200 OK). Send an Idempotency-Key header to make retries safe: a repeat with the same key returns the original scan instead of queueing a second one.

Parameters

Parameters for POST /api/v1/scans
NameTypeRequiredDescription
urlstringYesAbsolute http(s) URL to scan. Private, loopback, link-local and metadata addresses are refused.
viewportstringNodesktop | tablet | mobile. Default 'desktop'.
rulesetstringNowcag22aa | wcag21aa | wcag21a | best-practice. Default 'wcag22aa'.
authModestringNopublic | authenticated. Default 'public'. Authenticated scans never touch the shared cache.
prioritynumberNoHigher values are claimed first. Default 0.

Request

{
  "url": "https://example.org/enroll",
  "viewport": "desktop",
  "ruleset": "wcag22aa"
}

Response

{
  "id": "scan_7fk2m9qx4vbn3ptz8wc6hjrd",
  "status": "queued",
  "url": "https://example.org/enroll",
  "viewport": "desktop",
  "ruleset": "wcag22aa",
  "cached": false,
  "created_at": "2026-06-03T18:47:00.000Z"
}

GET/api/v1/scansreports:read

List scans, newest first.

Cursor-paginated. Pass the `next_cursor` from a response as `cursor` to fetch the following page. Filterable by status and by exact normalized URL.

Parameters

Parameters for GET /api/v1/scans
NameTypeRequiredDescription
statusstringNoqueued | running | completed | failed | cancelled.
urlstringNoExact URL match (normalized before comparison).
limitnumberNoPage size, 1–100. Default 25.
cursorstringNoOpaque cursor from the previous page.

Response

{
  "data": [
    {
      "id": "scan_7fk2m9qx4vbn3ptz8wc6hjrd",
      "status": "completed",
      "url": "https://example.org/enroll"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

GET/api/v1/scans/{id}reports:read

Read one scan and its current status.

Returns the scan record. Once complete, `report_id` points at the report produced.

Response

{
  "id": "scan_7fk2m9qx4vbn3ptz8wc6hjrd",
  "status": "completed",
  "url": "https://example.org/enroll",
  "report_id": "rpt_2n8xq4vk9mzb6cwr3tdh7pfj",
  "served_from_cache": false,
  "created_at": "2026-06-03T18:47:00.000Z",
  "finished_at": "2026-06-03T18:47:31.000Z"
}

POST/api/v1/scans/{id}/cancelscans:create

Cancel a queued or running scan.

A queued scan is cancelled immediately. A running scan is flagged, and its worker aborts at the next checkpoint — the response says `cancelling` in that case. Scans that already finished are returned unchanged.

Response

{
  "id": "scan_7fk2m9qx4vbn3ptz8wc6hjrd",
  "status": "cancelled",
  "outcome": "cancelled"
}

GET/api/v1/scans/{id}/reportreports:read

Read the report a scan produced.

Returns the report with its counts, compliance score, and a per-rule violation summary. Use the findings endpoint for the full element-level list.

Response

{
  "id": "rpt_2n8xq4vk9mzb6cwr3tdh7pfj",
  "scan_id": "scan_7fk2m9qx4vbn3ptz8wc6hjrd",
  "url": "https://example.org/enroll",
  "compliance_score": 78,
  "counts": {
    "violations": 14,
    "passes": 62,
    "incomplete": 3,
    "critical": 1,
    "serious": 6,
    "moderate": 5,
    "minor": 2
  },
  "rules": [
    {
      "rule_id": "color-contrast",
      "impact": "serious",
      "count": 6
    }
  ]
}

GET/api/v1/reports/{id}/screenshotreports:read

Fetch the page as it looked when it was scanned.

Returns JPEG bytes rather than JSON — this is the one endpoint whose answer is an image. Combine it with each finding's `location.box` and the report's `page_geometry` to draw a highlight over the page. Not every report has one: document scans never render a page, crawl pages that passed everything are not photographed, and images age out after 90 days while the report and its written locations are kept. All three answer 404.

Response

(binary image/jpeg)

GET/api/v1/reports/{id}/pages/{page}/imagereports:read

Fetch one page of a document, drawn as it was when it was checked.

The document equivalent of the page screenshot, addressed by 1-based page number, and JPEG bytes rather than JSON. Combine it with each finding's `location.box` to draw a highlight: a document finding's box is stored as fractions of its page (0–1 from the top-left), so it needs no geometry to place and holds at any render size. Only pages carrying a finding are rendered, the count is capped per document, and images age out after 90 days while the report and its written locations are kept. All three answer 404.

Response

(binary image/jpeg)

GET/api/v1/reports/{id}/findingsreports:read

List a report's findings, filterable and paginated.

One entry per affected element, with its CSS selector, HTML snippet, axe failure summary and plain-language remediation guidance.

Parameters

Parameters for GET /api/v1/reports/{id}/findings
NameTypeRequiredDescription
rulestringNoFilter to one axe rule id, e.g. color-contrast.
impactstringNocritical | serious | moderate | minor.
selectorstringNoSubstring match against the element selector.
typestringNoviolation | incomplete | pass. Default 'violation'.
limitnumberNoPage size, 1–200. Default 50.
cursorstringNoOpaque cursor from the previous page.

Response

{
  "data": [
    {
      "rule_id": "color-contrast",
      "impact": "serious",
      "selector": ".hero > p",
      "html": "<p class=\"lede\">Enrollment closes Friday</p>",
      "remediation": "Increase the contrast between the text and its background…"
    }
  ],
  "next_cursor": null,
  "has_more": false
}

POST/api/v1/sitesscans:create

Register a site to crawl.

A site is a standing crawl target: a root URL plus the scope, budget and rate limits every crawl of it inherits. The root URL goes through the same SSRF policy a single scan does, at registration rather than only at crawl time.

Parameters

Parameters for POST /api/v1/sites
NameTypeRequiredDescription
namestringYesDisplay name, e.g. "Example CUSD 100".
rootUrlstringYesWhere the crawl starts. Private, loopback, link-local and metadata addresses are refused.
includeSubdomainsbooleanNoTreat hs.district.org as part of district.org. Default false.
pathPrefixstringNoConfine the crawl to a path, e.g. /schools/east. Default: the whole host.
excludePatternsstringNoOne glob per line, matched against path and query. `*` matches anything.
maxPagesnumberNo1–5000. Default 250. The backstop that makes a crawl terminate.
maxDocumentsnumberNo0–1000. Default 50. PDFs the crawl may also check. 0 records the links without checking them.
viewportsstring[]NoWidths to check each page at: wide (1920), desktop (1440), tablet (834), mobile (390), narrow (320). Default ["desktop","tablet","mobile"]. narrow is the width WCAG 1.4.10 Reflow is written against, so a page can pass at mobile and fail there. Each width is a full re-scan of every page.
schedulestringNooff | daily | weekly | monthly. Default off. A scan on demand tells you about today; a scan on a schedule tells you what changed, which is what crawl.regressed needs a baseline for.
scheduleHournumberNo0–23, local time in scheduleTimezone (not UTC). Default 3. "3am" means 3am in July as well as January.
scheduleWeekdaynumberNo0 (Sunday) to 6. Used by the weekly schedule. Default 1.
scheduleDaynumberNo1–28, so the schedule means the same thing in February. Used by the monthly schedule.
scheduleTimezonestringNoIANA zone name. Default America/Chicago.
maxDepthnumberNo0–20. Default 5. Link hops from the root.
requestDelayMsnumberNoPause between page requests. Default 1000. A site's own Crawl-delay raises it.
maxConcurrentnumberNoPages in flight at once. Default 2.
obeyRobotsbooleanNoDefault true. Setting it false skips robots.txt Disallow rules only — Crawl-delay and the sitemap list are always honoured. On a domain this deployment owns it needs nothing further; on any other site it requires robotsOverrideReason.
robotsOverrideReasonstringNoRequired with obeyRobots:false on a site you do not own: who gave permission and where it is written down. Stored with the requester and a timestamp against it, and shown on every crawl that used it.
rulesetstringNowcag22aa | wcag21aa | wcag21a | best-practice. Default 'wcag22aa'.

Request

{
  "name": "Example CUSD 100",
  "rootUrl": "https://example.k12.il.us/",
  "maxPages": 250
}

Response

{
  "id": "site_9m4r5aauaj59kx3p2wqe7tvb",
  "name": "Example CUSD 100",
  "root_url": "https://example.k12.il.us/",
  "budget": {
    "max_pages": 250,
    "max_depth": 5
  }
}

GET/api/v1/sitesreports:read

List registered crawl targets.

Every site this tenant has registered, newest first.

GET/api/v1/sites/{id}reports:read

Read one site and its settings.

Another tenant's site id returns 404, not 403.

PATCH/api/v1/sites/{id}scans:create

Change a site's scope, budget or manners.

Every field of the create call except the root URL, which is deliberately fixed: it is what every crawl of this site was scoped against, so changing it would make the history a comparison between two different websites. A new root is a new site.

DELETE/api/v1/sites/{id}scans:create

Remove a site and its crawls.

Cascades to the site's crawls and their page records. The underlying page reports remain.

POST/api/v1/sites/{id}/crawlsscans:create

Crawl a site.

Queues a coordinator that discovers pages from robots.txt, the sitemap, and the links each scanned page returns, then scans them within the site's budget. Returns 202; poll the crawl for progress. Refused with 409 while a crawl of the same site is already running.

Response

{
  "id": "crl_7fk2m9qx4vbn3ptz8wc6hjrd",
  "status": "queued",
  "url": "https://example.k12.il.us/",
  "progress": {
    "discovered": 0,
    "scanned": 0,
    "max_pages": 250
  }
}

GET/api/v1/sites/{id}/crawlsreports:read

A site's crawl history.

Newest first, for a trend across runs.

GET/api/v1/crawls/{id}reports:read

Read a crawl's status, progress and totals.

Poll while it runs. `counts.issues` is distinct (rule, selector) groups rather than instances — how much work there is, as opposed to how loud it is.

GET/api/v1/crawls/{id}/pagesreports:read

Every URL the crawl considered.

Including the ones it did not scan, each with the reason: robots, off-scope, over-depth, over-budget, a non-HTML file, or a suspected crawler trap. A list of only the pages that worked could not tell you what was missed.

Parameters

Parameters for GET /api/v1/crawls/{id}/pages
NameTypeRequiredDescription
statestringNopending | queued | scanned | skipped | failed.
limitnumberNoPage size, 1–500. Default 200.

GET/api/v1/crawls/{id}/issuesreports:read

Findings across the crawl, grouped by what causes them.

One row per (rule, selector) with the number of pages it affects, ordered by impact and then by reach. A contrast failure in a shared header is one row saying "247 pages" rather than 247 findings — one fix in one template.

Response

{
  "data": [
    {
      "rule_id": "color-contrast",
      "impact": "serious",
      "selector": ".site-header a",
      "page_count": 247,
      "instance_count": 494,
      "example_url": "https://example.k12.il.us/about"
    }
  ]
}

POST/api/v1/crawls/{id}/cancelscans:create

Stop a running crawl.

The coordinator stops queuing pages at its next checkpoint. Pages already in flight finish, because abandoning them would leave rows locked for the reaper and they are seconds from done.

POST/api/v1/webhookswebhooks:write

Register a webhook endpoint.

Returns the signing secret exactly once. Every delivery carries X-AccessiScan-Signature: t=<unix>,v1=<hmac-sha256 of "timestamp.body">. Verify it before trusting a payload, and reject timestamps outside a few minutes to block replays.

Parameters

Parameters for POST /api/v1/webhooks
NameTypeRequiredDescription
urlstringYesHTTPS endpoint to POST events to. Must be publicly resolvable.
eventsstring[]Noscan.started | scan.completed | scan.failed. Default ["scan.completed"].

Request

{
  "url": "https://hooks.example.org/accessi",
  "events": [
    "scan.completed",
    "scan.failed"
  ]
}

Response

{
  "id": "whk_9mz3xq7vk2bn4cwr8tdh6pfj",
  "url": "https://hooks.example.org/accessi",
  "events": [
    "scan.completed",
    "scan.failed"
  ],
  "secret": "whsec_…"
}

GET/api/v1/sites/{id}/issuesreports:read

A site's open work, carried across crawls.

Issues rather than findings: one row per problem, with the date it was first seen, its status and its assignee. Identity is (site, rule, selector) with positional indices stripped from the selector, so an editor adding a news item does not resolve one issue and open an identical one. Defaults to open; pass status=all for everything.

Parameters

Parameters for GET /api/v1/sites/{id}/issues
NameTypeRequiredDescription
statusstringNoopen | resolved | wont_fix | false_positive | all. Default open.
limitnumberNoPage size, 1–500. Default 200.

Response

{
  "data": [
    {
      "id": "iss_4vbn3ptz8wc6hjrd7fk2m9qx",
      "rule_id": "color-contrast",
      "impact": "serious",
      "status": "open",
      "reach": {
        "pages": 247,
        "instances": 494
      },
      "first_seen_at": "2026-06-03T18:47:00.000Z"
    }
  ],
  "summary": {
    "open": 12,
    "resolved": 40,
    "wont_fix": 3,
    "false_positive": 1,
    "open_critical": 2
  }
}

GET/api/v1/issues/{id}reports:read

Read one issue.

Including when it was first seen, when it was last seen, and who decided about it.

PATCH/api/v1/issues/{id}scans:create

Decide about an issue, or assign it.

The only way a status becomes wont_fix or false_positive, and the only way one stops being either. A crawl never overwrites a decision: a re-scan that quietly undid a suppression would be worse than having none, because the operator would believe it was still in force. Who changed it and when are recorded.

Parameters

Parameters for PATCH /api/v1/issues/{id}
NameTypeRequiredDescription
statusstringNoopen | resolved | wont_fix | false_positive.
reasonstringNoWhy — shown next to the status, and kept in the audit log.
assignedTostringNoUser id, or null to unassign. Omitting the field leaves the assignee alone.

Request

{
  "status": "wont_fix",
  "reason": "Third-party embed; vendor ticket VEN-4821"
}

GET/api/v1/crawls/{id}/changesreports:read

What this crawl changed.

Which problems appeared for the first time and which stopped being found. Drawn from the issue table rather than by comparing two reports, so something marked won't fix cannot reappear as newly broken every week.

POST/api/v1/uploadsscans:create

Upload a document, image or media file to check.

multipart/form-data. The file is typed by its magic bytes, never by filename or declared Content-Type — both are set by the uploader, and a .pdf that is really HTML must not choose which parser runs.

GET/api/v1/uploadsreports:read

List uploaded files.

Cursor-paginated. Metadata only; the bytes are fetched per file.

GET/api/v1/uploads/{id}reports:read

Read one upload, or download it.

Metadata by default; `?download=1` returns the bytes attachment-only, nosniff, under a sandbox CSP, so an uploaded SVG cannot become stored XSS on this origin.

PATCH/api/v1/uploads/{id}scans:create

Attach a caption file to a media upload.

Pairs a .vtt or .srt with a video so the two can be checked together.

DELETE/api/v1/uploads/{id}scans:create

Delete an upload.

`?keepReport=1` drops the bytes but keeps the reports already produced from them.

POST/api/v1/api-keysscans:create

Mint an API key.

Signed-in users only, never another API key: a key that can mint keys can widen its own reach. A caller cannot grant a scope it does not itself hold. The plaintext is returned exactly once.

GET/api/v1/api-keysscans:create

List the tenant's API keys.

Prefixes, scopes and last use. Never key material.

DELETE/api/v1/api-keys/{id}scans:create

Revoke an API key.

Immediate: every request re-checks revocation, so a revoked key stops working on its next call.

GET/api/v1/notificationswebhooks:write

List who hears about what.

Notifications are delivered through MyLTC's own notification system rather than a channel of this app's own, so they land beside everything else from the LTC. Recipients are named by email because that is what the portal matches on — and because the people who need to hear that a page broke are often not the people who sign in here.

Response

{
  "data": [
    {
      "id": "nsb_9f2c1a",
      "event": "crawl.regressed",
      "site_id": "sit_4b8d10",
      "emails": [
        "webmaster@example.k12.il.us"
      ],
      "send_email": false,
      "active": true
    }
  ]
}

POST/api/v1/notificationswebhooks:write

Subscribe to an event, or change an existing subscription.

Upsert, keyed on (site, event): two subscriptions to one event would mean two messages about one thing happening. Omit siteId to hear about every site. crawl.regressed is the one to start with — it fires when something that was fine is not any more, which is the reason to scan on a schedule at all.

Parameters

Parameters for POST /api/v1/notifications
NameTypeRequiredDescription
eventstringYescrawl.regressed | crawl.completed | crawl.failed | scan.failed.
siteIdstringNoSite to watch. Omit for every site.
emailsstring[]YesRecipient addresses, 1–200. Matched to MyLTC accounts by the portal.
sendEmailbooleanNoAlso send a real email, so recipients without a portal account still hear. Default false.
activebooleanNoSet false to pause without unsubscribing. Default true.

Request

{
  "event": "crawl.regressed",
  "siteId": "sit_4b8d10",
  "emails": [
    "webmaster@example.k12.il.us"
  ],
  "sendEmail": true
}

DELETE/api/v1/notifications/{id}webhooks:write

Stop hearing about this.

Removes the subscription. The delivery history stays — its rows keep their own copy of who was told what, so unsubscribing never rewrites the record of what was already sent.

Response

{
  "id": "nsb_9f2c1a",
  "deleted": true
}

DELETE/api/v1/mcp/connections/{clientId}reports:read

Disconnect an assistant from your account.

Revokes every token the MCP client holds for the person making the request, refresh tokens included — revoking only the access token would appear to work and quietly last until the next refresh. Requires a signed-in session rather than an API key: withdrawing a grant is a personal act, and a key has no person behind it to act for. Revoking nothing is not an error; the tokens may already have expired.

Response

{
  "client_id": "mcp_7fk2m9qx4vbn3ptz",
  "revoked": 2,
  "disconnected": true
}

GET/api/v1/webhookswebhooks:write

List webhook endpoints.

Secrets are returned masked; the full value is shown only at creation.

GET/api/v1/webhooks/{id}webhooks:write

Read one webhook and its recent deliveries.

Delivery attempts are kept for debugging a endpoint that is not receiving events.

DELETE/api/v1/webhooks/{id}webhooks:write

Remove a webhook endpoint.

Deliveries stop immediately.

GET/api/v1/admin/tenantsadmin:write

List every tenant with usage counts.

Cross-tenant. Requires the admin scope.

GET/api/v1/admin/scansadmin:write

List scans across every tenant.

Cursor-paginated, filterable by status and tenant. Requires the admin scope.

POST/api/v1/admin/cache/invalidateadmin:write

Invalidate shared cache entries.

Drops cached reports so the next scan re-runs against the live page. Target one URL, one cache key, or everything.

Parameters

Parameters for POST /api/v1/admin/cache/invalidate
NameTypeRequiredDescription
urlstringNoInvalidate every entry for this URL (normalized first).
cacheKeystringNoInvalidate one entry by its key.
allbooleanNoInvalidate every entry. Mutually exclusive with the above.

Request

{
  "url": "https://example.org/enroll"
}

Response

{
  "ok": true,
  "invalidated": 3
}

GET/api/v1/admin/cache/invalidateadmin:write

Shared cache statistics.

Entry counts, hits served and distinct URLs. Shares a path with the invalidate action rather than earning its own, which is the sort of thing a registry entry is for saying out loud.

GET/api/v1/admin/audit-eventsadmin:write

Read the audit log.

Append-only record of scans, key changes and cache invalidations. Cursor-paginated.

Verifying webhook signatures

Every delivery carries X-AccessiScan-Signature: t=<unix>,v1=<hex>. The HMAC covers "<t>.<raw request body>" — the timestamp is inside the signed string so a captured delivery cannot be replayed later. Compare in constant time, and reject anything older than a few minutes.

import { createHmac, timingSafeEqual } from 'crypto';

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const age = Math.floor(Date.now() / 1000) - Number(parts.t);
  if (Math.abs(age) > 300) return false;               // stale or future-dated

  const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(parts.v1);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}