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

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`) );