Skip to content

Scan API

The scanner,
without the browser.

Two calls: start a scan, then poll it until it is done. Works with no key at all, under the same five-scans-an-hour limit the front page runs on. A key on a paid plan raises every one of those numbers and opens the crawl.

Start a scan

Returns 201 with a scan id, immediately. The scan itself runs in the background, so this call does not wait for it.

curl -s -X POST https://scs-health.onrender.com/api/scans \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com"}'

{"id":"5f3c…","url":"https://example.com","status":"running","score":null, … }

Read the report

Poll until status is completed or failed. The site polls every two seconds; a scan usually finishes inside forty.

curl -s https://scs-health.onrender.com/api/scans/5f3c…

{
  "status": "completed",
  "score": 72,
  "unreachable": false,
  "scannerVersion": "2.1.0",
  "summary": { "checksRun": 71, "passed": 62, "failed": 9 },
  "cappedBy": "critical",
  "categories": [
    { "category": "security", "score": 40, "checksRun": 12, "passed": 9, "failed": 3,
      "findings": [
        { "severity": "critical", "title": "SSL certificate has expired",
          "detail": "Expired 2026-07-02", "remediation": { … } }
      ] }
  ],
  "skipped": [
    { "id": "performance-lcp", "category": "performance",
      "label": "Largest Contentful Paint", "reason": "Could not measure LCP" }
  ],
  "aiReadiness": { … },
  "crawl": { "status": "not_requested", "pagesScanned": null }
}

Worth knowing before you build on it

once

A report is delivered exactly once

The server erases a report the moment it answers with it, so the second GET for the same id returns 410 Gone. Keep the first response — this is the same deliver-once behaviour the browser gets, not an API restriction. Responses are sent no-store for the same reason.
null

No score is different from a low score

When the entered page never loaded, score is null and unreachable is true. Nothing about the site was measured, so treating a missing score as zero would be wrong — a site behind a bot wall is not a site that failed.
skipped

Not measured is not the same as passed

skipped lists the checks that ran but could not reach a verdict on this page, each with a reason — no PageSpeed data, no images to inspect, a cross-page check on a single-page scan. They count as neither passed nor failed and do not move the score.
version

Every report says which scanner produced it

scannerVersion names the scanner that measured the page — currently 2.1.0, with 77 checks. When a check is added, removed or changes what it decides, or the weights change, the version moves and the changelog says what changed, so a score stored today can still be read against the checks that produced it.
limits

Five scans an hour, per IP — or your plan's rate, per key

Every scan response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds since the epoch), so you never have to discover the limit by hitting it. Over the limit you get 429 with a Retry-After counting to when the next slot frees rather than to the end of the window. An unmetered plan sends none of them — a sentinel would invite arithmetic on a limit that does not exist. With a key the count is against the key rather than the address, which matters in CI: a runner’s IP belongs to a datacentre and would otherwise share one allowance with everything else on that egress. If the service has been idle the first call can take up to a minute to answer; later ones are immediate.

Authenticating with a key

Everything above works without one. A key changes three things: the limits become your plan’s rather than the free row’s, the hourly count is kept against the key rather than your IP address, and you can unlock the crawl without handing over an email, because the key already says who you are.

Create one on the API keys page of your account. It is generated in your browser, shown once and stored as a SHA-256 — we cannot show it to you again, and neither could anybody who reached our database. Treat it as a password: not in a public repo, not in a client-side bundle. Present it as a bearer token.

curl -s -X POST https://scs-health.onrender.com/api/scans   -H 'Authorization: Bearer scs_…'   -H 'Content-Type: application/json'   -d '{"url":"https://example.com"}'

GET /api/account answers with the client behind a key, its plan and its limits. It is the call to make first when a 429 is not the number you expected.

curl -s https://scs-health.onrender.com/api/account -H 'Authorization: Bearer scs_…'

{
  "plan": "pro",
  "client": { "name": "Acme", "plan": "pro", "engagementEndsAt": "2027-01-01T00:00:00.000Z" },
  "limits": {
    "crawlPages": 100, "scansPerHour": 60,
    "scanTimeoutMs": 600000, "crawlTimeoutMs": 600000,
    "monitoredSites": 3, "rescanCadence": "daily",
    "scoreDropAlerts": "email", "apiAccess": true, "keptReports": 100
  },
  "usage": {
    "scansPerHour": 60, "scansRemaining": 57,
    "resetsAt": "2026-09-22T13:04:11.000Z"
  }
}

What the API answers when something is wrong

401

The key did not resolve

Unknown, revoked or malformed — all three answer the same way, with WWW-Authenticate: Bearer. We do not say which, because “that key was revoked” is a fact about somebody else’s account. A bad key is never treated as no key. Silently metering a paying caller at five scans an hour instead of sixty is the least debuggable failure available, so it is an error instead.
403

The key is fine, the plan does not include the API

The body names the plan you are on. A key is not revoked when a plan lapses — API access is checked against the plan at request time, so an upgrade makes the same key work again rather than making you re-deploy your CI secrets.
429

Over the hourly rate

With Retry-After. The message names the limit that was actually applied, which is your plan’s number rather than the free one.
410

That report has already been delivered

Deliver-once, described above. The first read is the only read.

Crawling more than one page

The free scan covers the entered page. The crawl follows same-origin links — up to ten more pages on the free plan, a hundred on Pro, a thousand on Agency — and from a browser it is unlocked with an email address. From the API it is unlocked with your key, and the finished report is emailed to the address on your account.

Call it once the first scan has completed, then keep polling the same id.

curl -s -X POST https://scs-health.onrender.com/api/scans/5f3c…/unlock   -H 'Authorization: Bearer scs_…'   -H 'Content-Type: application/json' -d '{}'

Being told when a score drops

The paid plans re-scan the sites you watch and email you when a score falls or a new critical finding appears. On Agency the same alert also POSTs to an endpoint you choose — configured on the API keys & webhook page of your account, one endpoint for every site you watch.

The email goes out either way. The webhook is an addition, never a replacement, which is what makes a dead endpoint survivable: it costs you an integration and never the warning.

POST your endpoint
X-SCS-Event: score_drop
X-SCS-Timestamp: 1789000000
X-SCS-Signature: sha256=<hex>

{"event":"score_drop","hostname":"example.com",
 "url":"https://example.com/","previousScore":82,"score":64,
 "newCriticals":["security-ssl-validity"],
 "scannedAt":"2026-09-21T09:00:00.000Z"}

Verify before you trust the body. The signature is HMAC-SHA256 of <timestamp>.<body>, hex encoded, using the signing secret shown beside the endpoint. The timestamp is inside the signed material rather than beside it, which is what makes rejecting an old delivery worth doing — a replayed request cannot have its timestamp moved forward without breaking the signature.

We do not retry. A non-2xx is recorded against the endpoint and shown on your account page so you can tell a misconfigured receiver from an unreachable one; the alert itself already reached you by email. Redirects are not followed.

Using it in CI

There is an action. It scans a URL, puts the score and every critical finding on the run’s summary page, and fails the build when the score is below a threshold you choose.

- uses: fanjum/smart-capture-studio/action@main
  with:
    url: ${{ steps.deploy.outputs.preview-url }}
    min-score: 80
    fail-on-critical: true
    api-key: ${{ secrets.SCS_API_KEY }}

Two things worth knowing. Start without a minimum — the step then reports and never fails, and a first run that breaks everybody’s build teaches a team to delete the step rather than fix the site. And use a key: without one the scan is metered per IP, and a runner’s IP belongs to a datacentre shared with everything else on that egress.

An unreachable URL fails the step with that reason rather than scoring zero. A preview that has not finished booting has not been measured, and zero is a claim about a website.

The shape below is the same thing written by hand, if you would rather not add an action.

id=$(curl -s -X POST https://scs-health.onrender.com/api/scans \
  -H 'Content-Type: application/json' \
  -d "{\"url\":\"$SITE_URL\"}" | jq -r .id)

# poll until it settles
until [ "$(curl -s $BASE/api/scans/$id | jq -r .status)" != "running" ]; do sleep 2; done

# NOTE: the report is erased once read, so capture it in one call
report=$(curl -s https://scs-health.onrender.com/api/scans/$id)
echo "$report" | jq '.summary, .score'
test "$(echo "$report" | jq -r '.score // 0')" -ge 80

With a key the poll loop is the same, and the crawl step above slots in after it. Scheduled re-scans and alerting are on the paid plans already; an official action and a CLI are not, and are the next thing on the list. If you are wiring this into a pipeline, say so — that is the feedback that decides what gets built first.

Try it against your own site first.

The browser version runs the same scan and shows the same findings.

Run a scan