# ADAGuard — Full API Reference > Web Accessibility Scanner — WCAG 2.2, ADA & European Accessibility Act (EAA) Compliance Reporter ADAGuard scans websites for WCAG 2.2, ADA Title III, and European Accessibility Act (EAA) compliance issues and pre-fills VPAT/ACR reports from scan results, flagging criteria that require manual testing. ~78% automated WCAG coverage with support for authenticated (login-protected) page scanning. ## API Base URL Production: https://api.adaguard.io/v1 Interactive docs (Swagger UI): https://api.adaguard.io/v1/docs OpenAPI spec (machine-readable): https://api.adaguard.io/v1/openapi.json Summary reference: https://www.adaguard.io/llms.txt --- ## Authentication Include your API key in every request: X-API-Key: your_api_key_here API keys are available in your dashboard at https://app.adaguard.io/settings. API access requires the Professional plan ($129/mo) or higher. --- ## Endpoints --- ### POST /v1/scan — Start a Scan Returns immediately with a scan_id. The scan runs in the background. Poll GET /v1/scan/{scan_id}/status until status is 'completed' or 'failed'. **Idempotency-Key header (optional, recommended for CI/CD):** pass any client-generated unique string (e.g. a UUID). If a scan was already created for that key in the last 24h, this returns the *original* scan_id instead of starting a new one — safe to blindly retry a request that timed out or whose response you lost, without burning a second scan from your monthly quota. The response includes `"idempotent_replay": true` when this happens. **Request body (JSON):** { "url": "https://example.com", // required — public URL (http/https only) "scan_mode": "single", // single|crawl|sitemap|layout (default: single) "max_pages": 1, // 1-5000, capped to plan limit (ignored for single) "min_score": 80, // CI/CD gate: result includes passed:true/false "auth_target": "latest", // named target (from dashboard), or "latest"; omit for public scans "include_subdomains": true // false = restrict crawl to exact hostname only } **scan_mode values:** - single — scan only the provided URL (default, fastest, CI/CD-friendly) - crawl — follow internal links up to max_pages - sitemap — discover URLs from sitemap.xml first, then crawl - layout — crawl + deduplicate structurally similar pages **include_subdomains:** - true (default) — crawl also follows links to subdomains (e.g. api.example.com, blog.example.com) - false — restricts crawl to the exact hostname only; www.example.com is treated as same as example.com - Only relevant for crawl, sitemap, and layout modes; ignored for single **Response:** { "scan_id": "3f7a2b1c-...", "status": "queued", "url": "https://example.com", "created_at": "2025-01-15T10:30:00Z", "poll_url": "/v1/scan/3f7a2b1c-.../status" } **curl example:** curl -X POST https://api.adaguard.io/v1/scan \ -H "X-API-Key: your_key" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"url":"https://example.com","scan_mode":"single","min_score":80}' **Error — quota exceeded (429):** { "error": "Monthly scan limit reached", "scans_used": 30, "scans_limit": 30, "upgrade_url": "/billing" } **Error — max_pages exceeds plan limit (422):** { "error": "max_pages exceeds your plan limit", "requested": 750, "plan_limit": 500, "tier": "professional", "upgrade_url": "/billing" } --- ### GET /v1/scan/{scan_id}/status — Poll Scan Progress Call repeatedly until status is 'completed' or 'failed'. Use poll_again_in (seconds) as the suggested retry interval. **Response (in progress):** { "scan_id": "3f7a2b1c-...", "status": "in_progress", "url": "https://example.com", "score": null, "passed": null, "pages_scanned": 3, "max_pages": 10, "issues_critical": null, "issues_warning": null, "issues_info": null, "auth_status": "authenticated", "error": null, "poll_again_in": 3 } **Response (completed):** { "scan_id": "3f7a2b1c-...", "status": "completed", "url": "https://example.com", "score": 87, "passed": true, "pages_scanned": 10, "max_pages": 10, "issues_critical": 2, "issues_warning": 8, "issues_info": 5, "auth_status": "authenticated", "error": null, "poll_again_in": null } **auth_status values:** - not_requested — no auth session provided - authenticated — scan ran with stored session - expired_fallback — session expired; scan fell back to public pages only - public_only — site detected as public (no auth needed) **passed field:** - true — score >= min_score (only populated when min_score was set at creation) - false — score < min_score - null — min_score was not set **curl example:** curl https://api.adaguard.io/v1/scan/3f7a2b1c-.../status \ -H "X-API-Key: your_key" --- ### GET /v1/scan/{scan_id} — Full Scan Results Returns the complete scan document including all accessibility issues. **Response:** { "id": "3f7a2b1c-...", "url": "https://www.example.com", "canonical_url": "https://example.com", "status": "completed", "score": 87, "passed": true, "timestamp": "2025-01-15T10:32:14Z", "pages_scanned": 1, "elements_checked": 342, "issues_critical": 2, "issues_warning": 8, "issues_info": 5, "auth_status": "not_requested", "issues": [ { "severity": "critical", "type": "missing-alt-text", "description": "Images must have alternative text", "impact": "serious", "difficulty": "easy", "count": 2, "wcag": ["1.1.1"], "help_url": "https://dequeuniversity.com/rules/axe/4.10/image-alt", "elements": [ {"html": "", "selector": "main > img:first-child"} ] } ] } **canonical_url:** The normalized form of the URL used for website identity. Always HTTPS, no `www.`, no trailing slash. Useful for deduplicating scans of the same site entered in different forms (e.g. `http://www.example.com/` and `https://example.com` share a canonical URL). **Issue severity levels:** critical | warning | info **Issue difficulty levels:** easy | medium | hard --- ### GET /v1/scan/{scan_id}/report — Download Report Generates and returns a formatted report for a completed scan. **Query parameters:** - format — json (default) | html | csv | pdf **Format details:** - json — structured data, ideal for CI/CD parsing - html — self-contained HTML report (for tickets/emails) - csv — spreadsheet-friendly issue list for QA teams - pdf — professional PDF for compliance/executive sharing (watermarked on Free) **curl examples:** # JSON (default) curl "https://api.adaguard.io/v1/scan/3f7a2b1c-.../report" \ -H "X-API-Key: your_key" -o report.json # PDF curl "https://api.adaguard.io/v1/scan/3f7a2b1c-.../report?format=pdf" \ -H "X-API-Key: your_key" -o report.pdf **Error — scan not completed (409):** { "detail": "Scan is not yet completed. Poll /status until status=completed." } --- ### GET /v1/scans — List Scan History Returns scans sorted newest first. **Query parameters:** - limit — max results, 1-100 (default: 10) - offset — pagination offset (default: 0) - url — filter by URL prefix (e.g. ?url=https://example.com) **Response:** { "scans": [ { "id": "3f7a2b1c-...", "url": "https://example.com", "timestamp": "2025-01-15T10:32:14Z", "score": 87, "status": "completed", "pages_scanned": 1, "issues_critical": 2, "issues_warning": 8, "issues_info": 5, "passed": true, "auth_status": "not_requested" } ], "total": 42, "limit": 10, "offset": 0, "has_more": true } **curl example:** curl "https://api.adaguard.io/v1/scans?url=https://example.com&limit=5" \ -H "X-API-Key: your_key" --- ### GET /v1/scans/stats — Usage Statistics Returns aggregate statistics and monthly quota. Requires scope: stats:read **Response:** { "user_id": "usr_abc123", "total_scans": 127, "completed_scans": 124, "scans_used": 12, "scans_limit": 30, "average_score": 84.5, "total_issues": { "critical": 38, "warning": 142, "info": 267 } } --- ### GET /v1/auth-sessions — List Authenticated Sessions Lists stored browser auth sessions for scanning login-protected pages. Sessions are created in the dashboard: Settings → Authenticated Scans. **Response:** { "auth_sessions": [ { "session_id": "sess_xyz789", "domain": "example.com", "url": "https://example.com/login", "status": "active", "created_at": "2025-01-10T09:00:00Z", "expires_at": "2025-02-10T09:00:00Z", "last_used": "2025-01-14T15:22:00Z" } ], "total": 1, "note": "Use session_id as auth_session_id in POST /v1/scan." } **session status values:** - active — session is valid, will be used for scanning - expired — session expired; scans using it fall back to public pages **NOTE:** Sessions cannot be created via API. Use the dashboard. --- ### DELETE /v1/scan/{scan_id} — Delete a Scan Permanently deletes a scan and all stored data. Cannot be undone. Requires scope: scans:delete **Response:** { "success": true, "message": "Scan deleted successfully", "scan_id": "3f7a2b1c-..." } --- ## CI/CD Integration Use the `min_score` field to gate deployments on accessibility compliance. This example is deliberately defensive — GitHub Actions (and most CI shells) run with `bash -e`, which kills the step on the first non-zero exit, so a naive polling loop dies on its own transient errors: #!/usr/bin/env bash set -euo pipefail # 1. Start scan — capture HTTP status separately so a failed request doesn't # silently produce SCAN_ID=null and poll a nonexistent scan forever. # Idempotency-Key is derived from stable CI identifiers (not a fresh # uuidgen), so re-running this exact step after a network blip or a job # retry replays the original scan instead of burning a second one from # your monthly quota. RESP=$(curl -s -w '\n%{http_code}' -X POST https://api.adaguard.io/v1/scan \ -H "X-API-Key: $ADAGUARD_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: ${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_ATTEMPT:-1}" \ -d '{"url":"https://staging.example.com","min_score":80}') HTTP_CODE=$(echo "$RESP" | tail -n1) BODY=$(echo "$RESP" | sed '$d') if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then echo "Scan creation failed (HTTP $HTTP_CODE): $BODY" >&2 exit 1 fi SCAN_ID=$(echo "$BODY" | jq -r '.scan_id') if [ -z "$SCAN_ID" ] || [ "$SCAN_ID" = "null" ]; then echo "No scan_id in response: $BODY" >&2 exit 1 fi # 2. Poll until done. The completion check lives in the LOOP CONDITION, not # a `cond1 || cond2 && break` one-liner inside the loop body — that # pattern parses as (cond1 || cond2) && break, so on every in-progress # poll (both sides false) the last command actually executed is a # failing `[ ]` test, which `set -e` treats as the step failing. A # while-loop's own condition is exempt from `set -e`, so this form isn't. # Also capped at 40 attempts so a stuck deployment fails the job instead # of burning the runner's 6h default timeout. MAX_ATTEMPTS=40 ATTEMPT=0 STATE="queued" while [ "$STATE" != "completed" ] && [ "$STATE" != "failed" ]; do ATTEMPT=$((ATTEMPT + 1)) if [ "$ATTEMPT" -gt "$MAX_ATTEMPTS" ]; then echo "Timed out waiting for scan $SCAN_ID to finish" >&2 exit 1 fi RESP=$(curl -s -w '\n%{http_code}' "https://api.adaguard.io/v1/scan/$SCAN_ID/status" \ -H "X-API-Key: $ADAGUARD_KEY") HTTP_CODE=$(echo "$RESP" | tail -n1) BODY=$(echo "$RESP" | sed '$d') if [ "$HTTP_CODE" = "429" ]; then echo "Rate limited on attempt $ATTEMPT/$MAX_ATTEMPTS — backing off 30s" >&2 sleep 30 continue fi if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then echo "Status check failed (HTTP $HTTP_CODE): $BODY — retrying" >&2 sleep 5 continue fi STATE=$(echo "$BODY" | jq -r '.status') sleep "$(echo "$BODY" | jq -r '.poll_again_in // 3')" done # 3. Gate on passed field PASSED=$(echo "$BODY" | jq -r '.passed') SCORE=$(echo "$BODY" | jq -r '.score') echo "Score: $SCORE — Passed: $PASSED" [ "$PASSED" = "true" ] || exit 1 --- ## GitHub App — PR/CI Integration (separate from the API) The GitHub App (install from `/docs/github-app`) comments on pull requests with an accessibility score and posts a GitHub check run, triggered automatically by `pull_request` and `deployment_status` (preview-deploy-ready) events — no bash polling loop to write or maintain. **It is a genuinely separate system from the API described above, not a variant of it:** - **Metered differently.** The GitHub App is limited by *number of repos installed*, not by the monthly scan quota in Plan Limits below: Free 1 repo, Starter 2, Professional 5, Business/Enterprise unlimited. Installing the App and calling `POST /v1/scan` draw from two independent pools — running both against the same site does not double-charge either one, but it also means the two aren't unified into a single "scans used" number. - **Results aren't shared.** GitHub App scans are stored separately and are **not** retrievable via `GET /v1/scan/{id}` or `GET /v1/scans` — the PR comment and check run are the only outputs today. If you need results programmatically (e.g. to post to your own dashboard), use the API instead of, or alongside, the App. ## GitHub Action — CI Integration (`adaguard-io/accessibility-action@v1`) A workflow step, published at `https://github.com/marketplace/actions/adaguard-accessibility-scan` and documented at `/docs/github-action`. Unlike the App, it runs on any trigger you choose and exposes the result as step outputs. ```yaml - uses: adaguard-io/accessibility-action@v1 with: api-key: ${{ secrets.ADAGUARD_API_KEY }} url: https://example.com min-score: 80 ``` - **Uses the API's quota**, not the App's per-repo metering — it calls `POST /v1/scan` under the hood with your API key, so a Professional plan or above is required. - **Inputs:** `api-key`, `url` (required); `min-score` (80), `scan-mode` (single | crawl | sitemap | layout), `max-pages` (1), `include-subdomains` (true), `auth-target`, `fail-on-scan-error` (false), `comment-on-pr` (true), `max-wait-minutes` (30), `idempotency-key`, `api-base`. - **Outputs:** `score`, `passed`, `critical`, `warning`, `info`, `pages`, `scan-id`, `scan-status`, `auth-status`, `dashboard-url`, `report-url`. - **Results are retrievable**, unlike the App's: `scan-id` works with `GET /v1/scan/{id}`. **Which to use:** the GitHub App is the batteries-included path if you just want PR gating on GitHub with zero scripting — it auto-detects preview URLs from Vercel/ Netlify/Railway/Render deployments. Use the API directly for custom CI systems (non-GitHub), scans outside the PR lifecycle (scheduled/staging), or when you need scan results back in your own tooling. They're complementary, not a primary/fallback pair — installing one doesn't reduce what the other can do. --- ## Error Response Format Every response — success or error — carries an `X-Request-Id` header. Include it when contacting support; it's the fastest way to locate your request in our logs. Send your own `X-Request-Id` header on a request and we'll echo it back unchanged, so you can correlate it with your own retry/trace chain. Most errors use this shape: { "error": "Human-readable summary", "details": [{"field": "url", "message": "Only http and https URLs are allowed"}] } **Status codes:** - 401 — Missing or invalid API key (see Authentication Errors below) - 402 — Your plan doesn't include a requested feature (e.g. authenticated scanning) - 403 — Valid key, insufficient scope (see Authentication Errors below) - 422 — Validation error (invalid request body) - 429 — Rate limit or monthly scan quota exceeded — check the `X-RateLimit-Type` response header (`rate_limit` vs `quota`) to tell which one, since only the rate-limit case is worth retrying soon - 404 — Scan or session not found - 409 — Operation invalid for current scan state - 500 — Internal server error **Authentication & authorization errors** — three distinct failure modes that are easy to conflate; each has a different fix on the caller's side: // 401 — no X-API-Key header sent at all { "detail": "API key required. Include X-API-Key header." } // 401 — header present but the key is wrong, expired, or revoked { "detail": "Invalid or expired API key" } // 403 — key is valid, but lacks a scope this endpoint requires { "detail": "Insufficient permissions. Required scope: scans:write" } // 402 — key and scopes are fine; your plan doesn't include this feature { "detail": { "error": "Authenticated scanning requires a Professional plan or higher", "feature": "authenticated_scanning", "upgrade_url": "/billing" } } Note the 401/403 body is `{"detail": ""}` (FastAPI's default wrapping), not the canonical `{error, details[]}` envelope most other endpoints use — check `detail` on those two status codes specifically rather than `error`. --- ## Plan Limits Every response from an authenticated request carries rate-limit headers, so you don't have to guess your remaining budget from the table below: X-RateLimit-Limit: 100 # requests/hour for your plan X-RateLimit-Remaining: 87 # requests left in the current window X-RateLimit-Reset: 1738000000 # unix timestamp when the window resets GET /v1/scan/{id}/status polls don't count against this budget — they use a separate, much higher allowance so a CI job can poll for as long as a scan actually takes. Tier Price Scans/Mo Pages/Scan API Access Rate Limit Free $0/mo Unlimited* 1 only No — Starter $49/mo 4 50 No — Professional $129/mo 30 500 Yes 100 req/hr Business $249/mo 150 1,000 Yes 500 req/hr Enterprise Custom Custom Custom Yes Custom `max_pages` accepts 1–5000 on the request itself — Enterprise is the tier that can actually use values above Business's 1,000 cap; contact sales for your limits. * Free has no monthly scan cap at all — unlimited single-page rescans of your one (locked) website, subject only to a short per-URL cooldown (~45s) that guards against scripted abuse. Multi-page crawling requires Starter or above. Moot for this API reference either way, since Free has no API access. Scan quota resets on the 1st of each calendar month. Annual billing available at ~17% discount (2 months free). --- ## Changelog **2026-08-12** - Added `Idempotency-Key` header support to `POST /v1/scan` — safe retries within a 24h window, no duplicate scan or quota charge. - `GET /v1/scan/{id}/status` polling no longer shares its rate-limit budget with scan creation — a CI job can poll for as long as a scan actually takes. - Every response now carries `X-RateLimit-Limit/Remaining/Reset`, and 429s carry `X-RateLimit-Type` (`rate_limit` vs `quota`) so you can tell which one fired without parsing the error body. - Every response now carries `X-Request-Id` — include it when contacting support. - Documented: 401/402/403 response shapes, the GitHub App's relationship to API quota (they're metered independently), and corrected inconsistent plan-limit figures across this file and the docs site. **2026-08-02** - `passed` now fails closed when an authenticated scan's session expired mid-scan — previously could return `true` having only tested public pages. - Added `DELETE /v1/auth-sessions/{id}` to revoke a stored session directly. - Scan requests are now protected against DNS-rebinding SSRF; authenticated sessions are pinned to the domain they were captured for. --- ## Links - Website: https://www.adaguard.io - Dashboard: https://app.adaguard.io - API Reference: https://www.adaguard.io/llms.txt - Full Docs (this file):https://www.adaguard.io/llms-full.txt - Swagger UI: https://api.adaguard.io/v1/docs - OpenAPI Spec: https://api.adaguard.io/v1/openapi.json