The Guardr API: A Hands-On Tutorial with Real Examples

Automate website security scans with the Guardr REST API. Every endpoint covered, real JSON responses and a working GitHub Actions CI gate.

apitutorialci-cdsecuritysecurity-headers

TL;DR — The Guardr API is a small REST API that scans any website for security misconfigurations (headers, TLS, DNS, cookies, exposure paths, JS bundle secrets) and returns a structured JSON response. Three endpoints, one header for auth, free tier for automated use. This post walks through every endpoint with real requests and responses from the live API — nothing in here is pseudocode. By the end you’ll have a GitHub Actions job that gates builds on a security grade.


What’s covered


Why an API (and not just the dashboard)

The Guardr dashboard is where most people start — paste a domain, read a scan, fix what’s broken. That works fine for one or two sites you own.

It stops working the moment you have any of these:

  • A CI/CD pipeline that should fail when CSP or HSTS regresses
  • A list of 20+ client sites you audit weekly
  • A compliance review that wants JSON evidence, not screenshots
  • A monthly client report that should build itself

All four share the same shape — one HTTP call per domain, JSON in, structured response out, repeat on a schedule. That’s what the API is for.

The Guardr API launched in April 2026 specifically because the old SecurityHeaders.com API was retired and nobody wanted to hand-roll a replacement. This post is the hands-on tour I wish every API shipped with.

The whole API on one screen

Three endpoints. That’s it.

EndpointPurposeAPI Key required
GET /v1/scan/:domainRead the most recent cached scanNo (grade + score only) / Yes (full results)
POST /v1/scanTrigger a fresh scanYes
GET /v1/accountCheck plan, quota and active keysYes

Base URL: https://api.guardr.io

Auth: one header, X-API-Key: your_key_here. Get yours at Dashboard → Settings → API Access. Store it as an environment variable — never commit it, never ship it in client-side JavaScript.

Your first scan (no key needed)

Before you sign up for anything, you can hit the public read endpoint:

curl https://api.guardr.io/v1/scan/example.com

This returns the cached scan for a domain — grade and score only, no issue list, no remediation, rate-limited to 20 req/day per IP. It’s enough to answer “is this domain roughly okay?” from a shell or a read-only monitoring script. For anything real, you want a key.

Reading a full scan — GET /v1/scan/:domain

With a key on the header, the same GET returns the full cached result:

curl https://api.guardr.io/v1/scan/example.com \
  -H "X-API-Key: your_key_here"

Here’s the shape of a real Solo-plan response, trimmed for readability but with every field name exactly as the API returns it:

{
  "domain": "example.com",
  "scanned_at": "2026-04-18T10:00:00.000Z",
  "grade": "C",
  "score": 61,
  "categories": {
    "tls": 90,
    "headers": 40,
    "cookies": 100,
    "dns": 80,
    "exposure": 100
  },
  "issues": [
    {
      "title": "Missing: content-security-policy",
      "severity": "high",
      "category": "Security Headers",
      "description": "...",
      "remediation": {
        "summary": "...",
        "effort": "requires-planning",
        "snippets": [ ... ]
      }
    }
  ],
  "issues_truncated": false,
  "total_issues": 4,
  "secrets_found": []
}

The three fields that matter most for automation live at the top: grade (A+ through F), score (0–100) and categories (score per area). Everything else is detail you drill into when something regresses.

One important thing the GET endpoint does not do: it never triggers a new scan. If a domain has never been scanned, you get a 404 scan_not_found. For fresh data, use POST.

Free-tier truncation

On the Free plan, the same endpoint returns a deliberately smaller payload — the top three critical/high severity issues with full remediation, plus a flag telling you more exist:

{
  "domain": "example.com",
  "grade": "C",
  "score": 61,
  "categories": { ... },
  "issues_truncated": true,
  "total_issues": 9,
  "upgrade_url": "https://guardr.io/dashboard/billing"
}

If your code needs to handle both tiers gracefully, check issues_truncated before you iterate. The secrets_found, tls, dns, cookies and exposure_paths objects are omitted on Free until you upgrade.

Triggering a fresh scan — POST /v1/scan

The cached GET is fast (under 200ms) but can be up to an hour old. When you need a guaranteed-current result — CI run, post-deploy smoke test, incident investigation — use POST:

curl -X POST https://api.guardr.io/v1/scan \
  -H "X-API-Key: your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"domain": "example.com"}'

POST scans run inline and return in up to 2-10 seconds depending on the target site’s response times and redirect chain. The result is cached for an hour and becomes immediately readable via the GET endpoint.

Quota math you should know before writing a loop: scan quota is per-domain, not per-account. Scanning 10 different domains uses 10 independent slots, not 10× a single slot. The quota window matches your plan — daily on Free/Solo/Starter, every 6 hours on Pro, hourly on Agency.

Here’s the loop pattern for scanning an estate of sites:

domains=("example.com" "another.io" "mysite.com")

for domain in "${domains[@]}"; do
  curl -X POST https://api.guardr.io/v1/scan \
    -H "X-API-Key: your_key_here" \
    -H "Content-Type: application/json" \
    -d "{\"domain\": \"$domain\"}"
done

If you’re auditing 50 client sites daily, trigger one POST per domain overnight, then use the much faster (and quota-free) GET endpoint for all subsequent reads during the day.

Checking your own account — GET /v1/account

Useful for debugging (“did my key get revoked?”) and for surfacing plan info in your own dashboard:

curl https://api.guardr.io/v1/account \
  -H "X-API-Key: your_key_here"

Returns your plan, quota configuration and a redacted list of active keys:

{
  "plan": "starter",
  "quota": {
    "scan_window": 86400,
    "burst_per_minute": 30
  },
  "keys": [
    {
      "key_id": "...",
      "display": "••••••••••••••••f630",
      "label": "CI pipeline",
      "created_at": "18.04.2026",
      "last_used_at": "18.04.2026",
      "revoked": false
    }
  ]
}

The display field is the last four characters of the key with the rest masked — safe to log, safe to show in a UI.

Rate limits, honestly

Two independent limits apply to every request: a per-minute burst limit and a per-domain scan quota.

PlanBurst limitScan quotaQuota window
Public (no key)20 req/day per IPRead only
Free5 req/min1/domain7 days
Solo15 req/min1/domain24 hours
Starter30 req/min1/domain24 hours
Pro60 req/min1/domain6 hours
Agency120 req/min1/domain1 hour

Every response carries standards-compliant rate-limit headers:

X-RateLimit-Limit:     60
X-RateLimit-Remaining: 0
X-RateLimit-Reset:     1713441600
Retry-After:           47

When you go over, you get 429 Too Many Requests with a machine-readable error field. There are two distinct 429 codes — rate_limit_exceeded (you hit the per-minute burst limit) and quota_exceeded (you used your per-domain scan slot for the window). Both ship Retry-After in seconds. Respect it; don’t hammer.

Error handling you should actually write

All errors return JSON with an error code and a human-readable message. Nine codes you should handle:

HTTPerrorMeaning
400invalid_domainDomain is malformed, private or blocked
400invalid_bodyRequest body is not valid JSON
401invalid_keyKey not found or revoked
401missing_keyEndpoint requires a key, none was provided
404scan_not_foundNo cached scan exists — use POST /v1/scan
404not_foundUnknown API endpoint
422scan_failedScanner could not reach the domain
429rate_limit_exceededPer-minute burst limit hit
429quota_exceededPer-domain scan quota hit for this window

In practice, the four you’ll see in production are invalid_key (rotate the secret), scan_not_found (fall back to POST), rate_limit_exceeded (back off on Retry-After) and quota_exceeded (you scheduled the job too aggressively — the signal is “use GET for reads, POST only when you need fresh data”). Transient 5xx errors do happen; retry with exponential backoff.

The ten-minute CI/CD example

Here’s the single most common pattern: a nightly GitHub Actions workflow that hits the API, parses the grade and fails the build if it regresses.

# .github/workflows/security-scan.yml
name: Security Scan

on:
  schedule:
    - cron: '0 4 * * *'   # daily at 04:00 UTC
  workflow_dispatch:

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - name: Scan site with Guardr
        env:
          GUARDR_API_KEY: ${{ secrets.GUARDR_API_KEY }}
        run: |
          response=$(curl -sf \
            -H "X-API-Key: $GUARDR_API_KEY" \
            "https://api.guardr.io/v1/scan/example.com")

          grade=$(echo "$response" | jq -r '.grade')
          score=$(echo "$response" | jq -r '.score')
          headers_score=$(echo "$response" | jq -r '.categories.headers')

          echo "Grade: $grade | Score: $score | Headers: $headers_score"

          if [[ "$grade" == "D" || "$grade" == "F" \
                || "$grade" == "C" || "$grade" == "C-" ]]; then
            echo "::error::Security grade regressed to $grade"
            echo "$response" | jq '.issues[] | select(.severity == "critical" or .severity == "high")'
            exit 1
          fi

          if (( headers_score < 70 )); then
            echo "::error::Headers score dropped to $headers_score"
            exit 1
          fi

Add GUARDR_API_KEY to your repo secrets, generate the key in Settings → API Access, commit the workflow — that’s the whole integration. Swap the curl to a POST against /v1/scan if you want to trigger a fresh scan per run instead of reading from cache; just watch your per-domain quota.

Plan comparison at a glance

PlanGET resultsPOST scanKeysMonthly
PublicGrade + score onlyFree
FreeTop 3 issues1Free
SoloFull results1$7
StarterFull results1$19
ProFull results2$69
AgencyFull results5$179

If you’re automating CI gates on a single production site, Free or Solo is fine. If you’re auditing client sites at an agency, Pro or Agency unlocks the faster scan windows and extra keys for separating dev/prod/CI traffic.

Frequently asked questions

Do I need a credit card to try the API?

No. Sign up free at guardr.io, grab a key, call POST. The Free tier includes one API key with scan access — you only need a paid plan for full TLS/DNS/cookie/exposure data in responses, tighter scan windows or higher burst limits.

How fresh is the GET endpoint’s data?

Up to one hour old. Every POST scan caches its result for 60 minutes. If you need guaranteed-current data, POST; if you’re doing high-volume reads, GET is faster and doesn’t count against your per-domain scan quota.

What counts as a “domain” for quota purposes?

The bare hostname. example.com and www.example.com are two different quota slots. Subdomains likewise — api.example.com is its own slot. Scans are run against exactly what you passed in.

How do I handle the free-tier truncation in my code?

Check issues_truncated. If it’s true, your iteration over issues is going to miss low/medium severity items. For most CI gates this is fine because you’re gating on grade and critical/high issues anyway — but if you’re building a dashboard, either upgrade to Solo or show a “truncated” badge in your UI.

Can I get raw HTTP headers in the response?

No. Guardr returns structured findings (“Missing: content-security-policy”, with severity and remediation) rather than the raw header dump the old SecurityHeaders.com API used to return. If you specifically need raw headers, a separate HEAD request against the target is a better tool for the job. The reasoning: the whole point of the API is to give you actionable output, and a raw header dump moves the work of interpreting it back onto the caller.

Is there a history or compare endpoint?

Not in v1. History, before/after comparison and PDF-via-API all exist in the dashboard today — exposing them as endpoints is a question of when enough users ask, not whether. If any of those is blocking your integration, email me directly.

What languages are supported?

Whatever can make an HTTP request. The API is plain REST with JSON — Node, Python, Go, Rust, Ruby, bash, all fine. I haven’t published SDKs because a three-endpoint API with one auth header doesn’t really need one. If you’d find one useful, tell me which language.

How does this compare to SecurityHeaders.com’s old API?

There’s a full migration post covering the side-by-side. Short version: same shape of GET request, same style of auth header, but the response is issue-centric rather than header-centric, and Guardr adds TLS parsing, DNS security checks, exposure path detection and JS bundle secret scanning that the old API didn’t have.


Ready to try it?

  1. Sign up at guardr.io — free, no card.
  2. Generate a key at Settings → API Access.
  3. Run the curl example at the top of this post.
  4. If it returns JSON, you’re done.

Full reference docs are at guardr.io/docs/api. If something in the API surprises you — unclear field, confusing error, missing endpoint — that’s a bug I want to fix. Email me.

— Anatoli


Check your website's security score

Free scan — no signup required.

Scan your site →