API & CI checks

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 or to your own endpoint as signed webhooks.

Docs · Pro & AgencyLast reviewed Automated checks and general information, not legal advice.

Authentication

Create a token in Settings → 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 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. Your site’s own address scans that site; any other address (a preview deploy) runs a one-off scan. If that address is still being checked, you get the same check back.
GET /checks/{scanId}
Poll a check: status, grade, score, the number of blocking findings and each finding in short.
GET /sites
List your sites with their latest grade.
POST /sites
Add a site and start its first scan.
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}/prices
The 30-day price check: each watched product’s daily prices and whether its “was” price holds up.

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",
  "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
}

GitHub Actions

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 .blocking <<<"$result")" -gt 0 ]; then
            echo "::error::untick found critical or high issues: $(jq -r .reportUrl <<<"$result")"
            exit 1
          fi

Webhooks & Slack

In Settings → Slack & webhooks, add a Slack incoming webhook or your own HTTPS endpoint. Monitoring then sends an event when a scan finds new issues (new-issues) and when a site can’t be scanned twice in a row (site-failing). “Send test” delivers a test event. Your own endpoint 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 started loading."
    }
  ],
  "link": "https://app.untick.io/scan?id=scn_…"
}
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 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” in Settings → Slack & webhooks 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, if it’s publicly reachable. Previews behind a password or a VPN can’t be scanned; use a public preview address or scan production right after the deploy.

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. Fail the build on it, or read the findings list and choose your own threshold.

Can a token change my account?

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

Try a scan before you wire it into CI.

Find out in about a minute. Free, no signup.

No signup · nothing installed · results in about a minute