API & CI checks

Docs · Pro & AgencyLast reviewed

Short answer

Create a token in Settings, then POST the address you just deployed to /checks and poll the result. Fail the build when “blocking” (critical and high findings) is above zero. Monitoring alerts can also go to Slack, Microsoft Teams, Discord or to your own endpoint as signed webhooks.

Authentication

Create a token in Settings → Integrations → API & CI checks (Pro and Agency). It’s shown once; we only keep a hash of it. Send it as a bearer token:

Header
Authorization: Bearer untk_…

The base address is https://app.untick.io/api/v1. Tokens can add sites, start scans and accessibility crawls, and read results (the calls below); every other call answers 401. Each token can make 120 requests a minute.

Endpoints

Endpoints available to API tokens
EndpointWhat it does
POST /checks
Start a scan of any public address, or a preview behind a basic login. Your site’s own address scans that site; any other address (a preview deploy) runs a one-off scan. Compares with a baseline scan so only new problems fail the check.
GET /checks/{scanId}
Poll a check: status, grade, score, whether it failed, every finding and the new ones in short.
GET /sites
List your sites with their latest grade.
POST /sites
Add a site and start its first scan. Optional changeAlerts (true or false, default false) switches on instant alerts for tracker changes.
GET /sites/{siteId}
One site with its newest 60 scans and a cursor to older ones.
GET /sites/{siteId}/scans?cursor=…
Older scans, newest first, 30 at a time (up to 100 with limit). Goes back as far as your plan’s history: 2 years on Pro, 5 on Agency.
POST /sites/{siteId}/scans
Scan a site again (joins the running scan if there is one).
GET /scans/{scanId}
The full report: findings with evidence, pages, Consent Mode signals.
GET /sites/{siteId}/accessibility-crawl
The accessibility crawl (automatic WCAG rules on many pages): the latest run, each page with its problems, problems grouped by rule, when the next crawl is due, and your plan’s planPages and everyDays (Free 25 pages every 30 days, Starter 250 pages every 30 days, Pro and Agency 500 pages every 7 days).
POST /sites/{siteId}/accessibility-crawl
Start a crawl now, once a day per site (joins the running crawl if there is one). Answers 202 with the same shape as the GET.
POST /sites/{siteId}/accessibility-crawl/deep-test
Test one page from the crawl in depth: keyboard, zoom, pop-ups, forms and AI review. Send {"url"} with a page the crawl found. Starter and up; uses one scan and answers 202 with a scanId to read at /scans/{scanId}.
GET /sites/{siteId}/changes?cursor=…
What changed on the site between scans: trackers, cookies and tools that appeared or went away, newest first. Narrow it with from, to (YYYY-MM-DD) and severity (critical, watch or good).
GET /changes?siteId=…
The same change list across all your sites, or one site with siteId.
GET /sites/{siteId}/inventory
Everything the latest scan saw on the site (trackers, cookies, tools and Google consent signals) by visitor choice, plus what has gone since earlier scans.
GET /sites/{siteId}/prices
The 30-day price check: each watched product’s daily prices and whether its “was” price holds up.
GET /sites/{siteId}/record?cursor=…
The site’s evidence record, newest check first, 30 at a time (up to 50 with limit). Narrow it with from and to (YYYY-MM-DD).
GET /sites/{siteId}/record/verify
Re-calculates every fingerprint in the record and says whether it is intact, or where the first mismatch is.
GET /sites/{siteId}/record/export?format=csv
The record as a download: format=csv (default) or format=json, with from and to to pick a date range.

Run a check

Startbash
curl -X POST https://app.untick.io/api/v1/checks \
  -H "Authorization: Bearer $UNTICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://preview-123.example.com/"}'

The answer comes straight away with a status address. A scan usually takes one to two minutes.

202 Acceptedjson
{
  "scanId": "scn_…",
  "siteId": null,
  "statusUrl": "https://app.untick.io/api/v1/checks/scn_…",
  "reportUrl": "https://app.untick.io/scan?id=scn_…"
}

Poll statusUrl every few seconds until status is done or failed:

GET /checks/{scanId}json
{
  "scanId": "scn_…",
  "siteId": null,
  "url": "https://preview-123.example.com/",
  "status": "done",
  "outcome": "failed",
  "coverageIssues": [],
  "grade": "C",
  "score": 61,
  "blocking": 1,
  "findings": [
    {
      "ruleId": "tracking-before-consent",
      "severity": "critical",
      "title": "Trackers fire before consent",
      "pageUrl": "https://preview-123.example.com/"
    },
    {
      "ruleId": "confirmshaming",
      "severity": "medium",
      "title": "Confirmshaming (guilt-trip opt-out wording)",
      "pageUrl": "https://preview-123.example.com/"
    }
  ],
  "reportUrl": "https://app.untick.io/scan?id=scn_…",
  "error": null,
  "failOn": "new",
  "failed": true,
  "baseline": { "scanId": "scn_…", "at": "2026-09-30T08:00:00.000Z" },
  "newFindings": [
    {
      "ruleId": "tracking-before-consent",
      "severity": "critical",
      "title": "Trackers fire before consent",
      "pageUrl": "https://preview-123.example.com/",
      "newDetails": ["Meta Pixel"]
    }
  ],
  "blockingNew": 1
}

Only fail on new problems

  • baseline: "site" compares with your site’s latest finished scan. "none" skips the comparison. It defaults to "site" when the address belongs to one of your sites, or when you pass siteId.
  • siteId: the site to compare with. Use it for previews (vercel.app, pages.dev, netlify.app), whose address differs from your site’s.
  • failOn: "new" (default with a baseline) sets failed when blockingNew is above zero. "any" sets it when blocking is above zero. Without a baseline both mean “any”.
  • auth: { "username", "password" } for a preview behind a basic login. It is used only for that exact address and only while the scan runs. We never save it, and no result shows it.
Preview with a baseline and a loginbash
curl -X POST https://app.untick.io/api/v1/checks \
  -H "Authorization: Bearer $UNTICK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://preview-123.example.vercel.app/",
    "siteId": "sit_…",
    "failOn": "new",
    "auth": { "username": "preview", "password": "…" }
  }'

outcome is "passed", "failed", "inconclusive" (no serious problems, but some checks couldn’t finish; coverageIssues says which) or "pending". failed is true only when serious problems trip the gate.

The result adds failed, failOn, baseline (the scan compared with, or null), newFindings and blockingNew. Login-protected previews run as one-off scans, so they don’t add a scan to your site’s history.

Evidence record

The evidence record is a dated history of every check on a site. Each entry carries the fingerprint (SHA-256) of the entry before it. Tokens can read it, check it and export it. It needs a paid plan, and tokens come with Pro and Agency.

List the recordbash
curl "https://app.untick.io/api/v1/sites/$SITE_ID/record?limit=2" \
  -H "Authorization: Bearer $UNTICK_TOKEN"
200 OKjson
{
  "entries": [
    {
      "seq": 12,
      "scanId": "scn_…",
      "at": "2026-10-09T08:00:12.000Z",
      "trigger": "monitor",
      "pages": ["https://acme.example/"],
      "grade": "B",
      "score": 82,
      "counts": { "critical": 0, "high": 1, "medium": 1, "low": 0, "info": 0 },
      "ruleIds": ["tracking-after-reject", "confirmshaming"],
      "resultSha256": "…",
      "prevHash": "…",
      "entryHash": "…",
      "changes": { "added": [], "gone": ["tracking-before-consent"] }
    }
  ],
  "next": "11",
  "summary": {
    "count": 12,
    "since": "2026-09-27T09:14:03.000Z",
    "headHash": "…",
    "prunedCount": 0,
    "prunedThrough": null
  },
  "timezone": "Europe/London"
}

The values above are placeholders that show the shape. Pass next back as cursor for the next page; it’s null on the last one. from and to are dates in your account’s time zone. changes lists the checks that appeared and went away since the entry before, and is null for the first entry we still hold.

GET /sites/{siteId}/record/verifyjson
{
  "check": {
    "intact": true,
    "count": 12,
    "since": "2026-09-27T09:14:03.000Z",
    "headHash": "…",
    "checkedAt": "2026-10-09T09:00:00.000Z",
    "resultsChecked": 12,
    "broken": null,
    "message": "Record intact: 12 checks since 27 Sept 2026."
  }
}

When something doesn’t match, intact is false and broken names the first entry (seq, scanId, at) and the reason: edited, missing, link or result. The JSON export explains how to recalculate a fingerprint yourself. Exports are logged in the team’s activity log.

Accessibility crawl

Besides the in-depth test of your key pages, each site gets a light accessibility crawl: the automatic WCAG 2.2 rules on many pages, found from your sitemap or by following links. It runs on your plan’s schedule (Free 25 pages every 30 days, Starter 250 pages every 30 days, Pro and Agency 500 pages every 7 days), and you can start one yourself once a day.

Start a crawlbash
curl -X POST https://app.untick.io/api/v1/sites/$SITE_ID/accessibility-crawl \
  -H "Authorization: Bearer $UNTICK_TOKEN"
202 Acceptedjson
{
  "run": {
    "id": "crl_…",
    "status": "running",
    "trigger": "manual",
    "source": "sitemap",
    "pagesLimit": 500,
    "pagesPlanned": 212,
    "pagesDone": 40,
    "pagesWithIssues": 9,
    "counts": { "critical": 1, "serious": 6, "moderate": 4, "minor": 0 },
    "error": null,
    "createdAt": "2026-10-25T08:00:00.000Z",
    "startedAt": "2026-10-25T08:00:02.000Z",
    "finishedAt": null
  },
  "pages": [
    {
      "url": "https://acme.example/checkout",
      "status": "ok",
      "reason": null,
      "counts": { "critical": 1, "serious": 2, "moderate": 0, "minor": 0 },
      "rules": [
        {
          "ruleId": "label",
          "title": "Form fields have labels",
          "impact": "critical",
          "elements": 2,
          "examples": ["#email"],
          "helpUrl": "…"
        }
      ],
      "checkedAt": "2026-10-25T08:01:10.000Z"
    }
  ],
  "rules": [
    {
      "ruleId": "label",
      "title": "Form fields have labels",
      "impact": "critical",
      "pages": 3,
      "elements": 5,
      "helpUrl": "…",
      "examplePages": ["https://acme.example/checkout"]
    }
  ],
  "nextCrawlAt": "2026-11-01T08:00:00.000Z",
  "canStartAt": "2026-10-26T08:00:00.000Z",
  "planPages": 500,
  "everyDays": 7
}

Poll GET /sites/{siteId}/accessibility-crawl until run.status is done or failed. A page’s status is ok, failed, skipped, wall (a bot check showed instead of the page) or queued, with reason saying why it wasn’t checked. To test one crawled page in depth (keyboard, zoom, pop-ups, forms and AI review), POST its address to /accessibility-crawl/deep-test and read the result at /scans/{scanId}.

GitHub Action

  • Use untickio/check: it starts the check, waits, and fails the job when failed is true. Set fail-on-incomplete: true to also fail when some checks couldn’t finish; by default that passes with a warning.
  • It adds a job summary and one pull request comment it keeps updated.
  • Inputs: api-token, url, site-id, fail-on, fail-on-incomplete, basic-auth-user, basic-auth-pass, comment, timeout-minutes.
.github/workflows/untick.ymlyaml
name: untick
on: pull_request

permissions:
  pull-requests: write

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: untickio/check@v1
        with:
          api-token: ${{ secrets.UNTICK_TOKEN }}
          url: https://my-preview.vercel.app
          site-id: sit_xxxxxxxx

GitHub Actions without the action

Save your token as the repository secret UNTICK_TOKEN. This workflow scans every successful deployment and fails when untick finds a critical or high issue. If your account already has 5 checks in progress, it waits 30 seconds and tries again, five attempts in all. For a fixed address, trigger it on push instead and set SITE_URL yourself.

.github/workflows/untick.ymlyaml
name: untick
on:
  deployment_status:

jobs:
  consent-check:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - name: Scan the deployed site with untick
        env:
          UNTICK_TOKEN: ${{ secrets.UNTICK_TOKEN }}
          SITE_URL: ${{ github.event.deployment_status.environment_url }}
        run: |
          set -euo pipefail
          api=https://app.untick.io/api/v1
          # 429: too many checks in progress on the account. Wait, then try again.
          for attempt in 1 2 3 4 5; do
            code=$(curl -sS -o start.json -w '%{http_code}' -X POST "$api/checks" \
              -H "Authorization: Bearer $UNTICK_TOKEN" \
              -H "Content-Type: application/json" \
              -d "$(jq -n --arg url "$SITE_URL" '{url: $url}')")
            if [ "$code" != 429 ] || [ "$attempt" -eq 5 ]; then break; fi
            echo "untick is busy (429), trying again in 30 seconds"
            sleep 30
          done
          if [ "$code" != 202 ]; then
            echo "::error::untick answered $code: $(jq -r '.error.message // empty' start.json)"
            exit 1
          fi
          status_url=$(jq -r .statusUrl start.json)
          for _ in $(seq 1 60); do
            result=$(curl --fail-with-body -sS "$status_url" \
              -H "Authorization: Bearer $UNTICK_TOKEN")
            status=$(jq -r .status <<<"$result")
            if [ "$status" = done ] || [ "$status" = failed ]; then break; fi
            sleep 5
          done
          jq '{status, grade, score, blocking, reportUrl}' <<<"$result"
          if [ "$status" != done ]; then
            echo "::error::The scan didn't finish: $(jq -r '.error // "timed out"' <<<"$result")"
            exit 1
          fi
          if [ "$(jq -r .failed <<<"$result")" = true ]; then
            echo "::error::untick found serious problems: $(jq -r .reportUrl <<<"$result")"
            exit 1
          fi
          # Some checks couldn't finish. Set FAIL_ON_INCOMPLETE=true to block the release on that too.
          if [ "$(jq -r .outcome <<<"$result")" = inconclusive ]; then
            echo "::warning::untick couldn't finish every check: $(jq -r '.coverageIssues[].reason' <<<"$result")"
            if [ "${FAIL_ON_INCOMPLETE:-false}" = true ]; then exit 1; fi
          fi

Alerts: Slack, Teams, Discord and webhooks

In Settings, open the alerts section (Slack, Teams, Discord and webhooks) and add a destination. Slack, Microsoft Teams and Discord take their own webhook addresses, and we send each one a message in the format it expects. A plain webhook takes any public HTTPS address. You can add up to 5 destinations. Monitoring then sends an event when a scan finds new issues (new-issues), when a change breaks consent on a site where you turned on change alerts (tracker-changes, with a change watch line for each change) and when a site can’t be scanned twice in a row (site-failing). A destination can also ask for the “worth a look” changes in tracker-changes, in Settings. “Send test” delivers a test event. A plain webhook gets each event as a JSON POST, and the untick-event header carries the same name as the body’s event:

untick-event: new-issuesjson
{
  "event": "new-issues",
  "summary": "Acme Store picked up 1 new issue (grade D)",
  "sentAt": "2026-09-27T08:00:00.000Z",
  "site": { "id": "sit_…", "name": "Acme Store", "host": "acme.example" },
  "scan": { "id": "scn_…", "grade": "D", "score": 52 },
  "issues": [
    {
      "ruleId": "tracking-before-consent",
      "title": "Trackers fire before consent",
      "severity": "critical",
      "detail": "Google Analytics, Meta Pixel were active before any consent. The first request fired 0.8s after the page loaded."
    }
  ],
  "link": "https://app.untick.io/scan?id=scn_…"
}
untick-event: tracker-changesjson
{
  "event": "tracker-changes",
  "summary": "Acme Store changed: 1 thing now breaks consent",
  "sentAt": "2026-09-27T08:00:00.000Z",
  "site": { "id": "sit_…", "name": "Acme Store", "host": "acme.example" },
  "scan": { "id": "scn_…" },
  "changes": [
    {
      "severity": "critical",
      "label": "Breaks consent",
      "name": "Meta Pixel",
      "text": "Meta Pixel was added on /checkout: it still loads after the visitor clicks Reject.",
      "pages": ["/checkout"]
    }
  ],
  "adImpact": null,
  "link": "https://app.untick.io/site?id=sit_…&tab=changes"
}
untick-event: site-failingjson
{
  "event": "site-failing",
  "summary": "We couldn’t scan Acme Store the last two times: The site took too long to load.",
  "sentAt": "2026-09-27T08:00:00.000Z",
  "site": { "id": "sit_…", "name": "Acme Store", "host": "acme.example" },
  "error": "The site took too long to load.",
  "link": "https://app.untick.io/site?id=sit_…"
}
untick-event: testjson
{
  "event": "test",
  "summary": "untick is connected. Monitoring alerts will arrive here.",
  "sentAt": "2026-09-27T08:00:00.000Z",
  "link": "https://app.untick.io"
}

Check the signature

Every plain webhook carries untick-event and untick-signature: t=…,v1=…. v1 is an HMAC-SHA256 of t + "." + body with the signing secret shown when you added the endpoint. Reject old timestamps to stop replays.

Lost the secret, or think it leaked? “Rotate secret” next to the webhook in Settings shows a new one once, and the old one stops signing straight away, so update your endpoint at the same time.

Node.jsjs
import { createHmac, timingSafeEqual } from 'node:crypto';

// raw: the request body exactly as received, before JSON parsing.
export function verifyUntick(raw, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${raw}`).digest();
  const received = Buffer.from(parts.v1 ?? '', 'hex');
  return received.length === expected.length && timingSafeEqual(received, expected);
}
Pythonpython
import hashlib, hmac, time

def verify_untick(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

We wait up to 8 seconds for an answer and don’t follow redirects. Any 2xx answer counts as delivered; the last result shows next to the endpoint in Settings.

Limits and errors

  • Up to 5 checks can be in progress per account. Starting a 6th answers 429 until one of them finishes.
  • Posting an address whose check is still running doesn’t start another. You get that check back instead: a 202 with the same scanId.

Errors come back as {"error": {"code", "message"}}:

  • 400 for an address we can’t scan.
  • 401 for a missing or revoked token, or a call tokens can’t make.
  • 402 (PLAN_LIMIT) when your plan doesn’t include the API (Free accounts get this from /checks) or this month’s scans are used up.
  • 404 for a check or site that isn’t yours.
  • 429 (RATE_LIMITED) when 5 checks are already in progress, or a token makes more than 120 requests a minute. Wait and try again, as the GitHub Actions example does.
429 Too Many Requestsjson
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "You already have 5 checks in progress. Wait for one to finish, then try again."
  }
}

Questions people ask

Can the scan reach a preview deploy?

Yes. Public previews work as they are. For a preview behind a basic login, send the login with the check: we use it only for that address, only for that scan, and never store it. Previews behind a VPN can’t be scanned.

Do CI checks use up my scans?

Yes, each check is one scan from your monthly allowance. If a check of the same address is still running, you get that check back instead of a new scan, so it isn’t counted twice.

What does “blocking” count?

The number of distinct critical and high findings. “blockingNew” counts the same among problems the baseline didn’t have. Use “failed” to let untick decide, or read the lists and choose your own threshold.

Can a token change my account?

Only by adding sites. Tokens can add sites, start scans and accessibility crawls, and read results, including the evidence record and what changed. They can’t delete anything or change billing, settings, alerts or other tokens: those need a signed-in session.