AccessiScanSign in

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.
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}/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/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/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/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);
}