Curbcut

CI gate docs

The CI gate checks up to 50 URLs, usually of a pull request's preview deployment, and answers pass or fail against the site's last full scan. It is part of the Team and Agency plans of the web app; the web app docs cover sites and scans.

How a check passes

API tokens

Create a token under Settings → API tokens in the web app, with a label such as the repository name. It starts with cbt_ and is shown once; we store only its hash. Store it as a secret in your CI, for example a GitHub repository secret named CURBCUT_TOKEN. Revoke it in settings when you no longer need it. A token sees only its own workspace's sites and checks.

Start a check

POST https://app.saffo.dev/api/v1/checks with the token as a bearer token and a JSON body:

curl -sS -X POST https://app.saffo.dev/api/v1/checks \
  -H "authorization: Bearer $CURBCUT_TOKEN" \
  -H "content-type: application/json" \
  -d '{"site": "https://www.example.com/", "urls": ["https://preview-42.example.com/", "https://preview-42.example.com/pricing/"]}'

Answer, HTTP 202:

{
  "id": "3f6c2b8e-0a4d-4f1b-9c7e-5d2a1b0c9e8f",
  "status": "queued",
  "url": "https://app.saffo.dev/scans/3f6c2b8e-0a4d-4f1b-9c7e-5d2a1b0c9e8f",
  "poll": "https://app.saffo.dev/api/v1/checks/3f6c2b8e-0a4d-4f1b-9c7e-5d2a1b0c9e8f"
}

Errors come as { "error": "<reason>" } with these statuses:

Status Meaning
401 Missing, unknown or revoked token
403 The plan has no CI gate, or this month's checks are used up
404 No site of the workspace matches
422 Invalid body: no URLs, more than 50, several origins, or a preview host that does not serve the verification file

Get the result

GET https://app.saffo.dev/api/v1/checks/<id> with the same token. Poll every 10 seconds or so until status is done, failed or cancelled. pass is null until then, and false when the check failed or was cancelled.

curl -sS https://app.saffo.dev/api/v1/checks/3f6c2b8e-0a4d-4f1b-9c7e-5d2a1b0c9e8f \
  -H "authorization: Bearer $CURBCUT_TOKEN"
{
  "id": "3f6c2b8e-0a4d-4f1b-9c7e-5d2a1b0c9e8f",
  "status": "done",
  "pass": false,
  "url": "https://app.saffo.dev/scans/3f6c2b8e-0a4d-4f1b-9c7e-5d2a1b0c9e8f",
  "pages": 2,
  "totals": { "critical": 0, "serious": 1, "moderate": 3, "minor": 0 },
  "newIssues": [
    {
      "ruleId": "button-name",
      "impact": "serious",
      "page": "https://preview-42.example.com/pricing/",
      "selector": "#plans > button.toggle",
      "criteria": ["4.1.2"]
    }
  ],
  "pageErrors": []
}

newIssues lists at most 50 issues; the scan page at url has all of them. pageErrors lists pages that could not be checked, as { "page", "error" }. A failed check also has an error field.

Preview hosts

URLs on another origin than the site's (a preview deployment) are accepted only when that origin serves the site's verification token at /.well-known/curbcut-verification.txt, the same file as for site verification. Add the file to your build output so every preview has it, and pass site.

Quota per plan

Plan Checks per month URLs per check
Starter no CI gate -
Team 200 50
Agency 1,000 50

The count resets on the first day of each calendar month (UTC). A check counts when it is queued.

GitHub Actions

The Curbcut action is not published to a public repository yet. Until it is, use one of the two ways below; both need only bash, curl and jq, which GitHub-hosted runners have. Store the token as the repository secret CURBCUT_TOKEN first.

A step in your workflow

This workflow checks two pages of a pull request's preview. Change the URLs to your own preview host.

name: Accessibility
on: pull_request

jobs:
  curbcut:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Curbcut check
        env:
          CURBCUT_TOKEN: ${{ secrets.CURBCUT_TOKEN }}
          CURBCUT_SITE: https://www.example.com/
          CURBCUT_URLS: |
            https://preview-${{ github.event.number }}.example.com/
            https://preview-${{ github.event.number }}.example.com/pricing/
        run: |
          set -euo pipefail
          api=https://app.saffo.dev
          auth="$RUNNER_TEMP/curbcut-auth"
          # The token goes to curl through a file, so it never shows in the process list.
          (umask 077; printf 'authorization: Bearer %s\n' "$CURBCUT_TOKEN" > "$auth")
          body=$(jq -n --arg urls "$CURBCUT_URLS" --arg site "$CURBCUT_SITE" \
            '{urls: [$urls | splits("\\s+") | select(length > 0)]} + (if $site == "" then {} else {site: $site} end)')
          code=$(curl -sS -o "$RUNNER_TEMP/curbcut.json" -w '%{http_code}' -X POST "$api/api/v1/checks" \
            -H @"$auth" -H 'content-type: application/json' --data "$body")
          if [ "$code" != 202 ]; then
            echo "::error::Curbcut check not accepted (HTTP $code): $(jq -r '.error // empty' "$RUNNER_TEMP/curbcut.json")"
            exit 1
          fi
          poll=$(jq -r .poll "$RUNNER_TEMP/curbcut.json")
          result=$(jq -r .url "$RUNNER_TEMP/curbcut.json")
          echo "Curbcut check: $result"
          deadline=$(( $(date +%s) + 15 * 60 ))
          while :; do
            sleep 10
            curl -sS --fail -o "$RUNNER_TEMP/curbcut.json" -H @"$auth" "$poll"
            case "$(jq -r .status "$RUNNER_TEMP/curbcut.json")" in done|failed|cancelled) break ;; esac
            if [ "$(date +%s)" -ge "$deadline" ]; then echo "::error::Curbcut check timed out: $result"; exit 1; fi
          done
          jq -r '.newIssues[]? | "::error title=\(.ruleId) (\(.impact))::\(.page) \(.selector)"' "$RUNNER_TEMP/curbcut.json"
          jq -r '.pageErrors[]? | "::error title=Page not checked::\(.page): \(.error)"' "$RUNNER_TEMP/curbcut.json"
          if [ "$(jq -r '.pass' "$RUNNER_TEMP/curbcut.json")" != true ]; then
            echo "::error::Curbcut check failed: $result"
            exit 1
          fi
          echo "Curbcut check passed: $result"

A composite action in your repository

To reuse the check in several workflows, save this file as .github/actions/curbcut-check/action.yml in your repository. Its inputs and outputs are the ones the published action will have.

name: Curbcut check
description: Checks URLs with the Curbcut CI gate and fails on new critical or serious WCAG issues.
inputs:
  token:
    description: Curbcut API token (cbt_...). Pass it from a secret.
    required: true
  urls:
    description: URLs to check, one per line (1 to 50, one origin).
    required: true
  site:
    description: Site id or home URL; needed when the URLs are on a preview host.
    required: false
    default: ""
  api-url:
    description: Base URL of the web app.
    required: false
    default: https://app.saffo.dev
  timeout-minutes:
    description: How long to wait for the result.
    required: false
    default: "15"
outputs:
  pass:
    description: "true when the check passed, else false"
    value: ${{ steps.check.outputs.pass }}
  result-url:
    description: The check's page in the web app.
    value: ${{ steps.check.outputs.result-url }}
runs:
  using: composite
  steps:
    - id: check
      shell: bash
      env:
        CURBCUT_TOKEN: ${{ inputs.token }}
        CURBCUT_URLS: ${{ inputs.urls }}
        CURBCUT_SITE: ${{ inputs.site }}
        CURBCUT_API_URL: ${{ inputs.api-url }}
        CURBCUT_TIMEOUT_MINUTES: ${{ inputs.timeout-minutes }}
      run: |
        set -euo pipefail
        api="${CURBCUT_API_URL%/}"
        work=$(mktemp -d)
        trap 'rm -rf "$work"' EXIT
        (umask 077; printf 'authorization: Bearer %s\n' "$CURBCUT_TOKEN" > "$work/auth")
        body=$(jq -n --arg urls "$CURBCUT_URLS" --arg site "$CURBCUT_SITE" \
          '{urls: [$urls | splits("\\s+") | select(length > 0)]} + (if $site == "" then {} else {site: $site} end)')
        code=$(curl -sS -o "$work/resp" -w '%{http_code}' -X POST "$api/api/v1/checks" \
          -H @"$work/auth" -H 'content-type: application/json' --data "$body")
        if [ "$code" != 202 ]; then
          echo "::error::Curbcut check not accepted (HTTP $code): $(jq -r '.error // empty' "$work/resp")"
          echo "pass=false" >> "$GITHUB_OUTPUT"
          exit 1
        fi
        poll=$(jq -r .poll "$work/resp")
        result=$(jq -r .url "$work/resp")
        echo "result-url=$result" >> "$GITHUB_OUTPUT"
        echo "Curbcut check: $result"
        deadline=$(( $(date +%s) + CURBCUT_TIMEOUT_MINUTES * 60 ))
        while :; do
          sleep 10
          curl -sS --fail -o "$work/resp" -H @"$work/auth" "$poll"
          case "$(jq -r .status "$work/resp")" in done|failed|cancelled) break ;; esac
          if [ "$(date +%s)" -ge "$deadline" ]; then
            echo "::error::Curbcut check timed out: $result"
            echo "pass=false" >> "$GITHUB_OUTPUT"
            exit 1
          fi
        done
        jq -r '.newIssues[]? | "::error title=\(.ruleId) (\(.impact))::\(.page) \(.selector)"' "$work/resp"
        jq -r '.pageErrors[]? | "::error title=Page not checked::\(.page): \(.error)"' "$work/resp"
        pass=$(jq -r 'if .pass == true then "true" else "false" end' "$work/resp")
        echo "pass=$pass" >> "$GITHUB_OUTPUT"
        if [ "$pass" != true ]; then
          echo "::error::Curbcut check failed: $result"
          exit 1
        fi
        echo "Curbcut check passed: $result"

Use it in a workflow:

name: Accessibility
on: pull_request

jobs:
  curbcut:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Curbcut check
        id: curbcut
        uses: ./.github/actions/curbcut-check
        with:
          token: ${{ secrets.CURBCUT_TOKEN }}
          site: https://www.example.com/
          urls: |
            https://preview-${{ github.event.number }}.example.com/
            https://preview-${{ github.event.number }}.example.com/pricing/
      - name: Result
        if: always()
        run: echo "pass=${{ steps.curbcut.outputs.pass }} ${{ steps.curbcut.outputs.result-url }}"

The job fails when the check fails, fails to start or times out, and each new issue shows as an annotation on the run. Wait for your preview deployment to finish before this step, for example with needs: on the deploy job.

Limits

Automated checks find only part of all WCAG failures. A passing check means no new critical or serious issue was found on the URLs checked, not that the pages are accessible.