Docs
Scan a URL, get structured WCAG violations back as JSON, and fail your build on the ones you have not already accepted.
Quickstart
Scan a page and print the violations:
# Node 20+. No install step.
npx @skiplink/cli scan https://example.com
Or call the API directly:
curl -X POST https://api.skiplink.dev/v1/scan \
-H "Authorization: Bearer $SKIPLINK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "standard": "wcag22aa"}'
{
"id": "scn_9f2c1axkqp",
"url": "https://example.com",
"standard": "wcag22aa",
"status": "done",
"engine": { "axe_core": "4.12.1", "chromium": "151.0.7922.34" },
"score": 71,
"summary": { "critical": 2, "serious": 9, "moderate": 14, "minor": 3 },
"violations": [
{
"rule": "color-contrast",
"impact": "serious",
"wcag": ["1.4.3"],
"selector": ".hero__subtitle",
"html": "<p class=\"hero__subtitle\">Ship faster</p>",
"message": "Contrast ratio 3.03:1, needs 4.5:1",
"fingerprint": "f_887e25a5",
"fingerprint_loose": "f_002b8cf4"
}
]
}
The free tier is 500 scans a month and needs no card.
Add it to CI
GitHub Actions:
# .github/workflows/a11y.yml
name: Accessibility
on: pull_request
jobs:
a11y:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: skiplink/action@v1
with:
urls: https://staging.example.com
baseline: .skiplink/baseline.json
token: ${{ secrets.SKIPLINK_TOKEN }}
Already have a test suite? Check the pages your tests reach — behind a login, on localhost, mid-checkout — with @skiplink/playwright or @skiplink/cypress. Every route shares one baseline file. All four routes compared.
Any other CI system runs the CLI. It needs Node 20+ and nothing else.
npx @skiplink/cli scan https://staging.example.com \
--baseline .skiplink/baseline.json --fail-on new
Baselines
Point a scanner at a site that already exists and you get a wall of red — the average home page has 56 detectable violations. Nobody fixes 56 issues to unblock a deploy, so the check gets deleted. A baseline is a committed file listing violations you have seen and chosen not to block on; the gate then fails on what is new.
# Once, after reviewing what it finds:
npx @skiplink/cli scan https://staging.example.com \
--baseline .skiplink/baseline.json --update-baseline
git add .skiplink/baseline.json
# Every build after that:
npx @skiplink/cli scan https://staging.example.com --baseline .skiplink/baseline.json
How violations are matched
Each violation carries two fingerprints:
- exact — the rule plus the normalised selector, with bundler hashes stripped.
- loose — the rule plus the element's text content.
Matching tries exact first, then loose. That means a restyle that only renames classes does not reopen accepted violations; those matches are reported as drifted so you can refresh the baseline deliberately. A loose match is consumed once per baseline entry, so one accepted violation cannot absorb ten new ones sharing its text.
Entries key on URL path, not the full URL. Preview deployments get a new hostname on every pull request, and a baseline keyed on the whole URL would match nothing on exactly the runs it exists to serve.
Get a key
Create an account with an email and a password, confirm the six-digit code we email you, and the welcome flow walks you to a working CI gate. Keys are created and revoked in the dashboard.
A key is shown exactly once. We store only a hash, so it can be replaced but never recovered — which is also why revoking one takes effect on the very next request.
Authentication
Send your key as a bearer token. Keys look like skl_live_… and are shown once
at creation — only a hash is stored, so there is no recovery, only reissue.
Authorization: Bearer skl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The CLI reads SKIPLINK_TOKEN from the environment. Never commit a key; in CI
it belongs in a repository secret.
Manage keys
GET /v1/keys
Lists active keys with prefix, name, creation and last-use time. Never key material.
POST /v1/keys
Issues another key, up to 20 active per account, and returns the plaintext once.
Takes an optional name so you can tell them apart later.
DELETE /v1/keys/{id}
Revokes immediately — the next request with that key gets 401. You may revoke
the key you are currently using; locking yourself out is your call to make, and refusing
would mean an exposed key stays live while you find another one.
Scan a URL
POST /v1/scan
Blocks for up to 15 seconds waiting for the result. If the scan takes longer you get
202 with a poll_url — the scan is still running, not failed.
| Parameter | Type | Notes |
|---|---|---|
| url | string | Required. http or https, port 80 or 443. |
| standard | string | Default wcag22aa. See standards. |
| viewport | object | {width, height}, each 200–4000. Default 1280×800. |
| wait_for | string | CSS selector to wait for before scanning. |
| headers | object | Up to 20 custom request headers. |
| basic_auth | object | {username, password} for protected staging. |
Standards
| Value | Rules included |
|---|---|
| wcag22aa | Default. WCAG 2.0, 2.1 and 2.2 at levels A and AA. |
| wcag21aa | WCAG 2.0 and 2.1, levels A and AA. |
| wcag2aa | WCAG 2.0, levels A and AA. |
| wcag2a | WCAG 2.0, level A only. |
| section508 | Section 508 rule set. |
| all | Everything axe-core ships, including best-practice rules that are not WCAG success criteria. Failing a build on these is a choice, not a requirement. |
Scan a batch
POST /v1/scan/batch
Up to 50 URLs. Always asynchronous — returns 202 with a scan stub per URL.
Takes the same options as a single scan, applied to all of them.
curl -X POST https://api.skiplink.dev/v1/scan/batch \
-H "Authorization: Bearer $SKIPLINK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"urls": ["https://example.com/", "https://example.com/pricing"]}'
A batch is validated whole: if one URL is rejected, none are queued and no quota is consumed. You never have to reason about a partially accepted batch.
Fetch a scan
GET /v1/scans/{id}
Returns the result object. status is queued, running,
done or failed. Scans are scoped to the account that created
them; another account's id returns 404, not 403.
Account and usage
GET /v1/account
{
"account_id": "acct_r62e6vzsxs",
"plan": "growth",
"usage": {
"period": 202607,
"limit": 25000,
"used": 1834,
"remaining": 23166,
"billable_ledger": 1834,
"overage_allowed": true
}
}
used is the live counter; billable_ledger is the append-only
record invoices are built from. They should agree — when they disagree, the ledger is right.
Monitors
POST /v1/monitors
A monitor is the same gate as CI, pointed at production: it runs on a schedule, holds its own baseline, and notifies you only when a violation appears that it has not already told you about. A tool that alerts on every run is a feed people mute.
Growth plan and above. Each run counts as one scan.
curl -X POST https://api.skiplink.dev/v1/monitors \
-H "Authorization: Bearer $SKIPLINK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/checkout",
"name": "Checkout",
"interval": "daily",
"notify_slack": "https://hooks.slack.com/services/..."
}'
| Parameter | Notes |
|---|---|
| url | Required. Checked by the same guard as any scan. |
| interval | hourly, daily (default), weekly. Not cron — three choices cover what production monitoring actually needs. |
| standard | Default wcag22aa. |
| name | Shown in the alert. Use the page's name, not the URL. |
| notify_webhook | Signed POST. See below. |
| notify_slack | A Slack incoming-webhook URL. |
GET /v1/monitors · GET /v1/monitors/{id}
PATCH /v1/monitors/{id} — {"enabled": false} to pause.
DELETE /v1/monitors/{id}
Verifying the webhook signature
A webhook URL ends up in logs, chat messages and screenshots, so treat it as public and
check the signature instead. We send Skiplink-Signature: t=<unix>,v1=<hex>,
where the signed payload is the timestamp, a dot, and the raw body, HMAC-SHA256 under your
webhook secret.
// Node
const [, ts, sig] = header.match(/t=(\d+),v1=([a-f0-9]+)/);
const expected = crypto.createHmac('sha256', secret)
.update(ts + '.' + rawBody).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw ...;
Reject timestamps more than a few minutes old, or a captured request can be replayed indefinitely.
Why a monitor goes quiet
Ten consecutive delivery failures disable it. A monitor whose endpoint has been dead for
days is burning your scan quota to talk to nobody, so it stops and says so rather than
retrying into a void. Re-enable it with PATCH once the endpoint is back.
Client workspaces
POST /v1/workspaces — Agency plan
A workspace is one client: their branding, their scans, their reports. Nothing is shared between workspaces, so a report for one client can never carry another's name.
curl -X POST https://api.skiplink.dev/v1/workspaces \
-H "Authorization: Bearer $SKIPLINK_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corp",
"brand_name": "Northwind Digital",
"brand_color": "#0B6B45",
"contact": "[email protected]",
"footer_note": "Confidential — prepared for Acme Corp."
}'
| Field | Notes |
|---|---|
name | Required. Usually the client's name — this is how you find the workspace, not what appears on the report. |
brand_name | Yours, not the client's. It goes on the cover and in the footer. |
brand_color | Hex, e.g. #0B6B45. Anything that is not a hex colour falls back to the default rather than reaching the stylesheet. |
logo_url | Fetched once and inlined into the report. The renderer has no network, so a logo that 404s is simply absent — check the first report. |
footer_note | Free text on every page footer. Confidentiality lines usually live here. |
hide_attribution | Default false. Leave it and the footer carries one line saying testing was done with Skiplink. Set it and our name and link come off entirely. |
GET /v1/workspaces · GET /v1/workspaces/{id} · DELETE /v1/workspaces/{id}
Deleting a workspace removes the grouping and its branding. The scans stay — they are your usage history and your evidence of when a page was in what state.
White-label reports
POST /v1/reports — Agency plan
Point it at scans you have already run. Rendering needs a browser, so it returns
202 with a report in queued; poll until done, then
download the PDF. A report covers at most 100 pages.
curl -X POST https://api.skiplink.dev/v1/reports \
-H "Authorization: Bearer $SKIPLINK_KEY" \
-H "Content-Type: application/json" \
-d '{
"workspace": "wsp_mf3ffeuvxw",
"title": "Accessibility assessment",
"client": "Acme Corp",
"scan_ids": ["scn_9f2c1a", "scn_9f2c1b"]
}'
GET /v1/reports/{id} · GET /v1/reports/{id}.pdf
Branding is copied into the report when you create it, not read at download time. Re-fetching a report from three months ago gives you the document you sent, with the logo and colours you had then.
What is on every report, whatever you configure
The paragraph explaining that automated testing covers a subset of WCAG, and that a clean result is not a statement of conformance. It is not a template option and there is no flag that removes it. That paragraph is what makes the document a findings report rather than a compliance claim, and it protects you at least as much as it protects your client — the FTC's action against accessiBe was about exactly the claim it stops you making. Deque's attribution for axe-core stays too; that one is a licence condition, not our choice.
The result object
| Field | Notes |
|---|---|
| id | scn_…. Unguessable, not sequential. |
| final_url | Where the redirect chain ended. |
| engine | The pinned axe-core and Chromium versions that produced this result. A finding you cannot attribute to a version is one you cannot reproduce. |
| score | 0–100. See how it is computed, and note it is never what fails your build. |
| summary | Counts by impact. |
| violations[] | One entry per offending element, with rule, impact, WCAG criteria, selector, offending HTML, message, and both fingerprints. |
| duration_ms | How long the scan itself took. |
| disclaimer | Ships inside every completed result so it cannot be dropped by a dashboard or a report template. |
Errors
Errors come back with the HTTP status and a stable machine-readable code:
{ "error": { "code": "blocked_url", "message": "…", "param": "url" } }
| Code | Status | Meaning |
|---|---|---|
| invalid_request | 400 | Malformed body or an unknown field. Unknown fields are errors, not silently ignored — a typo like waitFor should not fail quietly. |
| blocked_url | 400 | The URL is not scannable. See what is refused. |
| unauthorized | 401 | Missing or invalid API key. |
| forbidden | 403 | Account suspended. |
| not_found | 404 | No such scan, or it belongs to another account. |
| quota_exceeded | 429 | Monthly plan quota reached. |
| rate_limited | 429 | Too many requests this minute. Respect Retry-After. |
| internal_error | 500 | Our fault. Quote the X-Request-Id header. |
URLs we refuse to scan
Scanning means fetching a URL from our infrastructure, so the target has to be somewhere a
stranger is allowed to make us go. We refuse anything that is not http or
https, anything with credentials in the URL, ports other than 80 and 443, and
any hostname resolving to loopback, private, link-local, carrier-grade NAT, or reserved
address space — including the cloud metadata endpoint, and including IPv6 transition
addresses that wrap a private IPv4 address. Redirect chains are re-checked at every hop.
Limits
| Limit | Value |
|---|---|
| Requests per key | 120/minute |
| URLs per batch | 50 |
| Scan timeout | 30 seconds |
| Page size | 25 MB |
| Custom headers | 20 per scan |
Quota headers come back on every scan request: X-Skiplink-Quota-Limit,
-Used and -Remaining.
CLI
skiplink scan <url...> [options]
--baseline <path> Baseline file. Violations in it do not fail the build.
--update-baseline Rewrite the baseline from this run and exit 0.
--fail-on <level> new (default) | critical | serious | moderate | minor | never
--standard <name> wcag22aa (default) | wcag21aa | wcag2aa | wcag2a | section508 | all
--format <name> human (default) | json | github
--comment-file <path> Write a markdown report for a PR comment.
--token <key> API key. Defaults to $SKIPLINK_TOKEN.
--wait-for <selector> Wait for this selector before scanning.
--timeout <seconds> Give up waiting for scans. Default 600.
--quiet Suppress progress output.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Gate passed. |
| 1 | Gate failed — new violations, or violations at or above --fail-on. |
| 2 | Usage error — bad flag, unreadable baseline. |
| 3 | Could not complete — API unreachable, key rejected, or no page could be scanned. |
1 and 3 are deliberately distinct. "This page has a new violation"
and "we could not find out" are different facts, and a pipeline should be free to retry one
and block on the other. A scan that could not run never reports as a pass.
What a passing scan means
Automated testing detects a subset of WCAG success criteria — published estimates range from roughly 13% to 57% depending on how you count. A pass means no automatically detectable violations were found. It does not mean the page conforms to WCAG, nor that it complies with the ADA, Section 508 or the European Accessibility Act. The long version.