# wcag-audit API v1

Machine-facing API for running WCAG accessibility audits and retrieving results
and the PDF report.

This is separate from the browser flow. The web page gates its detailed report
behind a lead-capture form; **the API does not** — an API caller is already
authenticated, so results and the PDF are returned directly.

Base URL: `http://localhost:4200` (set `PORT` to change)

> **Deploying?** Set `PUBLIC_BASE_URL` to the address callers actually use, e.g.
> `https://wcag.example.com`. The `links` in every response are built from it.
> Without it they fall back to the request's own `Host` header — so a call made
> on the server against `localhost` comes back with `http://localhost:4200/...`
> in links meant to be shared. Behind a TLS-terminating proxy, also set
> `TRUST_PROXY=1` so the scheme reads `https` rather than `http`.

---

## Authentication

Every `/api/v1` route needs **two** credentials, both sent as headers:

```
X-API-Key:      <your-key>
X-API-Passcode: <your-passcode>
```

The key may also be sent as `Authorization: Bearer <your-key>`. The passcode
always uses its own header.

Configured server-side:

```bash
WCAG_API_KEYS="key-for-crm,key-for-website,key-for-ci"   # comma-separated
WCAG_API_PASSCODE='your-passcode'                        # shared across keys
```

`WCAG_API_KEYS` identifies the caller — issue one per consumer so any of them can
be revoked without disturbing the others. `WCAG_API_PASSCODE` is a shared secret
on top. It is **optional**, but once set it is required on every request.

**The API is disabled until `WCAG_API_KEYS` is set.** With no keys configured
every `/api/v1` route returns `503` rather than running unauthenticated — a
deploy that forgets the variable fails closed instead of silently exposing an
endpoint that drives headless browsers.

Both values are compared with a timing-safe hash comparison, and a wrong key and
a wrong passcode return the same message, so the response can't be used to work
out which half was correct.

| Status | Meaning |
|---|---|
| `503` | No keys configured — API disabled on this server |
| `401` | Key or passcode missing or wrong |

```bash
curl -X POST http://localhost:4200/api/v1/audits \
  -H 'X-API-Key: your-key' \
  -H 'X-API-Passcode: your-passcode' \
  -H 'Content-Type: application/json' \
  -d '{"urls":["https://www.example.com/"]}'
```

Serve this over HTTPS in production — credentials sent over plain HTTP are
readable in transit.

> **Quoting.** In a `.env` file, quote any value containing `#`, `$` or spaces.
> Unquoted, `#` starts a comment and silently truncates the value, which shows
> up as a puzzling `401` rather than an obvious error. Single quotes are safest.

---

## `POST /api/v1/audits`

Starts an audit. Returns immediately with `202 Accepted` — a scan takes roughly
20–60 seconds per page, well past most HTTP client timeouts, so results are
polled rather than waited on.

### Request

`Content-Type: application/json`

| Field | Type | Notes |
|---|---|---|
| `urls` | `string[]` or `string` | Pages to scan. An array, or a newline/comma-separated string. Missing scheme defaults to `https://`. Duplicates are dropped. |
| `sitemapUrl` | `string` | Alternative to `urls`. Loads the sitemap (following a `<sitemapindex>` into its children) and samples across page templates. |
| `maxPages` | `number` | `1`–`60`, default `20`. Only with `sitemapUrl`. |
| `wcagVersion` | `string` | One of `2.0-A`, `2.0-AA`, `2.1-A`, `2.1-AA`, `2.2-A`, `2.2-AA`. Default `2.2-AA`. |

Provide either `urls` or `sitemapUrl`. At most **25** URLs per request by
default — raise with `MAX_API_URLS` (hard ceiling 100).

```bash
curl -X POST http://localhost:4200/api/v1/audits \
  -H "X-API-Key: $WCAG_API_KEY" \
  -H "X-API-Passcode: $WCAG_API_PASSCODE" \
  -H 'Content-Type: application/json' \
  -d '{
    "urls": [
      "https://www.example.com/",
      "https://www.example.com/contact"
    ],
    "wcagVersion": "2.2-AA"
  }'
```

### Response — `202 Accepted`

```json
{
  "id": "a11y_1786098755107_1ulnd",
  "status": "running",
  "wcagVersion": "2.2-AA",
  "urls": ["https://www.example.com/", "https://www.example.com/contact"],
  "links": {
    "self": "http://localhost:4200/api/v1/audits/a11y_1786098755107_1ulnd",
    "pdf": null,
    "report": null
  }
}
```

`links.pdf` and `links.report` are `null` until the run finishes.

### Errors

| Status | Cause |
|---|---|
| `400` | No `urls`/`sitemapUrl`, unknown `wcagVersion`, too many URLs, or the sitemap couldn't be loaded |
| `401` / `503` | See Authentication |

---

## `GET /api/v1/audits/:id`

Poll this until `status` is `done`. Polling every 5 seconds is plenty.

### Response — `200 OK` (still running)

```json
{
  "id": "a11y_1786098755107_1ulnd",
  "status": "running",
  "startedAt": "2026-08-07T09:52:35.107Z",
  "links": { "self": "...", "pdf": null, "report": null }
}
```

### Response — `200 OK` (complete)

```json
{
  "id": "a11y_1786098755107_1ulnd",
  "status": "done",
  "startedAt": "2026-08-07T09:52:35.107Z",
  "finishedAt": "2026-08-07T09:53:04.882Z",
  "wcagVersion": "WCAG 2.2 Level AA",

  "summary": {
    "score": 59,
    "total": 47,
    "critical": 34,
    "high": 13,
    "medium": 0,
    "low": 0,
    "needsReview": 2,
    "pagesScanned": 1,
    "scanErrors": 0
  },

  "pages": [
    { "url": "https://www.example.com/", "title": "Example", "error": null }
  ],

  "categories": [
    {
      "key": "images",
      "label": "Images & Media",
      "status": "fail",
      "score": 33,
      "checksPassed": 1,
      "checksTotal": 3,
      "issueCount": 35
    }
  ],

  "findings": [
    {
      "ruleId": "image-alt",
      "issue": "Images are missing descriptions",
      "severity": "Critical",
      "category": "Images & Media",
      "wcagCriteria": ["1.1.1"],
      "occurrences": 33,
      "pages": 1,
      "businessImpact": "Blind and low-vision customers get no idea what these images show...",
      "recommendation": "Add a descriptive alt=\"...\" attribute. If the image is purely decorative..."
    }
  ],

  "links": {
    "self":   "http://localhost:4200/api/v1/audits/a11y_1786098755107_1ulnd",
    "pdf":    "http://localhost:4200/api/v1/audits/a11y_1786098755107_1ulnd/pdf",
    "report": "http://localhost:4200/report/a11y_1786098755107_1ulnd"
  }
}
```

### Field notes

- **`summary.score`** — percentage of automated checks that passed.
- **`summary.total`** vs **`findings.length`** — `total` counts individual failing
  elements; `findings` has one entry per *rule*. One broken rule failing on 33
  images is 33 in `total` and one finding with `occurrences: 33`. The findings
  list is what to act on.
- **`needsReview`** — items axe-core could not verdict either way (e.g. text over
  a photograph). Deliberately excluded from `total` rather than guessed at; they
  need a human.
- **`findings`** are sorted worst-first: Critical → High → Medium → Low.
- **`pages`** on a finding is how many scanned pages it appears on;
  `occurrences` is how many individual elements failed.
- **`links.report`** points at the HTML report. Note this route is the
  browser-facing one and is subject to the lead gate for runs started from the
  web form; runs started via the API are unlocked automatically.

### Errors

| Status | Cause |
|---|---|
| `404` | Unknown id. Runs are held in memory and do not survive a restart. |
| `500` | The audit itself failed — the body carries the reason in `error`. |

---

## `GET /api/v1/audits/:id/pdf`

Returns the report as `application/pdf`. Requires the same API key — the URL is
not publicly fetchable.

```bash
curl -L http://localhost:4200/api/v1/audits/$ID/pdf \
  -H "X-API-Key: $WCAG_API_KEY" -H "X-API-Passcode: $WCAG_API_PASSCODE" \
  -o accessibility-report.pdf
```

| Status | Cause |
|---|---|
| `409` | Audit still running |
| `404` | Unknown id, or no PDF was produced |
| `500` | The audit failed |

---

## End-to-end example

```bash
#!/usr/bin/env bash
set -euo pipefail
BASE=http://localhost:4200
KEY=$WCAG_API_KEY
PASS=$WCAG_API_PASSCODE

ID=$(curl -sS -X POST "$BASE/api/v1/audits" \
      -H "X-API-Key: $KEY" -H "X-API-Passcode: $PASS" -H 'Content-Type: application/json' \
      -d '{"urls":["https://www.example.com/"]}' \
    | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')

echo "Audit $ID started"

while :; do
  BODY=$(curl -sS "$BASE/api/v1/audits/$ID" -H "X-API-Key: $KEY" -H "X-API-Passcode: $PASS")
  STATUS=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')
  [ "$STATUS" = running ] || break
  sleep 5
done

printf '%s' "$BODY" | python3 -m json.tool
curl -sS "$BASE/api/v1/audits/$ID/pdf" -H "X-API-Key: $KEY" -H "X-API-Passcode: $PASS" -o report.pdf
echo "Saved report.pdf"
```

---

## Operational notes

- **Runs live in memory.** A restart loses them: `GET /api/v1/audits/:id` will
  404 even though the files remain on disk under `web-reports/<id>/`. Fetch
  results and the PDF reasonably soon after a run, or persist them yourself.
- **`web-reports/` is never cleaned up.** Each run leaves an HTML, PDF, CSV and
  XLSX behind. Prune it on a schedule if you run audits regularly.
- **No rate limiting.** Each audit launches a headless browser per page, which is
  expensive. Put the API behind a gateway or reverse-proxy limit if it's exposed
  beyond your own systems.
- **Concurrency is unbounded.** Enough simultaneous requests will exhaust memory.
  Queue them upstream if that's a risk.
- **CSV and XLSX** are produced for every run and left in `web-reports/<id>/`,
  but are not currently exposed over the API. Ask if you want endpoints for them.
