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