REST API.
Manage sites, audits, keywords, rankings, backlinks, Score and reports from your code. One key, JSON everywhere, errors always in the same shape and the price of every call before you spend it.
Base URL and key.
The base URL is https://seoprimo.it/api/v1. Every request carries the key in the Authorization: Bearer sp_live_… header. Panel cookies do not work here, and the key does not work in the panel.
curl -sS https://seoprimo.it/api/v1/sites \
-H "Authorization: Bearer sp_live_..."
Create keys in the panel, under Integrations, API keys: the secret is shown only once (format sp_live_ + 43 characters, we only keep the hash) and can be revoked at any time. There are two scopes:
| Scope | Allows |
|---|---|
read | Every GET: lists, details, Score, reports, credits, run results. |
write | POST and DELETE: adding or deleting a site, starting audits and credit-based analyses, creating trackers. |
The two are independent: a write-only key cannot read. For normal use pick both.
Requests per minute.
Each key can make 60 requests per minute (a 60-second window that starts at the first request). The limit is the same for the REST API and the MCP server: the two channels share one count.
Every response carries X-RateLimit-Limit, the key's limit. Above the limit the answer is 429 (rate_limited) and the Retry-After header says in how many seconds to retry: all you need to do is wait.
To wait for a long job do not poll in a tight loop: one request every 5-10 seconds is enough, or use webhooks.
The price before you spend it.
Paid calls use your account's credits, the same as the panel (plan credits first, then packs, each from the nearest expiry). These are free: listing and reading sites, adding a site, reading audits, Score, reports, credits and runs, creating a tracker. The quick audit costs 10 credits flat; the full audit costs the crawl, 2 credits up to 200 pages and 1 credit per 100 pages beyond, plus the Lighthouse checks if you ask for them; engine analyses (keywords, rankings, backlinks, competitors, search results, local SEO, AI visibility) cost according to how much data you ask for.
dry_run: the estimate without running anything
Add ?dry_run=true to any POST: nothing runs, nothing is charged and the answer is the estimate.
curl -sS -X POST "https://seoprimo.it/api/v1/sites/<site_id>/keywords/research?dry_run=true" \
-H "Authorization: Bearer $SEOPRIMO_KEY" -H "Content-Type: application/json" \
-d '{"seeds": ["scarpe running"], "result_limit": 150}'
# {"estimated_credits": 12}
- The estimate is always the worst case: it is the amount reserved before the call.
- When the call ends, the charge is recomputed on the data actually received and never exceeds the estimate: the difference becomes available again.
- If the call fails, the credits come back in full.
- The
X-Credits-Chargedheader says how many credits the call charged (for a long job still running, the credits reserved). - Without enough credits the answer is
402and nothing runs:
{
"error": "insufficient_credits",
"needed": 25,
"available": 4
}
Start, then check.
Some operations do not finish at once: the full audit and the rank check answer 202 with a running run. Read the outcome from GET /runs/{id}: the status is queued, running, done or failed, and the result (result) is there only when done. If the run fails, the credits come back.
# a long job (full audit, rank check) answers 202 with a run
RUN_ID=$(curl -sS -X POST "$API/sites/$SITE_ID/full-audits" -H "$AUTH" -H "Content-Type: application/json" \
-d '{"max_pages": 100, "run_lighthouse": false}' | jq -r .id)
# poll the run: status is queued, running, done or failed; "result" is filled only when done
curl -sS "$API/runs/$RUN_ID" -H "$AUTH" | jq '{status, credits_reserved, credits_charged, result}'
The quick audit is different: it is not a run. It answers 202 with the audit id and is followed with GET /sites/{id}/audits, looking for that id. It is the path of the example below.
Site, quick audit, Score.
The whole path: add a site, ask for the price of the quick audit, start it, wait for it to finish and read the SeoPrimo Score. You need credits for the audit (10) and a key with both scopes. The quick audit Score is preliminary (preliminary: true) until the Authority pillar is measured.
export SEOPRIMO_KEY="sp_live_..."
API=https://seoprimo.it/api/v1
AUTH="Authorization: Bearer $SEOPRIMO_KEY"
# 1. add the site (free). If it already exists the answer is 409: take its id from GET /sites
SITE_ID=$(curl -sS -X POST "$API/sites" -H "$AUTH" -H "Content-Type: application/json" \
-d '{"url": "https://www.acmescarpe.it/", "name": "Acme Scarpe"}' | jq -r .id)
# 2. the price of the quick audit, without running it
curl -sS -X POST "$API/sites/$SITE_ID/audits?dry_run=true" -H "$AUTH"
# {"estimated_credits":10}
# 3. start the quick audit (answer 202: {"id": "...", "status": "queued", "cost": 10})
AUDIT_ID=$(curl -sS -X POST "$API/sites/$SITE_ID/audits" -H "$AUTH" | jq -r .id)
# 4. poll until the audit is done or failed
while true; do
STATUS=$(curl -sS "$API/sites/$SITE_ID/audits" -H "$AUTH" \
| jq -r --arg id "$AUDIT_ID" '.audits[] | select(.id == $id) | .status')
echo "status: $STATUS"
[ "$STATUS" = "done" ] || [ "$STATUS" = "failed" ] && break
sleep 10
done
# 5. the SeoPrimo Score
curl -sS "$API/sites/$SITE_ID/score" -H "$AUTH" | jq '{score, preliminary, pillars}'
import os
import time
import httpx
api = httpx.Client(
base_url="https://seoprimo.it/api/v1",
headers={"Authorization": f"Bearer {os.environ['SEOPRIMO_KEY']}"},
timeout=30,
)
def ok(response: httpx.Response) -> dict:
if response.is_error:
body = response.json() # always {"error": code, "detail": ...}
raise RuntimeError(f"{response.status_code} {body['error']}: {body['detail']}")
return response.json()
# 1. add the site (free). If it already exists the answer is 409: take its id from GET /sites
site = ok(api.post("/sites", json={"url": "https://www.acmescarpe.it/", "name": "Acme Scarpe"}))
site_id = site["id"]
# 2. the price of the quick audit, without running it
print(ok(api.post(f"/sites/{site_id}/audits", params={"dry_run": "true"}))) # {'estimated_credits': 10}
# 3. start the quick audit (answer 202)
audit_id = ok(api.post(f"/sites/{site_id}/audits"))["id"]
# 4. poll until the audit is done or failed
while True:
audits = ok(api.get(f"/sites/{site_id}/audits"))["audits"]
audit = next(a for a in audits if a["id"] == audit_id)
print("status:", audit["status"])
if audit["status"] in ("done", "failed"):
break
time.sleep(10)
# 5. the SeoPrimo Score
score = ok(api.get(f"/sites/{site_id}/score"))
print(score["score"], "preliminary" if score["preliminary"] else "full", score["pillars"])
// Node 18+ (global fetch)
const API = "https://seoprimo.it/api/v1";
const headers = { Authorization: `Bearer ${process.env.SEOPRIMO_KEY}`, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(method, path, body) {
const response = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
const data = await response.json();
if (!response.ok) throw new Error(`${response.status} ${data.error}: ${JSON.stringify(data.detail)}`);
return data;
}
// 1. add the site (free). If it already exists the answer is 409: take its id from GET /sites
const site = await call("POST", "/sites", { url: "https://www.acmescarpe.it/", name: "Acme Scarpe" });
// 2. the price of the quick audit, without running it
console.log(await call("POST", `/sites/${site.id}/audits?dry_run=true`)); // { estimated_credits: 10 }
// 3. start the quick audit (answer 202)
const started = await call("POST", `/sites/${site.id}/audits`);
// 4. poll until the audit is done or failed
let audit;
do {
await sleep(10_000);
const { audits } = await call("GET", `/sites/${site.id}/audits`);
audit = audits.find((a) => a.id === started.id);
console.log("status:", audit.status);
} while (!["done", "failed"].includes(audit.status));
// 5. the SeoPrimo Score
const score = await call("GET", `/sites/${site.id}/score`);
console.log(score.score, score.preliminary ? "preliminary" : "full", score.pillars);
The answer of GET /sites/{id}/score looks like {"score": 72, "preliminary": true, "pillars": {…}, …}: score is the SeoPrimo Score (0-100), pillars the five pillars, checks the checks passed or not, history the last 12 measurements.
Always the same shape.
Every error is a JSON {"error": code, "detail": text}: error is a stable code you can base your code on, detail a text for people (in Italian; for validation_error it is the list of wrong fields). The only variant is the 402, which carries needed and available instead of detail.
| Status | Code | Meaning |
|---|---|---|
401 | invalid_api_key | Key missing, invalid or revoked. |
402 | insufficient_credits | Not enough credits. The body adds needed and available. |
403 | insufficient_scope | The key lacks the required scope (read for GET, write for POST and DELETE). |
404 | not_found | The resource does not exist or belongs to another account. |
409 | conflict | The site has already been added. |
422 | validation_error | Invalid request: detail is the list of wrong fields (loc, msg, type). |
429 | rate_limited | More than 60 requests per minute with this key. Wait the seconds given by Retry-After. |
500 | internal_error | Internal error: try again later. |
502 | engine_error | The analysis engine returned an error. The credits are given back. |
503 | engine_not_configured | The analysis engine is not configured or not answering (engine_not_configured, engine_unavailable, service_unavailable). The credits are given back. |
All the endpoints.
The table is generated from the OpenAPI spec when this page is published, so it cannot fall behind. A * marks required fields; paths with {id} carry an id in the address. Schemas, response examples and "Try it out" are in the interactive documentation.
| Call | What it does | Parameters | Scope | Cost |
|---|---|---|---|---|
GET /sites | List sites | - | read | Free |
POST /sites | Add a site | url*, name, dry_run | write | Free |
GET /sites/{site_id} | Get a site | - | read | Free |
DELETE /sites/{site_id} | Delete a site | - | write | Free |
POST /sites/{site_id}/audits | Start the quick audit | dry_run | write | 10 credits flat |
GET /sites/{site_id}/audits | Quick audits of a site | - | read | Free |
POST /sites/{site_id}/full-audits | Start the full site audit | max_pages, run_lighthouse, dry_run | write | Credits · long job |
POST /sites/{site_id}/keywords/research | Keyword research | seeds*, result_limit, dry_run | write | Credits |
POST /sites/{site_id}/keywords/metrics | Keyword metrics | keywords*, dry_run | write | Credits |
GET /sites/{site_id}/rankings/trackers | Rank trackers | - | read | Free |
POST /sites/{site_id}/rankings/trackers | Create the rank tracker | keywords*, schedule, dry_run | write | Free |
POST /sites/{site_id}/rankings/trackers/{tracker_id}/run | Run a rank check | dry_run | write | Credits · long job |
POST /sites/{site_id}/backlinks/overview | Backlinks overview | target, dry_run | write | Credits |
POST /sites/{site_id}/backlinks/profile | Backlinks profile | target, page, page_size, dry_run | write | Credits |
POST /sites/{site_id}/competitors | SERP competitors | keywords*, limit, dry_run | write | Credits |
POST /sites/{site_id}/serp | Search results | keywords*, depth, dry_run | write | Credits |
POST /sites/{site_id}/local/profile | Business profile | business_name, dry_run | write | Credits |
POST /sites/{site_id}/local/reviews | Google reviews | business_name, dry_run | write | Credits |
POST /sites/{site_id}/ai-visibility | AI visibility | brand, prompts, keywords, platforms, limit, dry_run | write | Credits |
GET /sites/{site_id}/score | Site score | - | read | Free |
GET /sites/{site_id}/reports | Reports of a site | - | read | Free |
GET /sites/{site_id}/reports/{report_id} | A report with its HTML | - | read | Free |
POST /sites/{site_id}/agents | Start an agent | agent*, budget_credits, dry_run | write | Credits · long job |
GET /sites/{site_id}/agents/{run_id} | Agent run status | - | read | Free |
GET /credits | Credit balance | - | read | Free |
GET /runs/{run_id} | Run status and result | - | read | Free |
One key,
twenty endpoints.
Create the key in the panel and make your first call within a minute.