SeoPrimo · SEO for Google and for AI · guaranteed presence, or your setup fee refunded · Free audit of your site
SeoPrimo · SEO for Google and for AI · guaranteed presence, or your setup fee refunded · Free audit of your site
seoprimo
— Developers · REST API

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.

26
operations, OpenAPI 3.1
60/min
per key
dry_run
the price before spending
— Authentication

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
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:

API key scopes
ScopeAllows
readEvery GET: lists, details, Score, reports, credits, run results.
writePOST 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.

— Limits

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.

— Credits

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
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-Charged header says how many credits the call charged (for a long job still running, the credits reserved).
  • Without enough credits the answer is 402 and nothing runs:
JSON
{
  "error": "insufficient_credits",
  "needed": 25,
  "available": 4
}
— Long jobs

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.

curl
# 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.

— Example

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.

curl
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}'
Python
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"])
JavaScript
// 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.

— Errors

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.

API errors
StatusCodeMeaning
401invalid_api_keyKey missing, invalid or revoked.
402insufficient_creditsNot enough credits. The body adds needed and available.
403insufficient_scopeThe key lacks the required scope (read for GET, write for POST and DELETE).
404not_foundThe resource does not exist or belongs to another account.
409conflictThe site has already been added.
422validation_errorInvalid request: detail is the list of wrong fields (loc, msg, type).
429rate_limitedMore than 60 requests per minute with this key. Wait the seconds given by Retry-After.
500internal_errorInternal error: try again later.
502engine_errorThe analysis engine returned an error. The credits are given back.
503engine_not_configuredThe analysis engine is not configured or not answering (engine_not_configured, engine_unavailable, service_unavailable). The credits are given back.
— Reference

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.

REST API v1 endpoints
CallWhat it doesParametersScopeCost
GET /sitesList sites-readFree
POST /sitesAdd a siteurl*, name, dry_runwriteFree
GET /sites/{site_id}Get a site-readFree
DELETE /sites/{site_id}Delete a site-writeFree
POST /sites/{site_id}/auditsStart the quick auditdry_runwrite10 credits flat
GET /sites/{site_id}/auditsQuick audits of a site-readFree
POST /sites/{site_id}/full-auditsStart the full site auditmax_pages, run_lighthouse, dry_runwriteCredits · long job
POST /sites/{site_id}/keywords/researchKeyword researchseeds*, result_limit, dry_runwriteCredits
POST /sites/{site_id}/keywords/metricsKeyword metricskeywords*, dry_runwriteCredits
GET /sites/{site_id}/rankings/trackersRank trackers-readFree
POST /sites/{site_id}/rankings/trackersCreate the rank trackerkeywords*, schedule, dry_runwriteFree
POST /sites/{site_id}/rankings/trackers/{tracker_id}/runRun a rank checkdry_runwriteCredits · long job
POST /sites/{site_id}/backlinks/overviewBacklinks overviewtarget, dry_runwriteCredits
POST /sites/{site_id}/backlinks/profileBacklinks profiletarget, page, page_size, dry_runwriteCredits
POST /sites/{site_id}/competitorsSERP competitorskeywords*, limit, dry_runwriteCredits
POST /sites/{site_id}/serpSearch resultskeywords*, depth, dry_runwriteCredits
POST /sites/{site_id}/local/profileBusiness profilebusiness_name, dry_runwriteCredits
POST /sites/{site_id}/local/reviewsGoogle reviewsbusiness_name, dry_runwriteCredits
POST /sites/{site_id}/ai-visibilityAI visibilitybrand, prompts, keywords, platforms, limit, dry_runwriteCredits
GET /sites/{site_id}/scoreSite score-readFree
GET /sites/{site_id}/reportsReports of a site-readFree
GET /sites/{site_id}/reports/{report_id}A report with its HTML-readFree
POST /sites/{site_id}/agentsStart an agentagent*, budget_credits, dry_runwriteCredits · long job
GET /sites/{site_id}/agents/{run_id}Agent run status-readFree
GET /creditsCredit balance-readFree
GET /runs/{run_id}Run status and result-readFree
Ready to try

One key,
twenty endpoints.

Create the key in the panel and make your first call within a minute.

What the guide gives you
curl
Complete examples
with Python and JavaScript
402
Credits under control
dry_run on every POST
MCP
And for agents
same key, 11 tools