Skiplink

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.

Parameters for POST /v1/scan
ParameterTypeNotes
urlstringRequired. http or https, port 80 or 443.
standardstringDefault wcag22aa. See standards.
viewportobject{width, height}, each 200–4000. Default 1280×800.
wait_forstringCSS selector to wait for before scanning.
headersobjectUp to 20 custom request headers.
basic_authobject{username, password} for protected staging.

Standards

Supported standards
ValueRules included
wcag22aaDefault. WCAG 2.0, 2.1 and 2.2 at levels A and AA.
wcag21aaWCAG 2.0 and 2.1, levels A and AA.
wcag2aaWCAG 2.0, levels A and AA.
wcag2aWCAG 2.0, level A only.
section508Section 508 rule set.
allEverything 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/..."
      }'
Monitor parameters
ParameterNotes
urlRequired. Checked by the same guard as any scan.
intervalhourly, daily (default), weekly. Not cron — three choices cover what production monitoring actually needs.
standardDefault wcag22aa.
nameShown in the alert. Use the page's name, not the URL.
notify_webhookSigned POST. See below.
notify_slackA 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": "jo@northwind.example",
    "footer_note": "Confidential — prepared for Acme Corp."
  }'
Workspace fields
FieldNotes
nameRequired. Usually the client's name — this is how you find the workspace, not what appears on the report.
brand_nameYours, not the client's. It goes on the cover and in the footer.
brand_colorHex, e.g. #0B6B45. Anything that is not a hex colour falls back to the default rather than reaching the stylesheet.
logo_urlFetched once and inlined into the report. The renderer has no network, so a logo that 404s is simply absent — check the first report.
footer_noteFree text on every page footer. Confidentiality lines usually live here.
hide_attributionDefault 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

Result object fields
FieldNotes
idscn_…. Unguessable, not sequential.
final_urlWhere the redirect chain ended.
engineThe pinned axe-core and Chromium versions that produced this result. A finding you cannot attribute to a version is one you cannot reproduce.
score0–100. See how it is computed, and note it is never what fails your build.
summaryCounts by impact.
violations[]One entry per offending element, with rule, impact, WCAG criteria, selector, offending HTML, message, and both fingerprints.
duration_msHow long the scan itself took.
disclaimerShips 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" } }
Error codes
CodeStatusMeaning
invalid_request400Malformed body or an unknown field. Unknown fields are errors, not silently ignored — a typo like waitFor should not fail quietly.
blocked_url400The URL is not scannable. See what is refused.
unauthorized401Missing or invalid API key.
forbidden403Account suspended.
not_found404No such scan, or it belongs to another account.
quota_exceeded429Monthly plan quota reached.
rate_limited429Too many requests this minute. Respect Retry-After.
internal_error500Our 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

Rate limits and quotas
LimitValue
Requests per key120/minute
URLs per batch50
Scan timeout30 seconds
Page size25 MB
Custom headers20 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

CLI exit codes
CodeMeaning
0Gate passed.
1Gate failed — new violations, or violations at or above --fail-on.
2Usage error — bad flag, unreadable baseline.
3Could 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.