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.
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 whole API on one screen
- Your first scan (no key needed)
- Reading a full scan — GET /v1/scan/:domain
- Triggering a fresh scan — POST /v1/scan
- Checking your own account — GET /v1/account
- Rate limits, honestly
- Error handling you should actually write
- The ten-minute CI/CD example
- Plan comparison at a glance
- Frequently asked questions
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.
| Endpoint | Purpose | API Key required |
|---|---|---|
GET /v1/scan/:domain | Read the most recent cached scan | No (grade + score only) / Yes (full results) |
POST /v1/scan | Trigger a fresh scan | Yes |
GET /v1/account | Check plan, quota and active keys | Yes |
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.
| Plan | Burst limit | Scan quota | Quota window |
|---|---|---|---|
| Public (no key) | 20 req/day per IP | Read only | — |
| Free | 5 req/min | 1/domain | 7 days |
| Solo | 15 req/min | 1/domain | 24 hours |
| Starter | 30 req/min | 1/domain | 24 hours |
| Pro | 60 req/min | 1/domain | 6 hours |
| Agency | 120 req/min | 1/domain | 1 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:
| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_domain | Domain is malformed, private or blocked |
| 400 | invalid_body | Request body is not valid JSON |
| 401 | invalid_key | Key not found or revoked |
| 401 | missing_key | Endpoint requires a key, none was provided |
| 404 | scan_not_found | No cached scan exists — use POST /v1/scan |
| 404 | not_found | Unknown API endpoint |
| 422 | scan_failed | Scanner could not reach the domain |
| 429 | rate_limit_exceeded | Per-minute burst limit hit |
| 429 | quota_exceeded | Per-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
| Plan | GET results | POST scan | Keys | Monthly |
|---|---|---|---|---|
| Public | Grade + score only | — | — | Free |
| Free | Top 3 issues | ✓ | 1 | Free |
| Solo | Full results | ✓ | 1 | $7 |
| Starter | Full results | ✓ | 1 | $19 |
| Pro | Full results | ✓ | 2 | $69 |
| Agency | Full results | ✓ | 5 | $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?
- Sign up at guardr.io — free, no card.
- Generate a key at Settings → API Access.
- Run the curl example at the top of this post.
- 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