API Reference

PerSeo Insights REST API v1.0

HTTPS only JSON v1.0.0

Base URL

https://insights.perseodesign.com/api/v1

The PerSeo Insights REST API lets you start SEO scans, read results, export reports and manage access tokens programmatically. Ideal for CI/CD, automated monitoring and third-party tool integrations. v1.0.0 adds readability scores, keyword analysis, bulk scan (up to 50 URLs), webhook callbacks and HTTPS/HTTP auto-fallback.

To create your first API key, go to the Dashboard, section API & Token.

Authentication

All endpoints require a Bearer token in the HTTP header:

Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

API Key (permanent)

Long-lived tokens created from the Dashboard. They start with sk_live_. Use them for scripts, automations and server-side integrations.

JWT (session 24h)

Short-lived tokens (24h) issued by POST /auth/login. Use them for interactive clients requiring explicit login.

Available scopes

ScopeDescription
readRead access: profile, history, token list
scanStart new scans (POST /scans)
exportExport results (GET /scans/{id}/export/{format})

Rate limits

API limits mirror the website limits. Reset every 24h (sliding window).

Tier Single Scan/day Sitemap Scan/day Crawl/day Scheduled Reports
Guest (unauthenticated)11--
Free (registered)5511
Pro50101010

For more details on plans and features, visit the Pricing page.

Response format

All responses follow the standard JSON format:

Success

{ "success": true, "data": { ... }, "meta": { "timestamp": "2026-02-25T10:30:00Z", "version": "1.0", "request_id": "550e8400-..." } }

Error

{ "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Scan limit reached (5/5).", "retry_after": 14400 }, "meta": { ... } }

Auth

POST /api/v1/auth/login No auth

Authenticate with email/password and receive a JWT access token (24h).

# Request curl -s -X POST https://insights.perseodesign.com/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"[email protected]","password":"password123"}'
Response 200
{ "success": true, "data": { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_in": 86400, "token_type": "Bearer", "user": { "id": 42, "username": "mario.rossi", "email": "[email protected]" } } }
GET /api/v1/auth/verify JWT Bearer

Verifies the validity of a JWT token.

API tokens

GET /api/v1/tokens scope: read

Lists all API keys for the authenticated user.

curl https://insights.perseodesign.com/api/v1/tokens \ -H "Authorization: Bearer sk_live_xxxx"
POST /api/v1/tokens scope: read

Creates a new API key. The token is shown only once.

FieldTypeReq.Description
namestringSIToken label (max 100 chars)
scopesarrayNODefault ["read"]. Values: read, scan, export
curl -X POST https://insights.perseodesign.com/api/v1/tokens \ -H "Authorization: Bearer sk_live_xxxx" \ -H "Content-Type: application/json" \ -d '{"name":"Script CI","scopes":["read","scan","export"]}'
IMPORTANT: The token field in the response is shown only once. Save it immediately in a secure place.
DELETE /api/v1/tokens/{id} scope: read

Permanently revokes (disables) an API key.

Scans

POST /api/v1/scans scope: scan

Starts an asynchronous SEO scan. Returns 202 Accepted and a scan_id to use for polling.

FieldTypeReq.Description
urlstringSIURL or bare domain to analyze (e.g. example.com)
modestringNOsingle (default) or sitemap
bypass_cacheboolNOForce fresh analysis, ignoring the 60-min cache (default false)
webhook_urlstringNOHTTPS URL to POST when scan completes (JSON body with scan_id, status, result)
curl -X POST https://insights.perseodesign.com/api/v1/scans \ -H "Authorization: Bearer sk_live_xxxx" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com","mode":"single"}'
Response 202 Accepted
{ "success": true, "data": { "scan_id": "a7f3d2e1-8b4c-4f9a-b6d5-c3e2f1a0b9d8", "status": "queued", "mode": "single", "url": "https://example.com", "poll_url": "/api/v1/scans/a7f3d2e1-..." } }
GET /api/v1/scans/{scan_id} scope: read

Retrieves status and results of a scan. Poll every 2-3 seconds until status != "running".

running

Running

complete

Completed

error

Failed

stopped

Stopped

# Bash polling loop while true; do RESULT=$(curl -s \ -H "Authorization: Bearer $TOKEN" \ "https://insights.perseodesign.com/api/v1/scans/$SCAN_ID") STATUS=$(echo $RESULT | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['status'])") [ "$STATUS" != "running" ] && break sleep 3 done

AI Overview

Retrieve the AI Overview (SGE readiness) analysis for a stored single-URL scan. The score measures how well a page is optimized to appear in Google AI Overviews, based on structured data, content depth, heading format, and more.

GET /api/v1/scans/{scan_id}/ai-overview scope: read

Returns AI Overview score, grade (A-F), per-check breakdown, and structured signals for the given scan. Only available for single-URL scans. If the scan predates the feature, re-scan to generate the data.

Path parameters

scan_id Numeric scan ID from /api/v1/history or the scan_id_db field returned after a completed scan.

Response fields

FieldDescription
ai_overview.score0-100 readiness score
ai_overview.gradeLetter grade A+ to F
ai_overview.checksArray of checks: label, status (pass/fail/partial), score, max_score, detail
ai_overview.signalsBoolean flags: has_faq_schema, has_speakable, has_howto, has_article_schema, question_heading_count
curl -s \ -H "Authorization: Bearer $TOKEN" \ "https://insights.perseodesign.com/api/v1/scans/123/ai-overview"

Example response

{ "success": true, "data": { "scan_id": 123, "url": "https://example.com", "scanned_at": "2026-05-07T08:30:52", "ai_overview": { "score": 24, "grade": "F", "checks": [ { "label": "FAQ Schema", "label_it": "Schema FAQ", "status": "fail", "score": 0, "max_score": 20, "detail": "No FAQPage schema found.", "detail_it": "Nessuno schema FAQPage trovato." } ], "signals": { "has_faq_schema": false, "has_speakable": false, "has_howto": false, "has_article_schema": false, "question_heading_count": 0, "question_headings": [] } } } }

Error codes

404 NOT_FOUNDScan not found or belongs to another user
422 NO_DATAScan predates AI Overview feature - re-scan to generate data

Security

Retrieve the complete security analysis for a stored scan: HTTP headers score, passive domain checks (DNS/WHOIS/TLS/WAF/CVE/VirusTotal/Shodan) and the combined score weighting both layers. Available for single-URL scans run with v9.6.0+.

GET /api/v1/scans/{scan_id}/security scope: read

Returns security headers score, passive domain checks, and combined security score. Use scan_id_db returned after scan completion, or fetch it from /api/v1/history.

Path parameters

scan_id Numeric scan ID from /api/v1/history or the scan_id_db field returned after a completed scan.

Response fields

Field Description
security.score_combined0-100 combined score: headers score minus passive penalties (no SPF -5, no DMARC -10, legacy TLS -10, critical CVE -10, VT malicious -15, expiry <30d -10)
security.headers.scoreHTTP security headers score (0-100): CSP, HSTS, X-Frame-Options, X-Content-Type, Referrer-Policy, Permissions-Policy
security.headers.gradeLetter grade A-F
security.headers.missingList of missing or misconfigured security headers
security.passive.emailSPF (bool + raw value), DMARC (bool + policy: none/quarantine/reject), DKIM (bool), MX presence
security.passive.domainRegistrar, expiry date (YYYY-MM-DD), days until expiration (WHOIS)
security.passive.tlsNegotiated TLS version, legacy TLS 1.0/1.1 availability, self-signed cert flag
security.passive.wafWAF detection: detected (bool), name (Cloudflare / CloudFront / Sucuri / Imperva / Akamai / Fastly / Varnish / Barracuda)
security.passive.cvesCVE list from NVD based on detected CMS/PHP version. Each item: id, severity (CRITICAL/HIGH/MEDIUM/LOW), description, url
security.passive.virustotalDomain reputation: malicious, suspicious, harmless engine counts + overall reputation score
security.passive.shodanOpen ports list and organization (Shodan)
security.passive.subdomainsSubdomains enumerated from CT logs (crt.sh): count (int) + list (array)
security.passive.findingsAggregated findings. Each item: message (str), severity (critical/high/medium/low/info), source (dns/tls/waf/cve/vt/shodan)
curl -s \ -H "Authorization: Bearer $TOKEN" \ "https://insights.perseodesign.com/api/v1/scans/123/security"

Example response

{ "success": true, "data": { "scan_id": 123, "url": "https://example.com", "scanned_at": "2026-06-27T10:00:00", "security": { "score_combined": 72, "headers": { "score": 80, "grade": "B", "https": true, "hsts": true, "missing": ["Content-Security-Policy"] }, "passive": { "email": { "spf": true, "dmarc": true, "dmarc_policy": "quarantine", "dkim": true, "mx_present": true }, "domain": { "registrar": "Namecheap", "expiry": "2027-03-15", "days_left": 261 }, "tls": { "version": "TLSv1.3", "tls_10_enabled": false, "tls_11_enabled": false, "cert_self_signed": false }, "waf": { "detected": true, "name": "Cloudflare" }, "cves": [], "virustotal": { "malicious": 0, "suspicious": 0, "harmless": 67, "reputation": 0 }, "shodan": { "ports": [80, 443], "org": "Cloudflare Inc" }, "subdomains": { "count": 4, "list": ["www.example.com", "mail.example.com", "blog.example.com"] }, "findings": [ { "message": "DMARC policy is quarantine, consider upgrading to reject", "severity": "medium", "source": "dns" } ] } } } }

Error codes

404 NOT_FOUNDScan not found or belongs to another user
422 NO_DATAScan predates passive security (v9.6.0+) - re-scan to generate data

Authority

Retrieve authority signals and E-E-A-T data for a stored scan, plus live Common Crawl web presence. Combines on-page data (domain WHOIS, Schema.org, Open Graph, trust signals) with a real-time Common Crawl CDX query.

GET /api/v1/scans/{scan_id}/authority scope: read

Returns domain info (WHOIS), trust signals, Schema.org types and Common Crawl presence. Use scan_id_db returned after scan completion, or fetch it from /api/v1/history. Common Crawl data is fetched live at request time.

Path parameters

scan_id Numeric scan ID from /api/v1/history or the scan_id_db field returned after a completed scan.

Response fields

Field Description
domain.age_daysDomain age in days (WHOIS). Google uses domain age as a ranking signal.
domain.expiration_dateDomain expiration date (YYYY-MM-DD). Warning if under 6 months.
domain.registrarRegistrar name from WHOIS.
trust_signals.httpsHTTPS active (bool).
trust_signals.open_graphOpen Graph tags present (bool).
trust_signals.schema_markupStructured data (Schema.org) present (bool).
trust_signals.schema_typesArray of detected Schema.org types (e.g. Article, Organization, FAQ).
trust_signals.analyticsGoogle Analytics or GTM present (bool).
trust_signals.llms_txtllms.txt file present at domain root (bool).
trust_signals.security_txtsecurity.txt (RFC 9116) present (bool).
trust_signals.word_countBody word count. Google favours pages with sufficient content depth.
cc_presence.successTrue if Common Crawl CDX query succeeded.
cc_presence.indexed_pages_totalTotal pages found across the last 5 Common Crawl campaigns.
cc_presence.indexed_pages_latestPages found in the most recent crawl.
cc_presence.crawl_trendTrend across crawls: growing, stable, declining or unknown.
cc_presence.last_crawledDate of the most recent crawl that found the domain (YYYY-MM-DD).
curl -s \ -H "Authorization: Bearer $TOKEN" \ "https://insights.perseodesign.com/api/v1/scans/123/authority"

Example response

{ "success": true, "data": { "scan_id": 123, "url": "https://example.com", "scanned_at": "2026-06-29T10:00:00", "domain": { "name": "example.com", "age_days": 4021, "age_years": 11.0, "creation_date": "2015-03-14", "expiration_date": "2027-03-14", "registrar": "Namecheap", "whois_available": true }, "trust_signals": { "https": true, "sitemap": true, "robots_txt": true, "open_graph": true, "schema_markup": true, "schema_types": ["Article", "Organization"], "analytics": true, "hreflang": false, "llms_txt": false, "security_txt": false, "word_count": 2340 }, "cc_presence": { "success": true, "domain": "example.com", "indexed_pages_total": 142, "indexed_pages_latest": 38, "last_crawled": "2026-06-15", "crawl_trend": "stable", "crawls_queried": 5 } } }

Error codes

404 NOT_FOUNDScan not found or belongs to another user
422 NO_DATAScan has no stored results - re-scan to generate data

Bulk scan

POST /api/v1/scans/bulk scope: scan

Starts parallel scans for multiple URLs (up to 50). Returns a batch_id to track progress.

FieldTypeReq.Description
urlsarraySIArray of URLs/domains to scan (max 50)
modestringNOsingle (default)
bypass_cacheboolNOForce fresh analysis for all URLs
curl -X POST https://insights.perseodesign.com/api/v1/scans/bulk \ -H "Authorization: Bearer sk_live_xxxx" \ -H "Content-Type: application/json" \ -d '{"urls":["https://example.com","https://myblog.com","example.org"]}'
Response 202 Accepted
{ "success": true, "data": { "batch_id": "b1c2d3e4-f5a6-7890-abcd-ef1234567890", "total": 3, "status": "running", "poll_url": "/api/v1/scans/bulk/b1c2d3e4-..." } }
GET /api/v1/scans/bulk/{batch_id} scope: read

Polls the status of a bulk batch. Returns partial results as scans complete.

curl https://insights.perseodesign.com/api/v1/scans/bulk/b1c2d3e4-... \ -H "Authorization: Bearer sk_live_xxxx"
Response (in progress)
{ "success": true, "data": { "batch_id": "b1c2d3e4-...", "status": "running", "total": 3, "completed": 1, "results": [ { "url": "https://example.com", "status": "complete", "result": { /* PageAnalysis object */ } }, { "url": "https://myblog.com", "status": "running" }, { "url": "https://example.org", "status": "running" } ] } }

History

GET /api/v1/history scope: read

User scan history with pagination.

ParameterTypeDefaultDescription
pageint1Page number
per_pageint20Results per page (max 50)
curl "https://insights.perseodesign.com/api/v1/history?page=1&per_page=20" \ -H "Authorization: Bearer sk_live_xxxx"

Export

GET /api/v1/scans/{id}/export/{format} scope: export

Exports scan results. Supported formats: json, csv, pdf.

# Download CSV curl -H "Authorization: Bearer sk_live_xxxx" \ "https://insights.perseodesign.com/api/v1/scans/127/export/csv" \ -o report.csv

Crawls

Start and monitor full-site crawls. Results are returned asynchronously via polling.

POST /api/v1/crawls scope: scan

Starts an asynchronous site crawl. Returns immediately with a task_id to poll for results.

FieldTypeRequiredDescription
domainstringyesDomain or full URL to crawl
max_pagesintnoMax pages (capped by plan: Free=1000, Pro=10000)
max_depthintnoMax link depth (capped by plan: Free=4, Pro=8)
curl -X POST https://insights.perseodesign.com/api/v1/crawls \ -H "Authorization: Bearer sk_live_xxxx" \ -H "Content-Type: application/json" \ -d '{"domain": "https://example.com", "max_pages": 500}'
Response 202
{ "success": true, "data": { "task_id": "a1b2c3d4-...", "status": "running", "domain": "https://example.com", "max_pages": 500, "max_depth": 4, "poll_url": "/api/v1/crawls/a1b2c3d4-..." } }
GET /api/v1/crawls/{task_id} scope: read

Polls crawl status. Status can be running, complete, error, or stopped. When complete, includes job_id and report_url.

curl https://insights.perseodesign.com/api/v1/crawls/a1b2c3d4-... \ -H "Authorization: Bearer sk_live_xxxx"
Response (complete)
{ "success": true, "data": { "task_id": "a1b2c3d4-...", "status": "complete", "domain": "https://example.com", "job_id": 42, "report_url": "/crawl/report/42", "crawled": 312, "summary": { "total_pages": 312, "total_issues": 47, "broken_pages": 3, "orphan_pages": 12 } } }
GET /api/v1/crawls scope: read

Lists crawl jobs for the authenticated user (max 100).

ParameterTypeDefaultDescription
limitint20Max results (max 100)
curl "https://insights.perseodesign.com/api/v1/crawls?limit=10" \ -H "Authorization: Bearer sk_live_xxxx"

Scheduled Reports

Create and manage automated SEO reports sent via email on a weekly or monthly schedule.

GET /api/v1/scheduled-reports scope: read

List all scheduled reports for the authenticated user.

POST /api/v1/scheduled-reports scope: write

Create a new scheduled report.

FieldTypeRequiredDescription
urlstringyesURL of the page to scan
schedule_typestringyesweekly or monthly
day_of_weekintif weekly0 (Mon) to 6 (Sun)
day_of_monthintif monthly1 to 28
labelstringnoOptional label for the report
notify_emailstringnoOverride email (defaults to account email)
PUT /api/v1/scheduled-reports/{id} scope: write

Update a scheduled report. Accepts the same fields as POST.

PATCH /api/v1/scheduled-reports/{id}/toggle scope: write

Toggle is_active on/off for a scheduled report.

DELETE /api/v1/scheduled-reports/{id} scope: write

Delete a scheduled report permanently.

User

GET /api/v1/user/profile scope: read

Returns the authenticated user's profile.

GET /api/v1/user/usage scope: read

Usage statistics: scans today, API requests, plan limits.

Error codes

CodeHTTPDescription
MISSING_AUTH 401 Authorization header missing or malformed
INVALID_TOKEN 401 Token invalid, expired or revoked
INSUFFICIENT_SCOPE 403 Token does not have the required scope
INVALID_CREDENTIALS 401 Wrong email/password
ACCOUNT_DISABLED 403 Account disabled
RATE_LIMIT_EXCEEDED 429 Scan or request limit exceeded
INVALID_REQUEST 400 Missing or malformed parameters
INVALID_URL 400 Invalid URL
INVALID_SCOPES 400 Unrecognized scopes
NOT_FOUND 404 Resource not found
FORBIDDEN 403 Access denied (resource belongs to another user)
INTERNAL_ERROR 500 Internal server error

Examples

Full flow: login + create token + first scan (Bash)

# 1. Login and get JWT JWT=$(curl -s -X POST https://insights.perseodesign.com/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"[email protected]","password":"password123"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])") # 2. Create a permanent API key TOKEN=$(curl -s -X POST https://insights.perseodesign.com/api/v1/tokens \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"name":"Script CI","scopes":["read","scan","export"]}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])") echo "Save this token: $TOKEN" # 3. Start a scan SCAN_ID=$(curl -s -X POST https://insights.perseodesign.com/api/v1/scans \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com","mode":"single"}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['scan_id'])") # 4. Poll until complete while true; do RESULT=$(curl -s -H "Authorization: Bearer $TOKEN" \ "https://insights.perseodesign.com/api/v1/scans/$SCAN_ID") STATUS=$(echo $RESULT | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['status'])") echo "Status: $STATUS" [ "$STATUS" != "running" ] && break sleep 3 done # 5. Export as CSV curl -s -H "Authorization: Bearer $TOKEN" \ "https://insights.perseodesign.com/api/v1/scans/127/export/csv" \ -o "report_$(date +%Y%m%d).csv"

Python: monitoring a list of websites

import time import requests API_BASE = "https://insights.perseodesign.com/api/v1" TOKEN = "sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} SITES = ["https://example.com", "https://myblog.com"] def scan_site(url): resp = requests.post(f"{API_BASE}/scans", json={"url": url, "mode": "single"}, headers=HEADERS) resp.raise_for_status() scan_id = resp.json()["data"]["scan_id"] while True: time.sleep(3) data = requests.get(f"{API_BASE}/scans/{scan_id}", headers=HEADERS).json()["data"] if data["status"] == "complete": return data.get("result", {}) elif data["status"] in ("error", "stopped"): raise RuntimeError(data.get("error")) for site in SITES: result = scan_site(site) g = result.get("googlebot", {}) print(f"{site}: SEO={g.get('seo_score')} Errori={len(g.get('errors',[]))}")

Python: start a full site crawl and wait for results

import time import requests API_BASE = "https://insights.perseodesign.com/api/v1" TOKEN = "sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} # 1. Start the crawl resp = requests.post( f"{API_BASE}/crawls", json={"domain": "https://example.com", "max_pages": 500}, headers=HEADERS, ) resp.raise_for_status() task_id = resp.json()["data"]["task_id"] print(f"Crawl started: {task_id}") # 2. Poll until complete while True: time.sleep(5) data = requests.get( f"{API_BASE}/crawls/{task_id}", headers=HEADERS ).json()["data"] status = data["status"] progress = data.get("progress", {}) crawled = progress.get("crawled", 0) print(f"Status: {status} ({crawled} pages)") if status == "complete": summary = data.get("summary", {}) print(f"Done! {summary.get('total_pages')} pages, {summary.get('total_issues')} issues") print(f"Report: https://insights.perseodesign.com{data['report_url']}") break elif status in ("error", "stopped"): raise RuntimeError(f"Crawl ended with status: {status}")

JavaScript/Node.js: sitemap scan

const fetch = require('node-fetch'); const API_BASE = 'https://insights.perseodesign.com/api/v1'; const TOKEN = 'sk_live_xxxx'; const headers = { 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }; async function scanSitemap(url) { const { data } = await (await fetch(`${API_BASE}/scans`, { method: 'POST', headers, body: JSON.stringify({ url, mode: 'sitemap' }) })).json(); let result; while (true) { await new Promise(r => setTimeout(r, 3000)); result = (await (await fetch(`${API_BASE}/scans/${data.scan_id}`, { headers })).json()).data; if (result.status !== 'running') break; } return result; } scanSitemap('https://myblog.com/sitemap.xml').then(r => console.log(`Completed! ${r.result?.length} URLs analyzed`) );