GEO-Score
ServicesAboutLearnLeaderboardReportsPricingBlog

GEO-Score

The Best GEO Analysis Tool

Test your content for AI search engines like ChatGPT, Perplexity, and Claude. Get actionable insights to improve your visibility.

Get GEO content tips

Plus our free white paper on why ChatGPT skips websites.

Resources

  • Knowledge Base
  • What is GEO?
  • GEO Blog
  • GEO Score Checker
  • Leaderboard
  • AI Glossary
  • Case Studies
  • GEO Insights
  • Reports

GEO Tools

  • Complete Website GEO Audit
  • GEO Analysis
  • AI Visibility Checker
  • ROI Calculator
  • Schema Validator
  • Perplexity SEO Checker
  • ChatGPT SEO Checker
  • Google AI Mode Checker
  • AI Crawler Checker
  • Agent Readiness Checker
  • llms.txt Generator
  • Query Fan-Out Tool
  • AI Bot Log Analyzer
  • Competitor Comparison
  • Professional Services
  • Shopify app
  • All Tools

Analyze Your

  • Blog Posts
  • Product Pages
  • Landing Pages
  • Homepages
  • Service Pages

GEO-Score

  • About
  • Privacy Policy
  • Terms & Conditions
  • Contact
  • White Label
  • Build Your Success

Follow GEO-Score

AI Search Guides

AI Search OptimizationPerplexity SEOChatGPT SEOLLM SEOAI Visibility CheckerAll guides →
GDPR CompliantData Hosted in EUMade in the Netherlands
KVK: 61855111BTW: NL004509498B76© 2026 GEO-score.onlineGEO-Score is part of Be-Found.
Empower your blogging with Bloffee

Developers

Page Analysis API

Analyze a single public web page for AI findability and get a structured result you can show in your own product: an overall score, four pillar scores, 22 metrics with an explanation and a recommendation each, and the top recommendations, in English or Dutch.

Access is for partners with an agreement. Keys are issued by us; there is no self-service sign-up.

  • OpenAPI 3.1 (JSON)
  • Result JSON Schema

On this page

  • What the API does
  • What is not included
  • Base URL and authentication
  • Quick start
  • Endpoints
  • POST /v1/analyses
  • GET /v1/analyses/{id}
  • GET /v1/analyses
  • GET /v1/usage
  • GET /v1/knowledge
  • The result
  • Polling
  • Webhooks
  • Rate limits and quota
  • Errors
  • Idempotency and duplicates
  • Test mode
  • Versioning
  • Magento 2 integration guide

What the API does

You send the URL of one public web page. We fetch and analyze that page and return how well it is prepared to be found, understood and cited by AI search engines and assistants. The analysis covers 22 metrics in 4 pillars:

  • content_quality: Content Quality
  • ai_readiness: AI Readiness
  • technical_basis: Technical Basis
  • authority_trust: Authority & Trust

Every result has an overall score (0-100), the four pillar scores, all 22 metrics (score, status, a one-sentence explanation and a recommendation) and up to 5 top recommendations, ranked by how much they can raise the overall score. Texts are available in nl and en. Results contain no links and no brand names, so you can present them in your own product.

Analyses are asynchronous: you submit a URL, then poll the analysis or receive a webhook when it is done.

What is not included

  • One URL per analysis: no site crawls or full website audits. Submit each page you want analyzed.
  • No AI visibility monitoring (whether AI assistants mention a brand), no keyword rankings and no traffic data.
  • No page content, HTML, screenshots or raw measurements: only the structured result described below.
  • No dashboard or user interface: you build the presentation in your product.
  • No browser access: the API has no CORS and must be called from your server.

Base URL and authentication

Base URL: https://api.geo-score.online/v1. Every request needs an API key in the Authorization header:

http
Authorization: Bearer gs_live_…
  • gs_test_… keys return fixture results instantly, never fetch the URL and never use quota. Use them to build and test your integration (see Test mode).
  • gs_live_… keys run real analyses and count against your monthly quota.

How to handle keys:

  • A key is shown once, when we hand it to you. We store only a hash and cannot show it again; if it is lost, we issue a new one.
  • Keep keys on your server: in an environment variable, a secrets manager or an encrypted configuration field. Never put a key in browser JavaScript, a mobile app, a URL or a code repository, and never log it.
  • We can issue a new key while the old one keeps working for an agreed grace period, so you can rotate without downtime. If a key may have leaked, tell us and we revoke it at once.
  • The API never reads or sets cookies. Authentication is the key only.

A missing, malformed, unknown or revoked key gets 401 unauthorized. A valid key of a suspended account gets 403 forbidden. Repeated failed authentication from one IP address is throttled with 429 rate_limited; requests with a valid key are not affected.

Quick start

1. Submit a URL. A new analysis returns 202 Accepted and a Location header:

bash
curl -sS -X POST https://api.geo-score.online/v1/analyses \
  -H "Authorization: Bearer $GEO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: product-1042-2026-11-01" \
  -d '{"url":"https://shop.example.com/products/blue-ceramic-mug","language":"en","page_type":"product"}'
202 Acceptedjson
{
  "id": "an_5c2e9a7f1b3d4e6a8c0f2b4d",
  "status": "queued",
  "url": "https://shop.example.com/products/blue-ceramic-mug",
  "created_at": "2026-11-01T08:59:12.000Z",
  "links": {
    "self": "/v1/analyses/an_5c2e9a7f1b3d4e6a8c0f2b4d"
  }
}

2. Fetch the analysis until status is completed or failed (or wait for the webhook):

bash
curl -sS https://api.geo-score.online/v1/analyses/an_5c2e9a7f1b3d4e6a8c0f2b4d \
  -H "Authorization: Bearer $GEO_API_KEY"
200 OK, still queuedjson
{
  "id": "an_5c2e9a7f1b3d4e6a8c0f2b4d",
  "status": "queued",
  "url": "https://shop.example.com/products/blue-ceramic-mug",
  "language": "en",
  "page_type_hint": "product",
  "created_at": "2026-11-01T08:59:12.000Z",
  "started_at": null,
  "completed_at": null,
  "error": null,
  "result": null
}

The completed analysis, with the result, is shown under The result. This is the exact fixture a test key returns for this URL.

Endpoints

The full machine-readable description is the OpenAPI 3.1 file; the result has its own JSON Schema.

Endpoints
Method and pathPurpose
POST /v1/analysesSubmit a URL for analysis.
GET /v1/analyses/{id}Status of one analysis, with the result once completed.
GET /v1/analysesYour analyses, newest first, paginated.
GET /v1/usageLive quota used this month.
GET /v1/knowledgeWhat every metric measures and how to improve it (for your own UI).
GET /v1/knowledge/metrics/{id}The same for one metric.

POST /v1/analyses

Request body (JSON)
FieldRequiredDescription
urlyesAbsolute http or https URL of a publicly reachable page, at most 2048 characters.
languagenoLanguage of names, explanations and recommendations: nl or en. Default en.
page_typenoHint for the kind of page: product, category, cms (cms = content or CMS page). The detected type is returned in the result.

Unknown fields are rejected with 400 invalid_request, so a typo never falls back to a default silently. The body must be JSON (Content-Type: application/json) and at most 16384 bytes.

  • 202 Accepted: a new analysis was created. With a live key this uses one unit of your monthly quota, which is not refunded if the analysis fails.
  • 200 OK: an existing analysis was returned and no quota was used (an Idempotency-Key replay, or the same URL is already queued or running; see Idempotency and duplicates).
  • Both carry a Location header with the path of the analysis.
  • 422: the URL cannot be analyzed (target_not_allowed, target_unresolvable). These are checked before anything is queued.

GET /v1/analyses/{id}

status is queued, running, completed or failed. result is set only when completed; error only when failed. An analysis that fails for a temporary reason goes back to queued and is retried automatically (3 attempts in total) before it is reported as failed; only completed and failed are final. An id that does not belong to your account returns 404.

200 OK, failedjson
{
  "id": "an_9b1d3f5a7c9e1b3d5f7a9c1e",
  "status": "failed",
  "url": "https://shop.example.com/kitchen/mugs",
  "language": "en",
  "page_type_hint": "category",
  "created_at": "2026-11-01T08:59:12.000Z",
  "started_at": "2026-11-01T08:59:14.000Z",
  "completed_at": "2026-11-01T09:03:40.000Z",
  "error": {
    "code": "target_unreachable"
  },
  "result": null
}
error.code values of a failed analysis
error.codeMeaningRetried first
target_not_allowedthe URL, a redirect or a navigation of the page pointed at a non-public addressno
target_unresolvablethe host of the page, or of a redirect target, does not exist in DNSno
target_unreachablethe page could not be loaded: connection refused or reset, TLS handshake failure, or a timeoutyes
target_errorthe site answered, but the page cannot be analyzed: redirect loop, invalid response or an invalid or expired TLS certificateno
internal_errorthe analysis failed on our sideyes
lease_expiredthe analysis was interrupted on our side on every attempt; treat it like internal_erroryes

A page that answers with an HTTP error status (for example 404 or 500) is analyzed as served, not reported as failed. New error.code values may be added within v1: treat an unknown value as a generic failure.

GET /v1/analyses

Newest first. limit is 1-100 (default 20). Pass next_cursor as cursor for the next page; it is null on the last page. Items do not include the result: fetch the analysis for that.

bash
curl -sS "https://api.geo-score.online/v1/analyses?limit=20" \
  -H "Authorization: Bearer $GEO_API_KEY"
200 OKjson
{
  "data": [
    {
      "id": "an_5c2e9a7f1b3d4e6a8c0f2b4d",
      "status": "completed",
      "url": "https://shop.example.com/products/blue-ceramic-mug",
      "language": "en",
      "page_type_hint": "product",
      "created_at": "2026-11-01T08:59:12.000Z",
      "started_at": "2026-11-01T08:59:14.000Z",
      "completed_at": "2026-11-01T09:00:00.000Z",
      "error": null
    },
    {
      "id": "an_9b1d3f5a7c9e1b3d5f7a9c1e",
      "status": "failed",
      "url": "https://shop.example.com/kitchen/mugs",
      "language": "en",
      "page_type_hint": "category",
      "created_at": "2026-11-01T08:59:12.000Z",
      "started_at": "2026-11-01T08:59:14.000Z",
      "completed_at": "2026-11-01T09:03:40.000Z",
      "error": {
        "code": "target_unreachable"
      }
    }
  ],
  "next_cursor": "MjAyNi0xMS0wMVQwODo1OToxMi4wMDBafGFuXzljMWQzZjVhN2M5ZTFiM2Q1ZjdhOWMxZQ"
}

GET /v1/usage

Live analyses used in the current calendar month (UTC), your monthly limit, your per-minute rate limit and when the quota resets. Test-key analyses are not counted. Example values:

bash
curl -sS https://api.geo-score.online/v1/usage \
  -H "Authorization: Bearer $GEO_API_KEY"
200 OKjson
{
  "period": "2026-11",
  "mode_counts": {
    "live": {
      "used": 120,
      "limit": 1000,
      "remaining": 880
    }
  },
  "rate_limit_per_minute": 60,
  "reset_at": "2026-12-01T00:00:00.000Z"
}

GET /v1/knowledge

Reference content for your own interface: for each of the 22 metrics what it measures, why it matters for AI findability, quick wins and step-by-step improvement plans per score range (with example markup where useful). Add ?language=nl or ?language=en (default en). The content carries no third-party branding and no links to us, so you can show it to your customers as your own.

  • GET /v1/knowledge returns all pillars and metrics; GET /v1/knowledge/metrics/{id} returns one metric. The ids are the metrics[].id values of a result.
  • To show the right plan for an analysis, pick the improvement_steps entry whose min_score-max_score range contains the metric score.
  • Works with test and live keys, never uses quota, and counts toward the per-minute rate limit. The content only changes with methodology_version: cache it per language and methodology version.
bash
curl -sS "https://api.geo-score.online/v1/knowledge/metrics/schema_validator?language=en" \
  -H "Authorization: Bearer $GEO_API_KEY"
200 OKjson
{
  "api_version": "v1",
  "methodology_version": "2026.11.0",
  "language": "en",
  "metric": {
    "id": "schema_validator",
    "name": "Schema Validator",
    "pillar": "technical_basis",
    "explanation": "Whether the JSON-LD structured data on the page is present, valid and complete.",
    "why_it_matters": "Valid, complete schema markup helps AI engines understand your content entities.",
    "quick_wins": [
      {
        "title": "Add Article schema",
        "text": "Add basic Article JSON-LD with headline, author, dates, and description.",
        "impact": "high"
      },
      {
        "title": "Fix validation errors",
        "text": "Test your schema at schema.org validator and fix any errors.",
        "impact": "medium"
      }
    ],
    "improvement_steps": [
      {
        "min_score": 0,
        "max_score": 40,
        "steps": [
          {
            "title": "Add primary schema",
            "text": "Add the most relevant schema type for your page as JSON-LD in the <head>.",
            "difficulty": "medium",
            "code": "<script type=\"application/ld+json\">\n{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"Article\",\n  \"headline\": \"How to Optimize for AI Search Engines\",\n  \"description\": \"Complete guide to GEO optimization\",\n  \"author\": {\n    \"@type\": \"Person\",\n    \"name\": \"Your Name\",\n    \"url\": \"https://yoursite.com/about\"\n  },\n  \"datePublished\": \"2026-01-15\",\n  \"dateModified\": \"2026-04-14\",\n  \"publisher\": {\n    \"@type\": \"Organization\",\n    \"name\": \"Your Site\",\n    \"logo\": { \"@type\": \"ImageObject\", \"url\": \"https://yoursite.com/logo.png\" }\n  }\n}\n</script>"
          },
          {
            "title": "Fill all required properties",
            "text": "Fill in the properties Google lists for your type (for Article: headline, image, author, datePublished, dateModified).",
            "difficulty": "easy",
            "code": null
          },
          {
            "title": "Validate with testing tool",
            "text": "Test at https://validator.schema.org/ and fix any warnings.",
            "difficulty": "easy",
            "code": null
          }
        ]
      },
      {
        "min_score": 41,
        "max_score": 70,
        "steps": [
          {
            "title": "Add additional schemas",
            "text": "Add BreadcrumbList, Organization or other types that match what is on the page.",
            "difficulty": "medium",
            "code": null
          },
          {
            "title": "Complete optional properties",
            "text": "Add image, wordCount, keywords, and other recommended properties.",
            "difficulty": "easy",
            "code": null
          }
        ]
      },
      {
        "min_score": 71,
        "max_score": 100,
        "steps": [
          {
            "title": "Add nested entities",
            "text": "Enrich schema with nested objects (author details, citations, mentions).",
            "difficulty": "medium",
            "code": null
          }
        ]
      }
    ]
  }
}

The result

A completed analysis of the example product page (the exact test-key fixture for this URL):

GET /v1/analyses/{id}, 200 OK, completedjson
{
  "id": "an_5c2e9a7f1b3d4e6a8c0f2b4d",
  "status": "completed",
  "url": "https://shop.example.com/products/blue-ceramic-mug",
  "language": "en",
  "page_type_hint": "product",
  "created_at": "2026-11-01T08:59:12.000Z",
  "started_at": "2026-11-01T08:59:14.000Z",
  "completed_at": "2026-11-01T09:00:00.000Z",
  "error": null,
  "result": {
    "url": "https://shop.example.com/products/blue-ceramic-mug",
    "analyzed_at": "2026-11-01T09:00:00.000Z",
    "api_version": "v1",
    "methodology_version": "2026.11.0",
    "page_type": "product",
    "overall_score": 57,
    "pillars": [
      {
        "id": "content_quality",
        "name": "Content Quality",
        "score": 52
      },
      {
        "id": "ai_readiness",
        "name": "AI Readiness",
        "score": 57
      },
      {
        "id": "technical_basis",
        "name": "Technical Basis",
        "score": 76
      },
      {
        "id": "authority_trust",
        "name": "Authority & Trust",
        "score": 43
      }
    ],
    "metrics": [
      {
        "id": "readability",
        "name": "Readability",
        "pillar": "content_quality",
        "score": 68,
        "status": "warn",
        "explanation": "How easy the text is to read, based on sentence length, word complexity and paragraph structure.",
        "recommendation": "Simplify the sentences that are still long and start or end each section with a clear summary sentence.",
        "priority": "medium"
      },
      {
        "id": "comprehensiveness",
        "name": "Comprehensiveness",
        "pillar": "content_quality",
        "score": 41,
        "status": "fail",
        "explanation": "How deeply the page covers its topic: amount of content, sections, examples, definitions and media.",
        "recommendation": "Cover the subtopics and related questions people have, organise them under clear headings, define key terms and add supporting images or tables.",
        "priority": "high"
      },
      {
        "id": "transition_words",
        "name": "Transition Words",
        "pillar": "content_quality",
        "score": null,
        "status": "not_applicable",
        "explanation": "How well sentences and paragraphs are connected with transition words that make the logical flow explicit.",
        "recommendation": null,
        "priority": null
      },
      {
        "id": "lsi_keywords",
        "name": "LSI Keywords",
        "pillar": "content_quality",
        "score": 57,
        "status": "warn",
        "explanation": "How varied the related terms, synonyms and topic variations on the page are.",
        "recommendation": "Add sections on related concepts and include question-based variations of the main keywords.",
        "priority": "medium"
      },
      {
        "id": "answer_completeness",
        "name": "Answer Completeness",
        "pillar": "content_quality",
        "score": 38,
        "status": "fail",
        "explanation": "Whether the page gives complete, self-contained answers that AI engines can quote directly.",
        "recommendation": "List the main questions the page should answer and give each one a paragraph that fully answers it without needing the rest of the page.",
        "priority": "high"
      },
      {
        "id": "factual_density",
        "name": "Factual Density",
        "pillar": "content_quality",
        "score": 72,
        "status": "warn",
        "explanation": "How many verifiable facts the text contains: figures, dates, named sources and concrete data points.",
        "recommendation": "Give every section at least one concrete data point or verifiable claim and replace outdated figures.",
        "priority": "medium"
      },
      {
        "id": "citability",
        "name": "Citability",
        "pillar": "content_quality",
        "score": null,
        "status": "not_applicable",
        "explanation": "How easily AI engines can lift and quote individual text blocks: self-contained, factual and free of promotional language.",
        "recommendation": null,
        "priority": null
      },
      {
        "id": "ai_optimization",
        "name": "AI Optimization",
        "pillar": "ai_readiness",
        "score": 46,
        "status": "fail",
        "explanation": "Whether the page uses formats AI engines extract easily: questions and answers, definitions, summaries, lists and structured data.",
        "recommendation": "Add a visible FAQ section with short direct answers, phrase headings as questions, and define key terms in an \"X is ...\" format.",
        "priority": "high"
      },
      {
        "id": "structure",
        "name": "Content Structure",
        "pillar": "ai_readiness",
        "score": 62,
        "status": "warn",
        "explanation": "The heading hierarchy, structured data, lists, tables and other structural elements of the page.",
        "recommendation": "Add a clearly headed FAQ section and, for long pages, a table of contents.",
        "priority": "medium"
      },
      {
        "id": "image_alt_text",
        "name": "Image Alt-Text",
        "pillar": "ai_readiness",
        "score": 58,
        "status": "warn",
        "explanation": "How many images have alt text and how descriptive that alt text is.",
        "recommendation": "Some images have no alt text. Add a short description of what each of these images shows.",
        "priority": "medium"
      },
      {
        "id": "platform_specificity",
        "name": "Platform Specificity",
        "pillar": "ai_readiness",
        "score": 52,
        "status": "warn",
        "explanation": "How well the page matches the signals the major AI answer platforms each look for.",
        "recommendation": "Format key content for direct answers: a definition paragraph, a numbered list or a comparison table.",
        "priority": "medium"
      },
      {
        "id": "content_type_matching",
        "name": "Content Type Matching",
        "pillar": "ai_readiness",
        "score": 80,
        "status": "pass",
        "explanation": "Whether the structure of the page matches what is expected for its content type, such as steps for a how-to or a table for a comparison.",
        "recommendation": "Make sure every section follows the expected pattern for the content type.",
        "priority": "low"
      },
      {
        "id": "semantic_clarity",
        "name": "Semantic Clarity",
        "pillar": "ai_readiness",
        "score": 77,
        "status": "pass",
        "explanation": "How unambiguous the language is: vague words, unexplained jargon, unclear references and passive voice.",
        "recommendation": "Check that each paragraph can be understood by someone without prior knowledge.",
        "priority": "low"
      },
      {
        "id": "ai_bot_crawler",
        "name": "AI Bot Access",
        "pillar": "technical_basis",
        "score": 100,
        "status": "pass",
        "explanation": "Whether robots.txt allows the AI crawlers that collect content for AI answers.",
        "recommendation": "Check robots.txt after changes so no new blocks for AI crawlers are added.",
        "priority": "low"
      },
      {
        "id": "schema_validator",
        "name": "Schema Validator",
        "pillar": "technical_basis",
        "score": 84,
        "status": "pass",
        "explanation": "Whether the JSON-LD structured data on the page is present, valid and complete.",
        "recommendation": "Add nested details such as author information and mentioned entities.",
        "priority": "low"
      },
      {
        "id": "sitemap_discoverability",
        "name": "Sitemap & Discoverability",
        "pillar": "technical_basis",
        "score": 70,
        "status": "warn",
        "explanation": "Whether the site has an XML sitemap, whether this page is in it, and how well the page is linked internally.",
        "recommendation": "This page is not listed in the XML sitemap. Add it so crawlers can find it.",
        "priority": "medium"
      },
      {
        "id": "page_speed",
        "name": "Page Speed & Renderability",
        "pillar": "technical_basis",
        "score": 61,
        "status": "warn",
        "explanation": "How fast the page loads and whether its content is available without running JavaScript.",
        "recommendation": "Inline critical CSS, defer the rest, and split or lazy-load non-critical JavaScript.",
        "priority": "medium"
      },
      {
        "id": "freshness",
        "name": "Content Freshness",
        "pillar": "technical_basis",
        "score": 30,
        "status": "fail",
        "explanation": "How recent the content is, based on publication and update dates on the page and in structured data.",
        "recommendation": "No publication or update date was found. Show the dates on the page and add datePublished and dateModified to the structured data.",
        "priority": "high"
      },
      {
        "id": "citations",
        "name": "Citations & Sources",
        "pillar": "authority_trust",
        "score": 22,
        "status": "fail",
        "explanation": "Whether the page refers to authoritative external sources with links, quotes or a reference section.",
        "recommendation": "Link to authoritative external sources such as official bodies, research or major industry publications, and quote experts with attribution.",
        "priority": "high"
      },
      {
        "id": "eeat",
        "name": "E-E-A-T Signals",
        "pillar": "authority_trust",
        "score": 48,
        "status": "fail",
        "explanation": "Signals of experience, expertise, authority and trust: author information, credentials, about and contact pages.",
        "recommendation": "No author information was found. Add an author box with name, role and expertise, ideally with Person structured data.",
        "priority": "high"
      },
      {
        "id": "knowledge_graph",
        "name": "Knowledge Graph Readiness",
        "pillar": "authority_trust",
        "score": 66,
        "status": "warn",
        "explanation": "How clearly the page names entities (people, organizations, products, places) and their relationships.",
        "recommendation": "Refer to specific people, organizations and products by name and use sameAs in structured data to link them to their official profiles.",
        "priority": "medium"
      },
      {
        "id": "topical_authority",
        "name": "Topical Authority",
        "pillar": "authority_trust",
        "score": null,
        "status": "not_applicable",
        "explanation": "How well the page is embedded in a cluster of related content through internal links, related sections and breadcrumbs.",
        "recommendation": null,
        "priority": null
      }
    ],
    "top_recommendations": [
      {
        "metric_id": "citations",
        "title": "Cite authoritative sources",
        "recommendation": "Link to authoritative external sources such as official bodies, research or major industry publications, and quote experts with attribution.",
        "priority": "high"
      },
      {
        "metric_id": "ai_optimization",
        "title": "Add questions, answers and definitions",
        "recommendation": "Add a visible FAQ section with short direct answers, phrase headings as questions, and define key terms in an \"X is ...\" format.",
        "priority": "high"
      },
      {
        "metric_id": "eeat",
        "title": "Add author information",
        "recommendation": "No author information was found. Add an author box with name, role and expertise, ideally with Person structured data.",
        "priority": "high"
      },
      {
        "metric_id": "comprehensiveness",
        "title": "Add depth to the content",
        "recommendation": "Cover the subtopics and related questions people have, organise them under clear headings, define key terms and add supporting images or tables.",
        "priority": "high"
      },
      {
        "metric_id": "structure",
        "title": "Improve the structure",
        "recommendation": "Add a clearly headed FAQ section and, for long pages, a table of contents.",
        "priority": "medium"
      }
    ]
  }
}
Result fields
FieldDescription
urlThe analyzed URL, after normalization (see Idempotency and duplicates).
analyzed_atWhen the analysis ran (ISO 8601, UTC).
api_versionAlways v1 in this version.
methodology_versionVersion of the scoring method (YYYY.MM.patch). It changes when weights, thresholds or how a metric is measured change. A score can move because the method changed rather than the page: compare scores over time only within the same methodology version, or show the change.
page_typeDetected page type, which decides which metrics apply and how they are weighted. Current values: blog-post, listicle, how-to, comparison, pillar-page, faq, case-study, knowledge-base, product, service, landing-page, pricing, category, homepage, about, contact, legal, team, portfolio. New values may be added within v1: handle unknown values.
overall_scoreInteger 0-100: the weighted score over all applicable metrics.
pillarsAlways the 4 pillars, in a fixed order: id, localized name, score (0-100).
metricsAlways all 22 metrics, in a fixed order (table below).
top_recommendationsUp to 5 recommendations for metrics with status fail or warn, ranked by how much fixing them can raise the overall score: metric_id, title, recommendation, priority. Empty when nothing is below pass.
Fields of each metric
FieldDescription
idStable identifier (snake_case). Use it, not name, as a key.
nameLocalized display name.
pillarPillar id the metric belongs to.
scoreInteger 0-100, or null when the metric does not apply.
statuspass (score 75 or higher), warn (50-74), fail (below 50) or not_applicable.
explanationOne sentence on what the metric measures (localized).
recommendationWhat to do (localized). For pass a short advice to keep it that way; null when not_applicable.
priorityhigh (fail), medium (warn), low (pass), null when not_applicable.

not_applicable means the metric does not apply to this page type (for example some content metrics on a product page). It has no score and is left out of the weighting; show it as "not applicable", never as 0.

The 22 metrics, in result order
idpillarWhat it measures
readabilitycontent_qualityHow easy the text is to read, based on sentence length, word complexity and paragraph structure.
comprehensivenesscontent_qualityHow deeply the page covers its topic: amount of content, sections, examples, definitions and media.
transition_wordscontent_qualityHow well sentences and paragraphs are connected with transition words that make the logical flow explicit.
lsi_keywordscontent_qualityHow varied the related terms, synonyms and topic variations on the page are.
answer_completenesscontent_qualityWhether the page gives complete, self-contained answers that AI engines can quote directly.
factual_densitycontent_qualityHow many verifiable facts the text contains: figures, dates, named sources and concrete data points.
citabilitycontent_qualityHow easily AI engines can lift and quote individual text blocks: self-contained, factual and free of promotional language.
ai_optimizationai_readinessWhether the page uses formats AI engines extract easily: questions and answers, definitions, summaries, lists and structured data.
structureai_readinessThe heading hierarchy, structured data, lists, tables and other structural elements of the page.
image_alt_textai_readinessHow many images have alt text and how descriptive that alt text is.
platform_specificityai_readinessHow well the page matches the signals the major AI answer platforms each look for.
content_type_matchingai_readinessWhether the structure of the page matches what is expected for its content type, such as steps for a how-to or a table for a comparison.
semantic_clarityai_readinessHow unambiguous the language is: vague words, unexplained jargon, unclear references and passive voice.
ai_bot_crawlertechnical_basisWhether robots.txt allows the AI crawlers that collect content for AI answers.
schema_validatortechnical_basisWhether the JSON-LD structured data on the page is present, valid and complete.
sitemap_discoverabilitytechnical_basisWhether the site has an XML sitemap, whether this page is in it, and how well the page is linked internally.
page_speedtechnical_basisHow fast the page loads and whether its content is available without running JavaScript.
freshnesstechnical_basisHow recent the content is, based on publication and update dates on the page and in structured data.
citationsauthority_trustWhether the page refers to authoritative external sources with links, quotes or a reference section.
eeatauthority_trustSignals of experience, expertise, authority and trust: author information, credentials, about and contact pages.
knowledge_graphauthority_trustHow clearly the page names entities (people, organizations, products, places) and their relationships.
topical_authorityauthority_trustHow well the page is embedded in a cluster of related content through internal links, related sections and breadcrumbs.

Polling

Polling is a fully supported way to get results; webhooks are optional. Every request counts against your per-minute rate limit, so poll calmly:

  • Store the analysis id when you submit. Poll each open analysis with GET /v1/analyses/{id} from a scheduled job, not in a tight loop.
  • Wait at least 10 seconds before the first poll and increase the interval for analyses that stay open (for example 10 s, 20 s, 40 s, then every few minutes).
  • Stop polling once status is completed or failed. Results do not change afterwards: store them on your side.
  • Watch RateLimit-Remaining and pause when it reaches 0 (see Rate limits and quota).

Webhooks

Optional. We send a webhook when an analysis reaches a final state: analysis.completed or analysis.failed. The webhook URL is configured per account by us; it must be https on port 443 at a public address. Together with the URL you get a webhook secret (whsec_…), shown once, for verifying signatures. Webhooks are sent for test-key analyses too, so you can test your endpoint without using quota (data.mode is then test).

Events are thin: they say which analysis changed (data.url is the URL as you submitted it). Fetch the full result with GET /v1/analyses/{id}.

analysis.completedjson
{
  "id": "evt_4a6c8e0b2d4f6a8c0e2b4d6f",
  "type": "analysis.completed",
  "created_at": "2026-11-01T09:00:00.000Z",
  "data": {
    "analysis_id": "an_5c2e9a7f1b3d4e6a8c0f2b4d",
    "mode": "live",
    "url": "https://shop.example.com/products/blue-ceramic-mug",
    "status": "completed",
    "overall_score": 57
  }
}
analysis.failedjson
{
  "id": "evt_7e9a1c3e5a7c9e1b3d5f7a9c",
  "type": "analysis.failed",
  "created_at": "2026-11-01T09:03:40.000Z",
  "data": {
    "analysis_id": "an_9b1d3f5a7c9e1b3d5f7a9c1e",
    "mode": "live",
    "url": "https://shop.example.com/kitchen/mugs",
    "status": "failed",
    "error_code": "target_unreachable"
  }
}
Webhook request
PartValue
Method and bodyPOST, Content-Type: application/json, the event above.
User-AgentGEOScoreBot-Webhook/1.0
Webhook-IdEvent id, the same as id in the body. Deduplicate on it.
Webhook-TimestampUnix time (seconds) of signing.
Webhook-Signaturet=<unix seconds>,v1=<signature>: the lowercase hex HMAC-SHA256 of <t>.<raw body>, keyed with your full webhook secret as UTF-8 (including the whsec_ prefix; do not decode it). t equals Webhook-Timestamp. During a secret rotation the header can carry several v1 values: accept a match on any one, and ignore keys you do not know.
  • Verify the signature over the raw body before parsing it, compare in constant time, and reject when the timestamp is more than 300 seconds from your clock.
  • Any 2xx answer within 10 seconds marks the event delivered; we do not read the response body. Anything else, a timeout or a redirect (redirects are never followed) is retried after 1m, 5m, 30m, 2h, 6h, 12h, 24h: 8 attempts in total, then the event is dropped. Polling still works for such analyses.
  • Answer fast: store the event and process it in the background.
  • Delivery is at least once and not ordered: an event can arrive more than once. Use Webhook-Id to deduplicate and the analysis itself as the source of truth.
Verify a webhook (PHP)php
<?php
declare(strict_types=1);

/**
 * @param string $rawBody         the request body exactly as received
 * @param string $signatureHeader the Webhook-Signature header
 */
function verifyPageAnalysisWebhook(string $rawBody, string $signatureHeader, string $secret, int $tolerance = 300): bool
{
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $signatureHeader) as $part) {
        $pair = explode('=', trim($part), 2);
        if (count($pair) !== 2) {
            continue;
        }
        if ($pair[0] === 't') {
            $timestamp = $pair[1];
        } elseif ($pair[0] === 'v1') {
            $signatures[] = $pair[1];
        }
    }
    if ($timestamp === null || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}
verify-webhook.js (Node.js)javascript
import crypto from 'node:crypto'

// rawBody: the request body exactly as received (string or Buffer), before any JSON parsing.
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  if (typeof signatureHeader !== 'string') return false
  const pairs = signatureHeader.split(',').map((part) => part.trim().split('='))
  const t = pairs.find(([k]) => k === 't')?.[1]
  const signatures = pairs.filter(([k]) => k === 'v1').map(([, v]) => v)
  if (!t || !/^\d+$/.test(t)) return false
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
  return signatures.some(
    (sig) => /^[0-9a-f]{64}$/.test(sig) && crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected)
  )
}
Express endpoint (Node.js)javascript
import express from 'express'
import { verifyWebhook } from './verify-webhook.js'

const app = express()

// Keep the raw body: the signature covers the exact bytes we sent.
app.post('/webhooks/page-analysis', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhook(req.body.toString('utf8'), req.get('Webhook-Signature'), process.env.PAGE_ANALYSIS_WEBHOOK_SECRET)
  if (!ok) return res.status(400).end()

  const event = JSON.parse(req.body.toString('utf8'))
  // Deduplicate on the event id, hand off to a queue, answer at once.
  enqueueFetch(event.id, event.data.analysis_id)
  res.status(204).end()
})

Rate limits and quota

  • Rate limit: a number of requests per minute for your account, counted over all endpoints and all your keys of the same mode, in fixed one-minute windows. Test keys and live keys have separate windows, so testing never slows down your live integration. Every authenticated response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window ends).
  • Over the limit: 429 with code rate_limited and a Retry-After header (seconds). Wait that long, then continue.
  • Monthly quota: a number of live analyses per calendar month (UTC). Each new live analysis (202) uses one unit when it is accepted, also if the analysis later fails (failed analyses are not refunded). Returned existing analyses (200) and test-key analyses do not use quota.
  • Quota used up: 429 with code quota_exceeded, limit, reset_at and a Retry-After header until the start of the next month. Stop submitting until then; reading analyses keeps working.
  • Both limits return 429: always branch on code, not on the status alone.
  • GET /v1/usage shows the current numbers.

Errors

Errors are application/problem+json (RFC 9457) with a stable code. Branch on code; title and detail are for humans and may be reworded. Every response, including errors, has an X-Request-Id header (also request_id in the body): include it when you contact us.

400 Bad Requestjson
{
  "type": "/v1/errors/invalid_request",
  "title": "Invalid Request",
  "status": 400,
  "detail": "The request is invalid. See errors for details.",
  "code": "invalid_request",
  "request_id": "req_6f1d2c3b4a5948e7b8c9d0e1f2a3b4c5",
  "errors": [
    {
      "field": "url",
      "message": "Must be an absolute http or https URL."
    },
    {
      "field": "language",
      "message": "Must be one of: nl, en."
    },
    {
      "field": "page_type",
      "message": "Must be one of: product, category, cms."
    }
  ]
}
Error codes
HTTPcodeWhat to do
400invalid_requestA field or parameter is invalid; errors lists each field with a message. Fix the request; do not retry it unchanged.
401unauthorizedMissing, malformed, unknown or revoked key. Check the Authorization: Bearer … header. Do not retry with the same key.
403forbiddenYour API account is suspended. Contact us; retrying does not help.
404not_foundUnknown path, or an analysis id that does not exist in your account.
405method_not_allowedThe path exists but not for this method. The Allow header lists the supported methods.
409idempotency_conflictYou reused an Idempotency-Key for a different request. Use a new key for a new analysis.
413payload_too_largeThe body is larger than 16384 bytes.
415unsupported_media_typeSend the body as JSON with Content-Type: application/json.
422target_not_allowedThe URL points to a private, local or reserved address, or uses a port other than 80/443. Such URLs are never analyzed. Do not retry.
422target_unresolvableThe host name does not exist in DNS. Check the URL (typo, expired domain). Retry only after the DNS is fixed.
429quota_exceededYour monthly live quota is used up. reset_at (and Retry-After) tell you when it resets. Test keys keep working.
429rate_limitedToo many requests in the current minute. Wait Retry-After seconds, then continue.
500internal_errorSomething failed on our side. Retry later with backoff and include request_id if you contact us.

Idempotency and duplicates

  • Idempotency-Key header (optional, 1-255 visible ASCII characters): sending the same key with the same request returns the original analysis (200) without using quota, so a retry after a timeout never creates a second analysis.
  • The same key with a different URL, language or page type returns 409 idempotency_conflict.
  • Keys are scoped per mode: the same Idempotency-Key used with a test key and with a live key refers to two separate analyses.
  • Keys do not expire: a key keeps returning the same analysis. Use a new key for every new analysis, for example your entity id plus the date or version you analyze.
  • Without a key, submitting a URL that is still queued or running for your account in the same language returns that analysis (200) instead of starting a second one. Once it is final, the same URL starts a new analysis.

For that comparison the URL is normalized: scheme and host lowercased, default port and #fragment removed, utm_*, gclid and fbclid parameters removed, other query parameters sorted by name, and a bare trailing / on the root removed. The path is kept as is (it is case sensitive).

Test mode

  • Test keys (gs_test_…) return a deterministic fixture result. The analysis is completed as soon as it is created, the URL is never fetched and no quota is used.
  • There are four fixtures: product page, category page, CMS page and blog post. page_type product, category or cms picks that fixture; without a hint the URL decides, and the same URL always gets the same fixture.
  • URL validation is real: a private address returns 422 target_not_allowed and a host that does not exist 422 target_unresolvable, as with a live key.
  • The rate limit applies to test keys as well, in a window separate from your live keys; quota does not.
  • If a webhook is configured, test-key analyses send analysis.completed like live ones, with data.mode set to test.
  • Test and live analyses are fully separate: a test key only sees test analyses (GET /v1/analyses and GET /v1/analyses/{id}) and a live key only live ones; an analysis of the other mode is a 404. A test submission never returns a live analysis and the other way round.

Versioning

  • The version is in the path: /v1. Breaking changes are made only in a new version (/v2), never in v1.
  • Within v1 we may add: optional request fields, response fields, page_type values, error codes and webhook event types. Ignore fields and values you do not know.
  • The 22 metric ids, the 4 pillar ids and the meaning of the existing fields do not change within v1.
  • methodology_version changes when the scoring method changes; that is not an API change, but scores can shift (see The result).

Magento 2 integration guide

A Magento 2 module talks to the API from the server only: from cron jobs or queue consumers, never from storefront JavaScript and never during a storefront request. The outline below uses Magento's standard building blocks; adapt names to your module.

1. Store the key encrypted. Use an obscure field with the Encrypted backend model, so the key is encrypted in core_config_data and masked in the admin. Decrypt it with EncryptorInterface when you build the request.

xml
<!-- etc/adminhtml/system.xml -->
<section id="page_analysis" translate="label" sortOrder="900" showInDefault="1">
    <label>Page Analysis API</label>
    <tab>service</tab>
    <resource>Vendor_PageAnalysis::config</resource>
    <group id="api" translate="label" sortOrder="10" showInDefault="1">
        <label>API</label>
        <field id="api_key" translate="label" type="obscure" sortOrder="10" showInDefault="1">
            <label>API key</label>
            <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
        </field>
        <field id="webhook_secret" translate="label" type="obscure" sortOrder="20" showInDefault="1">
            <label>Webhook secret</label>
            <backend_model>Magento\Config\Model\Config\Backend\Encrypted</backend_model>
        </field>
    </group>
</section>

2. A small API client with Magento's HTTP client. Set a timeout and never log the key or the Authorization header.

Model/ApiClient.phpphp
<?php
declare(strict_types=1);

namespace Vendor\PageAnalysis\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Framework\HTTP\Client\CurlFactory;
use Magento\Framework\Serialize\Serializer\Json;

class ApiClient
{
    private const BASE_URL = 'https://api.geo-score.online/v1';

    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig,
        private readonly EncryptorInterface $encryptor,
        private readonly CurlFactory $curlFactory,
        private readonly Json $json
    ) {
    }

    /** POST /analyses. Returns [httpStatus, decodedBody, headers]. */
    public function submit(string $url, string $pageType, string $idempotencyKey, string $language = 'en'): array
    {
        $curl = $this->client();
        $curl->addHeader('Content-Type', 'application/json');
        $curl->addHeader('Idempotency-Key', $idempotencyKey);
        $curl->post(self::BASE_URL . '/analyses', $this->json->serialize([
            'url' => $url,
            'language' => $language,
            'page_type' => $pageType, // 'product', 'category' or 'cms'
        ]));
        return $this->response($curl);
    }

    /** GET /analyses/{id}. */
    public function fetch(string $analysisId): array
    {
        $curl = $this->client();
        $curl->get(self::BASE_URL . '/analyses/' . rawurlencode($analysisId));
        return $this->response($curl);
    }

    private function client(): \Magento\Framework\HTTP\Client\Curl
    {
        // Stored encrypted (backend model Encrypted): decrypt before use. Never log it.
        $apiKey = $this->encryptor->decrypt(
            (string) $this->scopeConfig->getValue('page_analysis/api/api_key')
        );
        $curl = $this->curlFactory->create();
        $curl->setTimeout(15);
        $curl->addHeader('Authorization', 'Bearer ' . $apiKey);
        return $curl;
    }

    private function response(\Magento\Framework\HTTP\Client\Curl $curl): array
    {
        $body = $curl->getBody();
        return [$curl->getStatus(), $body === '' ? null : $this->json->unserialize($body), $curl->getHeaders()];
    }
}

3. Queue what to analyze in your own table (entity type and id, store, URL, page_type, idempotency key, analysis id, state, next attempt). Submit product pages with page_type product, category pages with category and CMS pages with cms. Use the storefront URL of the store view the customer sees (frontend URL, not an admin URL), and build the idempotency key from the entity and its version, e.g. product-1042-<updated_at>, so a retry never costs quota twice.

4. Cron is the first-class path: one job submits pending rows in small batches, one polls open analyses. Respect the rate limit and stop the batch on 429:

xml
<!-- etc/crontab.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="default">
        <job name="page_analysis_submit" instance="Vendor\PageAnalysis\Cron\Submit" method="execute">
            <schedule>*/5 * * * *</schedule>
        </job>
        <job name="page_analysis_poll" instance="Vendor\PageAnalysis\Cron\Poll" method="execute">
            <schedule>*/5 * * * *</schedule>
        </job>
    </group>
</config>
Handling submit responsesphp
// Inside Cron\Submit::execute(), per pending row (a small batch, oldest first):
try {
    [$status, $body, $headers] = $this->apiClient->submit($row->getUrl(), $row->getPageType(), $row->getIdempotencyKey());
} catch (\Exception $e) { // network error or timeout: same idempotency key next time, so no double quota
    $row->setNextAttemptAt($this->in($this->backoff($row->getAttempts())));
    continue;
}
$headers = array_change_key_case($headers, CASE_LOWER);

switch (true) {
    case $status === 200 || $status === 202:
        $row->setAnalysisId($body['id'])->setState('submitted')->setNextPollAt($this->in(30));
        break;
    case $status === 422: // target_not_allowed / target_unresolvable: not analyzable as it is
        $row->setState('not_analyzable')->setErrorCode($body['code']);
        break;
    case $status === 429 && $body['code'] === 'rate_limited':
        $this->pauseUntil($this->in((int) ($headers['retry-after'] ?? 60)));
        return; // stop this batch, the rest stays pending
    case $status === 429 && $body['code'] === 'quota_exceeded':
        $this->pauseUntil(new \DateTimeImmutable($body['reset_at']));
        return;
    case $status >= 500: // our side: retry later with backoff
        $row->setNextAttemptAt($this->in($this->backoff($row->getAttempts())));
        break;
    default: // 400/401/403/409/413/415: a bug or a configuration problem, log it and alert
        $row->setState('error')->setErrorCode($body['code'] ?? (string) $status);
}

422 target_not_allowed and 422 target_unresolvable mean the URL cannot be analyzed as it is, typically a staging or local base URL, a store behind basic auth or a domain that does not resolve. Mark the row and show the reason in the admin instead of retrying.

5. Polling cron: for each row with an analysis id that is not final and whose next poll time has passed, call fetch(). Store result when completed, store error.code when failed, otherwise set the next poll time with a growing interval (see Polling).

6. Optional webhook controller: a frontend POST route that verifies the signature with the function from Webhooks, marks the analysis for the poll cron and answers 204 at once. Send us the URL; it must be public HTTPS on port 443.

Controller/Webhook/Index.phpphp
<?php
declare(strict_types=1);

namespace Vendor\PageAnalysis\Controller\Webhook;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\Http as HttpRequest;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\RawFactory;
use Vendor\PageAnalysis\Model\AnalysisRepository;
use Vendor\PageAnalysis\Model\SignatureVerifier; // wraps verifyPageAnalysisWebhook() from above
use Vendor\PageAnalysis\Model\WebhookSecret;     // decrypts the "Webhook secret" config field

class Index implements HttpPostActionInterface, CsrfAwareActionInterface
{
    public function __construct(
        private readonly HttpRequest $request,
        private readonly RawFactory $rawFactory,
        private readonly SignatureVerifier $verifier,
        private readonly WebhookSecret $secret,
        private readonly AnalysisRepository $analyses
    ) {
    }

    public function execute()
    {
        $raw = (string) $this->request->getContent();
        $signature = (string) $this->request->getHeader('Webhook-Signature');
        $result = $this->rawFactory->create();

        if (!$this->verifier->verify($raw, $signature, $this->secret->get())) {
            return $result->setHttpResponseCode(400);
        }
        $event = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
        // Idempotent: a repeated event id is a no-op. Do not process here, just mark for the poll cron.
        $this->analyses->markReadyToFetch($event['data']['analysis_id'], $event['id']);
        return $result->setHttpResponseCode(204);
    }

    // The signature replaces Magento's form key check for this machine-to-machine endpoint.
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null;
    }

    public function validateForCsrf(RequestInterface $request): ?bool
    {
        return true;
    }
}
  • Use a gs_test_… key on development and staging installations and a gs_live_… key only in production.
  • Present results with your own wording around them; use the metric id as a key and the localized name, explanation and recommendation as display text.