GeoScored API

Integrate GEO scanning into your workflow. Create scans, retrieve reports, and monitor AI visibility programmatically.

1. Getting started

The GeoScored API is a REST API that returns JSON. Every request must include an API key header. You can generate keys in Account Settings → API Keys.

Base URL

https://app.geoscored.ai/api/v1

Content type

application/json

The interactive OpenAPI specification is available at /api/docs (Swagger UI) and /api/redoc (ReDoc). This page covers the concepts and patterns that the auto-generated docs do not.

2. Authentication

Every request requires an API key passed in the X-API-Key header. Keys are scoped to your organization. All members of an org share the same scan history.

# Pass your API key on every request
curl https://app.geoscored.ai/api/v1/scans \
  -H "X-API-Key: geo_your_key_here" \
  -H "Content-Type: application/json"

Key format

All keys begin with geo_. Requests with missing or malformed keys return 401.

Org-scoped

API keys are scoped to your organization. Scans created by one key are visible to all keys in the same org.

3. Scan lifecycle

Scans are asynchronous. Creating a scan returns immediately with status pending. The engine processes the scan in the background. Poll the scan endpoint or use a webhook callback to detect completion.

Polling pattern

Poll GET /api/v1/scans/{'{'}scan_id{'}'} until status is COMPLETED or FAILED. Most scans complete within 60–120 seconds. Use an exponential backoff starting at 5 seconds.

Webhook alternative

Pass a callback_url when creating a scan to receive a POST notification when the scan completes. See the Webhooks section for payload details.

4. Endpoints

All endpoints require the X-API-Key header.
Method Path Description
POST /api/v1/scans Create a scan.
GET /api/v1/scans List scans. Paginated, filterable.
GET /api/v1/scans/compare Compare two scans with computed deltas.
GET /api/v1/scans/{'{scan_id}'} Get a scan by ID (status + scores).
GET /api/v1/scans/{'{scan_id}'}/report Full report with all check results.
GET /api/v1/scans/{'{scan_id}'}/triage Site Triage dossier: platform, complexity, risk lenses, evidence.
GET /api/v1/scans/{'{scan_id}'}/checks List all check results for a scan.
GET /api/v1/scans/{'{scan_id}'}/checks/{'{check_id}'} Get a single check result.
GET /api/v1/scans/price Pre-flight pricing check for a URL.
GET /api/v1/usage Current daily usage quota information.
PATCH /api/v1/scans/{'{scan_id}'} Update scan metadata (project, tracked URL).
DELETE /api/v1/scans/{'{scan_id}'} Delete a scan and all its records.
GET /api/v1/scans/export Export scan results as CSV.
GET /api/v1/scans/{'{scan_id}'}/pdf Download the PDF report for a completed scan.
POST /api/v1/schedules Create a recurring scan schedule.
GET /api/v1/schedules List all schedules for your organization.
GET /api/v1/schedules/{'{schedule_id}'} Get a single schedule by ID.
PATCH /api/v1/schedules/{'{schedule_id}'} Update frequency, notification, or active state.
DELETE /api/v1/schedules/{'{schedule_id}'} Deactivate a schedule (soft delete).
POST /api/v1/scans Requires subscription

Create a scan

Submits a URL for a GEO scan. The scan starts asynchronously. Returns 201 with the scan object in pending status. Requires an active subscription.

Request body

Field Type Required Description
url string Yes The URL to scan. Must be publicly accessible HTTPS.
brand_name string No Brand name for AI mention checks. Defaults to the domain name.
callback_url string No HTTPS URL to POST scan results to when complete. Max 2048 chars.

Example request

curl -X POST https://app.geoscored.ai/api/v1/scans \
  -H "X-API-Key: geo_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "brand_name": "Example Corp",
    "callback_url": "https://your-server.com/webhooks/geoscored"
  }'

Response

{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "url": "https://example.com",
  "brand_name": "Example Corp",
  "status": "PENDING",
  "visibility_score": null,
  "trustworthiness_score": null,
  "search_score": null,
  "grade": null,
  "grade_descriptor": null,
  "pillar_scores": null,
  "error_message": null,
  "created_at": "2026-03-01T10:00:00",
  "updated_at": "2026-03-01T10:00:00",
  "callback_url": "https://your-server.com/webhooks/geoscored"
}
GET /api/v1/scans

List scans

Returns a paginated list of scans for your organization. Filter by status or URL.

Query parameters

Parameter Type Default Description
limit integer 50 Results per page. Max 100.
offset integer 0 Number of results to skip.
status string all Filter: PENDING, RUNNING, COMPLETED, FAILED.
url string Exact URL match (normalized). Returns scan history for that URL.
tracked_url_id string Filter by tracked URL ID for timeline queries.
{
  "data": [/* array of scan objects */],
  "total": 42,
  "limit": 50,
  "offset": 0,
  "has_more": false
}
GET /api/v1/scans/{'{scan_id}'}/report

Get report

Returns the complete report for a completed scan: search, visibility, and trust scores with grades, pillar scores, all check results, and AI-generated summaries. Only available once the scan status is COMPLETED.

Query parameters

Parameter Type Default Description
audience string marketing marketing or engineering. Switches explanatory text in why_it_matters, grade_descriptor, and pillar descriptions. Scores are identical for both.
{
  "scan_id": "a1b2c3d4-...",
  "url": "https://example.com",
  "visibility_score": 0.72,
  "trustworthiness_score": 0.65,
  "search_score": 0.58,
  "visibility_grade": "B",
  "trustworthiness_grade": "B-",
  "search_grade": "C+",
  "grade_descriptor": "Good AI visibility with room to improve",
  "pillar_scores": {
    "geo": 0.68,
    "seo": 0.81
  },
  "total_checks": 28,
  "passing_checks": 19,
  "warning_checks": 6,
  "failing_checks": 3,
  "non_scoring_checks": 4,
  "checks": [/* array of check result objects */],
  "geo_marketing_summary": "AI-generated summary...",
  "seo_marketing_summary": "SEO-focused marketing summary...",
  "seo_engineering_summary": "SEO-focused engineering summary...",
  "executive_summary": "High-level executive summary...",
  "company_profile": null,
  "site_profile": null,
  "access_level": "full",
  "is_lite": false,
  "audience": "marketing"
}
GET /api/v1/scans/{'{scan_id}'}/triage

Get Site Triage dossier

Returns the Site Triage reconnaissance dossier for a completed scan: platform detection (CMS, framework, page builder), complexity assessment, a plain-language summary, risk lenses, a full evidence ledger, priority actions, and caveats. Triage is a what-is-this-site-made-of dossier, not a fix list. Returns 404 if triage has not yet been computed for the scan.

Response fields

Field Type Description
scan_id string UUID of the scan this triage belongs to.
schema_version string Triage schema version (e.g. "1.0"). Increment when shape changes.
platform object Platform detection result. Fields: cms, page_builder, js_framework, complexity_bucket (all nullable strings).
summary_paragraph string | null Plain-language overview of the site's technical profile.
priority_actions array Ranked opportunity findings. Each item: id, label, impact, effort, quick_win (bool).
strengths array Findings with status "strength" from the evidence ledger.
risk_lenses array Risk assessments across named dimensions. Each item: lens_id, label, bucket ("low" | "moderate" | "high" | "critical"). Raw float scores are internal-only and never returned.
evidence_ledger array All findings across all categories. Each item: id, category, label, status, impact, effort, why_it_matters.
caveats array<string> Confidence caveats and data-quality notes for this dossier.
quick_win_count integer Number of priority actions flagged as quick wins (high impact, low effort).
action_count integer Total number of items in priority_actions.

Example request

curl https://app.geoscored.ai/api/v1/scans/a1b2c3d4-e5f6-7890-abcd-ef1234567890/triage \
  -H "X-API-Key: geo_your_key"

Response

{
  "scan_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "schema_version": "1.0",
  "platform": {
    "cms": "WordPress",
    "page_builder": "Elementor",
    "js_framework": null,
    "complexity_bucket": "moderate"
  },
  "summary_paragraph": "A WordPress site built with Elementor. Moderate complexity with standard plugin overhead.",
  "priority_actions": [
    {
      "id": "finding-001",
      "label": "Enable server-side caching to reduce TTFB",
      "impact": "high",
      "effort": "low",
      "quick_win": true
    }
  ],
  "strengths": [
    {
      "id": "finding-010",
      "category": "structured_data",
      "label": "Schema markup present on key pages",
      "status": "strength",
      "impact": null,
      "effort": null,
      "why_it_matters": "Structured data improves AI parsing and rich result eligibility."
    }
  ],
  "risk_lenses": [
    { "lens_id": "visibility",        "label": "Search Visibility",   "bucket": "moderate" },
    { "lens_id": "maintenance",       "label": "Maintenance Burden",  "bucket": "high"     },
    { "lens_id": "vendor_complexity", "label": "Vendor Complexity",   "bucket": "moderate" }
  ],
  "evidence_ledger": [/* array of all finding objects */],
  "caveats": [
    "Platform detection is heuristic-based; verify CMS version independently."
  ],
  "quick_win_count": 1,
  "action_count": 3
}

Error: triage not yet available

# HTTP 404
{
  "error": {
    "type": "not_found",
    "code": "triage_not_available",
    "message": "Triage data not available for this scan.",
    "param": "scan_id"
  }
}
GET /api/v1/scans/compare

Compare two scans

Returns a structured comparison of two scans with computed deltas for each ring score (search, visibility, trust), each pillar, and each individual check. Both scans must belong to your organization. Useful for tracking improvement over time.

Query parameters

Parameter Type Required Description
scan_a string Yes Scan ID for the baseline (earlier) scan.
scan_b string Yes Scan ID for the comparison (later) scan.
curl "https://app.geoscored.ai/api/v1/scans/compare?scan_a=ID_A&scan_b=ID_B" \
  -H "X-API-Key: geo_your_key"

Response

{
  "data": {
    "scan_a": {
      "id": "ID_A",
      "visibility_score": 0.60,
      "trustworthiness_score": 0.55,
      "search_score": 0.48
    },
    "scan_b": {
      "id": "ID_B",
      "visibility_score": 0.72,
      "trustworthiness_score": 0.65,
      "search_score": 0.58
    },
    "deltas": {
      "search_delta": 0.10,
      "visibility_delta": 0.12,
      "trustworthiness_delta": 0.10,
      "pillars": [/* per-pillar delta objects */]
    }
  }
}
GET /api/v1/scans/price

Pre-flight pricing check

Returns the pricing for scanning a given URL before committing to a scan. Useful for confirming cost before calling POST /api/v1/scans.

Query parameters

Parameter Type Required Description
url string Yes The URL you intend to scan.

Example request

curl "https://app.geoscored.ai/api/v1/scans/price?url=https://example.com" \
  -H "X-API-Key: geo_your_key"

Response

{
  "price": 1,
  "currency": "scan"
}
GET /api/v1/usage

Get usage quota

Returns the current daily usage quota information for your organization: how many scans your plan allows per day, how many have been used today, and how many remain.

Example request

curl https://app.geoscored.ai/api/v1/usage \
  -H "X-API-Key: geo_your_key"

Response

{
  "daily_limit": 50,
  "used_today": 3,
  "remaining": 47
}

5. Scan schedules

Scan schedules run GEO audits on a recurring basis without manual intervention. Each schedule targets one root domain and runs monthly or quarterly. When a scan completes, GeoScored sends an email notification to the schedule owner (opt-in, default on).

Schedule limits: Free accounts can keep 5 active schedules per organization; GeoScored Pro accounts can keep 50. An org-level override applies if one is configured. Only root domains can be scheduled; subpage URLs return not_homepage.
POST /api/v1/schedules

Create a schedule

Creates a recurring scan schedule for a root domain. Returns the existing schedule (HTTP 200) if one already exists for that URL and organization, so the caller can PATCH it instead.

Request body

Field Type Required Description
url string Yes Root domain to scan. Must be a homepage (e.g. https://example.com). Subpages return not_homepage.
frequency string No monthly or quarterly. Defaults to quarterly. Other values return invalid_frequency.
brand_name string No Brand name for AI mention checks. Defaults to the domain name.
notify_email boolean No Send an email when each scheduled scan completes. Defaults to true.

Example request

curl -X POST https://app.geoscored.ai/api/v1/schedules \
  -H "X-API-Key: geo_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "frequency": "quarterly",
    "brand_name": "Example Corp",
    "notify_email": true
  }'

Response (201 Created)

{
  "id": "sch_a1b2c3d4-...",
  "url": "https://example.com",
  "brand_name": "Example Corp",
  "frequency": "quarterly",
  "is_active": true,
  "notify_email": true,
  "next_run_at": "2026-08-06T10:00:00",
  "last_run_at": null,
  "last_scan_id": null,
  "created_at": "2026-05-06T10:00:00"
}
GET /api/v1/schedules

List schedules

Returns all schedules for your organization, ordered by creation date (newest first). Includes both active and inactive schedules.

{
  "data": [/* array of schedule objects */],
  "total": 3
}
GET /api/v1/schedules/{'{schedule_id}'}

Get a schedule

Returns a single schedule by ID. Returns 404 with code schedule_not_found if the schedule does not exist or belongs to a different organization.

curl https://app.geoscored.ai/api/v1/schedules/sch_a1b2c3d4-... \
  -H "X-API-Key: geo_your_key"
PATCH /api/v1/schedules/{'{schedule_id}'}

Update a schedule

Updates one or more fields on an existing schedule. All fields are optional. When frequency changes, next_run_at is recomputed from the current time.

Request body

Field Type Description
frequency string monthly or quarterly. Recomputes next_run_at from now.
notify_email boolean Enable or disable completion email notifications.
is_active boolean Set to false to pause, true to resume. Pausing does not reset next_run_at.

Example request

curl -X PATCH https://app.geoscored.ai/api/v1/schedules/sch_a1b2c3d4-... \
  -H "X-API-Key: geo_your_key" \
  -H "Content-Type: application/json" \
  -d '{"frequency": "monthly", "notify_email": false}'
DELETE /api/v1/schedules/{'{schedule_id}'}

Deactivate a schedule

Deactivates a schedule. The schedule record is retained for history. No further scans run for this schedule. Returns 204 No Content on success.

curl -X DELETE https://app.geoscored.ai/api/v1/schedules/sch_a1b2c3d4-... \
  -H "X-API-Key: geo_your_key"

Schedule-specific error codes

HTTP code Meaning
422 invalid_url URL is not a valid public HTTPS address.
422 not_homepage Only root domains can be scheduled. Enter your main domain (e.g. example.com), not a subpage.
422 invalid_frequency Frequency must be monthly or quarterly.
422 schedule_limit_reached Active schedule count is at the organization limit. Deactivate an existing schedule to create a new one.
404 schedule_not_found Schedule does not exist or belongs to a different organization.

6. Error handling

All errors return a structured JSON envelope with a consistent shape. Rather than inspecting HTTP status codes alone, always read the error.code field for machine-readable error classification.

{
  "error": {
    "type": "invalid_request",
    "code": "invalid_url",
    "message": "The URL must be a publicly accessible HTTPS address.",
    "param": "url"
  }
}

Common error codes

HTTP code Meaning
401 api_key_required No X-API-Key header present.
401 api_key_invalid Key does not exist, is revoked, or has wrong format.
402 subscription_required No active subscription or daily scan limit reached.
404 scan_not_found Scan does not exist or belongs to a different org.
400 scan_not_complete Report or checks requested before scan has finished.
422 invalid_url URL is not a valid public HTTPS address.
422 invalid_callback_url Callback URL must be HTTPS and publicly accessible.
429 org_rate_limit_exceeded Organization-level rate limit reached. Back off and retry.
500 internal_error Unexpected server error. Retrying usually resolves transient failures.

7. Rate limits

The API enforces two layers of rate limiting: IP-level limits applied before authentication, and organization-level limits applied after authentication. Organization limits are configurable per org and may differ from the defaults shown here.

Limit group Applies to Headers
api_write POST /api/v1/scans X-RateLimit-*
api_read All other GET endpoints X-RateLimit-*

Rate limit information is returned in response headers:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1740823200

429 responses include a Retry-After header with the number of seconds to wait before retrying. Do not retry immediately on 429. Back off exponentially.

8. Webhooks

Pass a callback_url when creating a scan to receive a POST request when the scan completes. The callback URL must be HTTPS and publicly reachable from the internet.

Callback payload

When a scan reaches a terminal state (COMPLETED or FAILED), GeoScored sends a POST request to your callback URL with the scan object as the body.

# POST https://your-server.com/webhooks/geoscored
# Content-Type: application/json
{
  "id": "a1b2c3d4-...",
  "url": "https://example.com",
  "status": "COMPLETED",
  "visibility_score": 0.72,
  "trustworthiness_score": 0.65,
  "grade": "B",
  "pillar_scores": {
    "geo": 0.68,
    "seo": 0.81
  },
  "created_at": "2026-03-01T10:00:00",
  "updated_at": "2026-03-01T10:01:45"
}
Retry behavior: If your endpoint returns a non-2xx response, GeoScored retries up to 3 times with exponential backoff (5s, 25s, 125s). Your endpoint should respond within 10 seconds to avoid timeout.

9. Python examples

These examples use httpx, a modern HTTP client for Python. Install it with pip install httpx.

Create a scan and poll for completion

import time
import httpx

API_KEY = "geo_your_key_here"
BASE_URL = "https://app.geoscored.ai/api/v1"

headers = {"X-API-Key": API_KEY, "Content-Type": "application/json"}

# 1. Create a scan
response = httpx.post(
    f"{BASE_URL}/scans",
    headers=headers,
    json={
        "url": "https://example.com",
        "brand_name": "Example Corp",
    },
)
response.raise_for_status()
scan = response.json()
scan_id = scan["id"]
print(f"Created scan {scan_id}, status: {scan['status']}")

# 2. Poll until terminal state
delay = 5
while scan["status"] not in ("COMPLETED", "FAILED"):
    time.sleep(delay)
    delay = min(delay * 2, 60)  # cap at 60s
    response = httpx.get(
        f"{BASE_URL}/scans/{scan_id}",
        headers=headers,
    )
    response.raise_for_status()
    scan = response.json()
    print(f"Status: {scan['status']}")

if scan["status"] == "FAILED":
    raise RuntimeError(f"Scan failed: {scan.get('error_message')}")

print(f"Visibility: {scan['visibility_score']}, Trustworthiness: {scan['trustworthiness_score']}")

Retrieve the report

# 3. Retrieve report (after scan completes)
response = httpx.get(
    f"{BASE_URL}/scans/{scan_id}/report",
    headers=headers,
    params={"audience": "engineering"},  # or "marketing"
)
response.raise_for_status()
report = response.json()

print(f"Visibility: {report['visibility_score']:.1%}, Trustworthiness: {report['trustworthiness_score']:.1%}")
print(f"Checks: {report['passing_checks']} pass, {report['failing_checks']} fail")

# List failing checks
for check in report["checks"]:
    if check["severity"] == "FAIL":
        print(f"  FAIL: {check['check_name']} - {check['summary']}")

Compare two scans

# 4. Compare scans to track improvement
response = httpx.get(
    f"{BASE_URL}/scans/compare",
    headers=headers,
    params={"scan_a": "older_scan_id", "scan_b": "newer_scan_id"},
)
response.raise_for_status()
comparison = response.json()["data"]

visibility_delta = comparison["deltas"]["visibility_delta"]
trust_delta = comparison["deltas"]["trustworthiness_delta"]
sign_v = "+" if visibility_delta >= 0 else ""
sign_t = "+" if trust_delta >= 0 else ""
print(f"Visibility change: {sign_v}{visibility_delta:.1%}, Trustworthiness change: {sign_t}{trust_delta:.1%}")

for pillar in comparison["deltas"]["pillars"]:
    d = pillar["delta"]
    sign = "+" if d >= 0 else ""
    print(f"  {pillar['pillar_key']}: {sign}{d:.1%}")