{
  "openapi": "3.1.0",
  "info": {
    "title": "Alpha Tools — public endpoints",
    "version": "2026-08-21",
    "summary": "The three public, UI-backed endpoints behind alpha-tools.no: AI-visibility scanning, storefront health checks, and verified Product/Offer/FAQ JSON-LD generation.",
    "description": "These endpoints back the free tools on https://www.alpha-tools.no. They are **UI-backed, rate-limited and date-versioned** — they exist to serve this site's own interface, and the version date is the whole of what is promised about their shape.\n\n- No authentication and no API key. CORS is not enabled: they are same-origin endpoints for the site's UI.\n- Free to use (NOK 0), like the tools themselves. Please be gentle — every GEO scan spends real LLM tokens.\n- Not suitable for bulk or automated mass scanning. If you need that, talk to us instead: kontakt@alpha-tools.no.\n\n- **Versioning.** Every response carries `X-API-Version: 2026-08-21`. The request header `X-API-Version` is optional: absent — or empty/whitespace-only, which is treated as absent — or exactly `2026-08-21` gets current behaviour, and any other value is refused, before the request body is read, with `400 {\"error\": \"unsupported_version\", \"supported\": [\"2026-08-21\"]}`. That refusal is answered ahead of the limiters, so it carries `X-API-Version` but no `RateLimit-*` headers. On a non-POST method the 405 wins — the method handlers do not version-check. A breaking change ships as a new date; the previous date keeps answering for at least 30 days and the change is announced on /developers. Deprecated versions answer with `Deprecation` and `Sunset` headers — none are deprecated today. Non-breaking additions (new fields, new event types) do not change the date.\n- **Errors are always JSON.** Every error under `/api/` answers `application/json` with an `ApiError` body: a stable machine-readable `error` code plus an optional human `message`. A non-POST method on any endpoint here answers `405 {\"error\": \"method_not_allowed\", \"allow\": [\"POST\"], \"message\": …}` with an `Allow: POST` header — POST is the only method that does anything, and that `Allow` is the authoritative statement of it. (`OPTIONS` is answered by the framework itself: `204` with an `Allow` that lists every exported handler, which is wider than what actually works.) Any undocumented path under `/api/` answers `404 {\"error\": \"not_found\", \"message\": …, \"docs\": …}` — never an HTML error page. The Schema Emitter keeps its own `{\"ok\": false, \"reason\": …}` shape for engine-level outcomes; the version, method and unknown-path refusals above use `ApiError` there too.\n- **Rate-limit headers.** Every response from the three endpoints carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`, describing **the limiter that governed that particular response** — the cheap front gate when that is what refused it, the scarce scan/generation budget otherwise. A `429` adds `Retry-After`, which is the authoritative wait; `RateLimit-Remaining` is forced to `0` on any `429`, and `RateLimit-Reset` can be `0` when the refusal came from a global daily budget rather than from the caller's own window. The `405` is the one case where no limiter governed the response: it reports the endpoint's primary budget as it currently stands, read without spending it, so a `GET` probe can discover the limits without consuming any. The one response without the headers entirely is the `unsupported_version` 400, refused before any limiter ran. Concurrency ceilings are per server instance and are deliberately not expressed here — they are not a per-caller budget.\n\nThe tools report honestly by design, and the responses reflect that:\n\n- The GEO scan produces **no composite 0–100 score**. An engine that failed or returned nothing is reported as *not measured* and kept out of every denominator — never counted as 'no mention'. Each report is a single dated snapshot, not a trend.\n- The Health Check produces **four independent pillar grades**, not a single score. A bot-protection page is never graded as the store, and a PageSpeed measurement that could not be taken is reported as not measured rather than fabricated.\n- The Schema Emitter follows 'the LLM proposes, code disposes': every model-proposed field must pass deterministic verification against the page's own content, and any field that fails is omitted rather than guessed.",
    "contact": {
      "name": "Alpha Solutions Norge",
      "email": "kontakt@alpha-tools.no",
      "url": "https://www.alpha-tools.no/developers"
    }
  },
  "servers": [
    {
      "url": "https://www.alpha-tools.no",
      "description": "Production"
    }
  ],
  "externalDocs": {
    "description": "How agents should use this site",
    "url": "https://www.alpha-tools.no/developers"
  },
  "x-api-versioning": {
    "strategy": "header",
    "header": "X-API-Version",
    "current": "2026-08-21",
    "supported": [
      "2026-08-21"
    ],
    "policy": "A breaking change ships as a new date; the previous date keeps answering for at least 30 days and the change is announced on /developers. Deprecated versions answer with `Deprecation` and `Sunset` headers — none are deprecated today. Non-breaking additions (new fields, new event types) do not change the date.",
    "emptyHeaderTreatedAsAbsent": true,
    "unsupportedResponse": {
      "status": 400,
      "body": {
        "error": "unsupported_version",
        "supported": [
          "2026-08-21"
        ]
      },
      "headers": {
        "X-API-Version": "present",
        "RateLimit-*": "absent — the version check runs ahead of the limiters"
      },
      "note": "On a non-POST method the 405 wins: the method handlers do not version-check, though they still send X-API-Version."
    }
  },
  "x-catch-all": {
    "description": "Any path under `/api/` that is not documented here answers `404` with an `ApiError` body and `Content-Type: application/json` — never an HTML error page, and never a framework default. The response carries `X-API-Version` like every other one.",
    "pathPrefix": "/api/",
    "status": 404,
    "error": "not_found",
    "schema": {
      "$ref": "#/components/schemas/ApiError"
    }
  },
  "tags": [
    {
      "name": "geo",
      "description": "AI visibility: whether AI answer engines mention and cite a brand."
    },
    {
      "name": "health",
      "description": "Storefront technical health: AI/bot access, speed, schema, robots, links."
    },
    {
      "name": "schema",
      "description": "Verified Product/Offer/FAQ JSON-LD generated from a product page."
    }
  ],
  "paths": {
    "/api/health/scan": {
      "post": {
        "tags": [
          "health"
        ],
        "operationId": "runHealthScan",
        "summary": "Run a storefront health check",
        "description": "Streams a technical audit of a storefront as newline-delimited JSON. The scan runs eight phases (access, robots, sitemap, SEO, links, registry, schema, speed) and grades four independent pillars — revenue-critical, AI readiness, speed, SEO hygiene. There is no composite score. Findings arrive as they are made; the stream ends with a `complete` event carrying the summary, or an `error` event.",
        "parameters": [
          {
            "$ref": "#/components/parameters/ApiVersion"
          }
        ],
        "x-rate-limits": {
          "frontGate": {
            "requests": 30,
            "per": "minute",
            "scope": "client-ip"
          },
          "scans": {
            "requests": 5,
            "per": "minute",
            "scope": "client-ip"
          },
          "concurrency": {
            "requests": 4,
            "scope": "server-instance"
          },
          "policyHeader": {
            "scan": "5;w=60",
            "frontGate": "30;w=60",
            "note": "The `RateLimit-*` headers report whichever of the two governed this response."
          },
          "note": "Rate limiting is in-memory per server instance and is a deterrent, not a quota: the effective limit can vary between instances. The per-window budget is counted per client IP; the concurrency ceiling is shared across all clients on that instance."
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HealthScanRequest"
              },
              "examples": {
                "store": {
                  "value": {
                    "url": "https://www.example-store.no"
                  }
                },
                "b2bStore": {
                  "summary": "A store that sells to businesses",
                  "value": {
                    "url": "https://www.example-store.no",
                    "b2b": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A stream of scan events. Terminal event is `complete` (or `error`).",
            "headers": {
              "Content-Type": {
                "description": "Always `application/x-ndjson; charset=utf-8`.",
                "schema": {
                  "type": "string"
                }
              },
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/x-ndjson": {
                "schema": {
                  "type": "string",
                  "description": "Newline-delimited JSON. Each line is one complete JSON event object with a `type` field; the stream ends after the terminal event. Parse line by line — do not wait for the whole body."
                },
                "x-event-types": [
                  "phase",
                  "finding",
                  "pillar-complete",
                  "complete",
                  "error"
                ]
              }
            }
          },
          "400": {
            "description": "Invalid input, or an API version this deployment does not answer. `error` is `invalid` (not a URL, or whitespace inside one), `protocol` (not http or https), `private` (the hostname is a private literal, or resolves only to a private/internal address — an SSRF-guard refusal, not a fault of the target), or `unsupported_version` (with `supported` listing the versions that are answered). A hostname that will not resolve is deliberately NOT a 400 here: the scan runs and reports honestly that it could not reach the store. The `unsupported_version` case alone is refused ahead of the limiters, so it carries `X-API-Version` but no `RateLimit-*` headers.",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "405": {
            "description": "Method not allowed — `POST` is the only method that does anything here. The body is `{\"error\": \"method_not_allowed\", \"allow\": [\"POST\"], \"message\": …}` and the `Allow: POST` header carries the same fact; it is the authoritative statement of what a caller can use. The `RateLimit-*` headers are present but mean something slightly different here than on a metered response: no limiter governed a refusal on method, so they report the caller's CURRENT standing budget for the endpoint's primary limiter (the scarce scan/generation budget — for the GEO scan, the fresh-scan one), read without spending it. Probing with `GET` is therefore free: it never moves `RateLimit-Remaining`. Two more details worth knowing: an `OPTIONS` request never reaches this response — the framework auto-answers it with `204` and an `Allow` listing every exported handler, which is wider than what actually works — and a request that is BOTH a wrong method and a wrong `X-API-Version` gets this 405, not the version 400, because the method handlers do not version-check (they still send `X-API-Version`).",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "Allow": {
                "$ref": "#/components/headers/Allow"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. `error` is `rate_limited` with a machine-readable `reason` — `rate` for the per-IP window, `capacity` for the instance-wide concurrency ceiling — or `daily_cap` for the global daily AI budget, which carries no `reason`. `Retry-After` is typically typically 1–60 seconds and `RateLimit-Remaining` is `0`.",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/geo/scan": {
      "post": {
        "tags": [
          "geo"
        ],
        "operationId": "runGeoScan",
        "summary": "Measure a brand's visibility across six AI engines",
        "parameters": [
          {
            "$ref": "#/components/parameters/ApiVersion"
          }
        ],
        "description": "Streams an AI-visibility measurement as newline-delimited JSON. The same curated industry prompts are put to six engines — OpenAI/ChatGPT, Anthropic/Claude, Google/Gemini, Perplexity, Mistral and xAI/Grok — and the result reports discovery rate, citation rate, engine coverage and share of voice. Engines that fail or return an empty answer are reported as not measured and excluded from denominators. A repeat scan of the same (domain, vertical, lang) within 72 hours is served from the stored measurement with `result.cached = true` and no LLM spend.",
        "x-rate-limits": {
          "frontGate": {
            "requests": 10,
            "per": "hour",
            "scope": "client-ip"
          },
          "freshScans": {
            "requests": 2,
            "per": "hour",
            "scope": "client-ip"
          },
          "concurrency": {
            "requests": 2,
            "scope": "server-instance"
          },
          "globalDailyCap": {
            "description": "A global daily ceiling on fresh scans across all callers guards LLM spend. Once reached, fresh scans answer 429 with `{\"error\": \"daily_cap\"}` until the next day; cached serves are unaffected."
          },
          "policyHeader": {
            "fresh": "2;w=3600",
            "cached": "10;w=3600",
            "frontGate": "10;w=3600",
            "note": "The `RateLimit-*` headers describe the limiter that governed THIS response: the fresh-scan budget when a scan was actually run, the front gate — which is also the cached-serve budget — otherwise."
          },
          "note": "Rate limiting is in-memory per server instance and is a deterrent, not a quota: the effective limit can vary between instances. The per-window budget is counted per client IP; the concurrency ceiling is shared across all clients on that instance."
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GeoScanRequest"
              },
              "examples": {
                "furniture": {
                  "value": {
                    "brand": "Example Møbler",
                    "domain": "https://www.example-mobler.no",
                    "vertical": "hjem",
                    "competitors": [
                      "Bohus",
                      "Skeidar"
                    ],
                    "lang": "no"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A stream of scan events. Terminal event is `complete` (or `error`). A cached serve is a single `complete` line whose `result.cached` is true.",
            "headers": {
              "Content-Type": {
                "description": "Always `application/x-ndjson; charset=utf-8`.",
                "schema": {
                  "type": "string"
                }
              },
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/x-ndjson": {
                "schema": {
                  "type": "string",
                  "description": "Newline-delimited JSON. Each line is one complete JSON event object with a `type` field; the stream ends after the terminal event. Parse line by line — do not wait for the whole body."
                },
                "x-event-types": [
                  "phase",
                  "audit",
                  "provider-result",
                  "prompt-complete",
                  "metrics",
                  "complete",
                  "error"
                ]
              }
            }
          },
          "400": {
            "description": "Invalid input, or any SSRF-guard failure on the domain — private address, unresolved hostname, or a non-http(s) scheme. Stricter than the health scan on purpose: a typo domain would produce a useless citation report after spending real tokens. `error` is one of `invalid_brand`, `invalid_domain`, `invalid_competitor`, `too_many_competitors`, `invalid_vertical`, `invalid_lang`, a guard reason (`invalid`, `protocol`, `private`, `unresolved`), or `unsupported_version` when `X-API-Version` names a version this deployment does not answer — checked before the body is read, and refused ahead of the limiters, so that one carries `X-API-Version` but no `RateLimit-*` headers.",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "405": {
            "description": "Method not allowed — `POST` is the only method that does anything here. The body is `{\"error\": \"method_not_allowed\", \"allow\": [\"POST\"], \"message\": …}` and the `Allow: POST` header carries the same fact; it is the authoritative statement of what a caller can use. The `RateLimit-*` headers are present but mean something slightly different here than on a metered response: no limiter governed a refusal on method, so they report the caller's CURRENT standing budget for the endpoint's primary limiter (the scarce scan/generation budget — for the GEO scan, the fresh-scan one), read without spending it. Probing with `GET` is therefore free: it never moves `RateLimit-Remaining`. Two more details worth knowing: an `OPTIONS` request never reaches this response — the framework auto-answers it with `204` and an `Allow` listing every exported handler, which is wider than what actually works — and a request that is BOTH a wrong method and a wrong `X-API-Version` gets this 405, not the version 400, because the method handlers do not version-check (they still send `X-API-Version`).",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "Allow": {
                "$ref": "#/components/headers/Allow"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "409": {
            "description": "`scan_in_progress` — the same store, vertical and language is already being scanned on this instance. Retry shortly; the retry will usually be served from cache at no cost. This is a single-flight conflict, not a limiter refusal, so `RateLimit-Remaining` reports the caller's real remaining budget rather than `0`.",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. `error` is `rate_limited` with a machine-readable `reason` — `rate` for the per-IP window, `capacity` for the instance-wide concurrency ceiling — or `daily_cap` for the global daily AI budget, which carries no `reason`. `Retry-After` is typically typically 60–3600 seconds and `RateLimit-Remaining` is `0`.",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/schema/generate": {
      "post": {
        "tags": [
          "schema"
        ],
        "operationId": "generateSchema",
        "summary": "Generate verified Product/Offer/FAQ JSON-LD from a product page",
        "parameters": [
          {
            "$ref": "#/components/parameters/ApiVersion"
          }
        ],
        "description": "Returns a single JSON verdict, not a stream — one bounded fetch plus at most one LLM call has one atomic result. Deterministic extraction reads the page's own JSON-LD, then microdata, then OG/meta tags; only genuine gaps go to a single gated model call, and every proposal must pass deterministic verification against the page's own content before it is emitted. Unverified fields are omitted, never guessed. `Offer` is emitted only when price, currency and availability are all verified; `FAQPage` only with at least one verified Q&A pair.",
        "x-rate-limits": {
          "frontGate": {
            "requests": 30,
            "per": "hour",
            "scope": "client-ip"
          },
          "generations": {
            "requests": 6,
            "per": "hour",
            "scope": "client-ip"
          },
          "concurrency": {
            "requests": 2,
            "scope": "server-instance"
          },
          "policyHeader": {
            "generation": "6;w=3600",
            "frontGate": "30;w=3600",
            "note": "The `RateLimit-*` headers report whichever of the two governed this response."
          },
          "note": "Rate limiting is in-memory per server instance and is a deterrent, not a quota: the effective limit can vary between instances. The per-window budget is counted per client IP; the concurrency ceiling is shared across all clients on that instance."
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmitterRequest"
              },
              "examples": {
                "pdp": {
                  "value": {
                    "url": "https://www.example-store.no/produkt/vare-123"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The emitter verdict. Note that an honest failure (`blocked`, `not-product-page`, `unreachable`, `guard-blocked`) is also a 200 with `ok: false` — the request succeeded, the page could not be turned into schema.",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmitterApiResponse"
                }
              }
            }
          },
          "400": {
            "description": "Two shapes, because two different things can be wrong. A bad target is an engine-level refusal in the emitter's own vocabulary: `{\"ok\": false, \"reason\": \"invalid-url\"}` for a URL that cannot be normalised, `\"guard-failed\"` for any SSRF-guard refusal (private address, unresolved hostname, wrong scheme). A bad `X-API-Version` never reaches the engine and is refused with the site-wide `ApiError`: `{\"error\": \"unsupported_version\", \"supported\": [\"2026-08-21\"]}` — refused ahead of the limiters, so that one carries `X-API-Version` but no `RateLimit-*` headers.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/EmitterFailure"
                    },
                    {
                      "$ref": "#/components/schemas/ApiError"
                    }
                  ]
                }
              }
            },
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              }
            }
          },
          "405": {
            "description": "Method not allowed — `POST` is the only method that does anything here. The body is `{\"error\": \"method_not_allowed\", \"allow\": [\"POST\"], \"message\": …}` and the `Allow: POST` header carries the same fact; it is the authoritative statement of what a caller can use. The `RateLimit-*` headers are present but mean something slightly different here than on a metered response: no limiter governed a refusal on method, so they report the caller's CURRENT standing budget for the endpoint's primary limiter (the scarce scan/generation budget — for the GEO scan, the fresh-scan one), read without spending it. Probing with `GET` is therefore free: it never moves `RateLimit-Remaining`. Two more details worth knowing: an `OPTIONS` request never reaches this response — the framework auto-answers it with `204` and an `Allow` listing every exported handler, which is wider than what actually works — and a request that is BOTH a wrong method and a wrong `X-API-Version` gets this 405, not the version 400, because the method handlers do not version-check (they still send `X-API-Version`).",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "Allow": {
                "$ref": "#/components/headers/Allow"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited — `{\"ok\": false, \"reason\": \"rate-limited\"}`, the emitter's own failure shape. Carries `Retry-After` and the `RateLimit-*` headers.",
            "headers": {
              "X-API-Version": {
                "$ref": "#/components/headers/XApiVersion"
              },
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimitReset"
              },
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimitPolicy"
              },
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmitterFailure"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "ApiVersion": {
        "name": "X-API-Version",
        "in": "header",
        "required": false,
        "description": "Optional. Pin the version of this API you were written against. Absent — or exactly `2026-08-21` — gets current behaviour; any other value is refused with `400 unsupported_version` before the request body is read. A breaking change ships as a new date; the previous date keeps answering for at least 30 days and the change is announced on /developers. Deprecated versions answer with `Deprecation` and `Sunset` headers — none are deprecated today. Non-breaking additions (new fields, new event types) do not change the date.",
        "schema": {
          "type": "string",
          "enum": [
            "2026-08-21"
          ]
        },
        "example": "2026-08-21"
      }
    },
    "headers": {
      "XApiVersion": {
        "description": "The version of this API that produced the response. `2026-08-21` today, on every response — including the NDJSON streams, every error, and the `/api/` catch-all 404.",
        "schema": {
          "type": "string",
          "enum": [
            "2026-08-21"
          ]
        }
      },
      "RateLimitLimit": {
        "description": "Requests permitted in the window of the limiter that governed this response.",
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      },
      "RateLimitRemaining": {
        "description": "Requests left in the current window. Forced to `0` on any `429`, whatever refused it.",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      },
      "RateLimitReset": {
        "description": "Seconds until that window frees a slot. Can be `0` on a `429` that came from a global daily budget rather than from the caller's own window — the caller's window is genuinely empty, the budget is what is spent. On a `429`, prefer `Retry-After`.",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      },
      "RateLimitPolicy": {
        "description": "The governing policy, as `<limit>;w=<window-seconds>`. Which one appears depends on which limiter governed the response: the scarce budget — `5;w=60` for a health scan, `6;w=3600` for a schema generation, `2;w=3600` for a fresh GEO scan — or the cheap front gate that meters every request ahead of DNS: `30;w=60` on the health endpoint, `30;w=3600` on the schema endpoint, `10;w=3600` on the GEO endpoint, which is also what a cached GEO serve reports. The per-instance concurrency ceilings are deliberately not expressed here — they are not a per-caller budget.",
        "schema": {
          "type": "string"
        },
        "examples": [
          "5;w=60",
          "30;w=60",
          "6;w=3600",
          "2;w=3600",
          "10;w=3600"
        ]
      },
      "RetryAfter": {
        "description": "Seconds to wait before retrying. On a `429` this is the authoritative value — prefer it over `RateLimit-Reset`.",
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      },
      "Allow": {
        "description": "On a `405`, the methods that actually work: `POST`. An `OPTIONS` request is answered by the framework's own auto-implementation with `204` and an `Allow` listing every exported handler — wider than this, and not a statement of what a caller can usefully call.",
        "schema": {
          "type": "string",
          "enum": [
            "POST"
          ]
        }
      }
    },
    "schemas": {
      "ApiError": {
        "description": "The typed error body behind every JSON error under `/api/` — the three documented endpoints and the catch-all 404 alike. Branch on `error`; it is the stable part.",
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Stable, machine-readable reason code. Never branch on `message` — that wording is free to change.",
            "examples": [
              "method_not_allowed",
              "not_found",
              "unsupported_version",
              "rate_limited",
              "daily_cap",
              "scan_in_progress",
              "private",
              "invalid_domain"
            ]
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation, for a log line or a developer. Not part of the contract."
          },
          "allow": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "`method_not_allowed` only: the methods this endpoint accepts. Mirrors the `Allow` response header.",
            "examples": [
              [
                "POST"
              ]
            ]
          },
          "supported": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "`unsupported_version` only: the API versions this deployment answers."
          },
          "reason": {
            "type": "string",
            "description": "`rate_limited` only: `rate` when the per-IP window is spent, `capacity` when the instance-wide concurrency ceiling is full. Absent on `daily_cap`, which is a budget rather than a limiter.",
            "enum": [
              "rate",
              "capacity"
            ]
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "`not_found` only: the absolute URL where the documented endpoints are listed.",
            "examples": [
              "https://www.alpha-tools.no/developers"
            ]
          }
        }
      },
      "EmitterFailure": {
        "description": "The Schema Emitter's own failure shape — kept because it is the engine's vocabulary, not an HTTP one: the same `reason` values describe an honest 200 outcome (`blocked`, `not-product-page`, `unreachable`, `guard-blocked`) and a request-level refusal (`invalid-url`, `guard-failed`, `rate-limited`). Method, version and unknown-path refusals on this endpoint use `ApiError` like everywhere else.",
        "type": "object",
        "required": [
          "ok",
          "reason"
        ],
        "properties": {
          "ok": {
            "const": false
          },
          "reason": {
            "type": "string",
            "enum": [
              "unreachable",
              "guard-blocked",
              "blocked",
              "not-product-page",
              "invalid-url",
              "guard-failed",
              "rate-limited"
            ],
            "description": "`unreachable` = the page did not answer. `guard-blocked` = it answered, but a hop was refused by our own SSRF guard. `blocked` = the site's bot protection stopped us. `not-product-page` = it answered, but it is not a PDP. `invalid-url`/`guard-failed`/`rate-limited` are request-level refusals (400/429)."
          },
          "lowText": {
            "type": "boolean",
            "description": "Set on `not-product-page` when the page carried too little text to judge — often a JS-rendered PDP."
          }
        }
      },
      "HealthScanRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "The storefront to check. A bare hostname is normalised to https. Must resolve to a public address.",
            "examples": [
              "https://www.example-store.no",
              "example-store.no"
            ]
          },
          "b2b": {
            "type": "boolean",
            "default": false,
            "description": "Whether the store sells to businesses. `true` adds the B2B-only `ehf-capability` finding — is the company's organisation number listed in the Peppol Directory as an EHF receiver, and for which document types (invoice / order / catalogue). Strictly the JSON boolean `true`; any other value, or omitting the field, reads as `false`: no Peppol lookup and no EHF row. Company identity (Brreg) and the login-wall check run regardless. Optional and additive — no API version change."
          }
        }
      },
      "GeoScanRequest": {
        "type": "object",
        "required": [
          "brand",
          "domain",
          "vertical"
        ],
        "properties": {
          "brand": {
            "type": "string",
            "maxLength": 60,
            "description": "The brand or store name, as a person would write it. Letters, digits, spaces and light punctuation only — control characters and anything URL-shaped are rejected. Used for mention matching and share-of-voice identity; pack prompts are fixed strings and do not interpolate this value."
          },
          "domain": {
            "type": "string",
            "description": "The store's domain or origin. Used for citation matching and for the crawler-access audit."
          },
          "vertical": {
            "type": "string",
            "enum": [
              "elektronikk",
              "mote",
              "hjem",
              "sport",
              "skjonnhet",
              "mat",
              "barn",
              "bygg",
              "dyr",
              "bil",
              "apotek",
              "boker",
              "smykker",
              "blomster",
              "saas",
              "byra",
              "reise",
              "eiendom",
              "finans",
              "helse",
              "utdanning",
              "jus",
              "elgrossist",
              "byggproff",
              "vvsgrossist",
              "industri",
              "itbedrift",
              "arbeidsklar"
            ],
            "description": "Which curated prompt pack to run. The generic fallback pack id `generisk` is also accepted, though the site's own picker does not offer it."
          },
          "competitors": {
            "type": "array",
            "maxItems": 3,
            "items": {
              "type": "string",
              "maxLength": 60
            },
            "description": "Up to 3 competitor names for the share-of-voice comparison. Same name rules as `brand`. Optional; empty entries are ignored."
          },
          "lang": {
            "type": "string",
            "enum": [
              "no",
              "en"
            ],
            "default": "no",
            "description": "The language the prompts are put in."
          }
        }
      },
      "EmitterRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "A single product page (PDP) URL. Must resolve to a public address."
          }
        }
      },
      "EmitterApiResponse": {
        "description": "Either a successful emitter result, or an honest failure. Modelled loosely: the engine's own type is a discriminated union on `ok`.",
        "oneOf": [
          {
            "type": "object",
            "required": [
              "ok",
              "result"
            ],
            "properties": {
              "ok": {
                "const": true
              },
              "result": {
                "$ref": "#/components/schemas/EmitterResult"
              }
            }
          },
          {
            "$ref": "#/components/schemas/EmitterFailure"
          }
        ]
      },
      "EmitterResult": {
        "type": "object",
        "required": [
          "version",
          "url",
          "finalUrl",
          "fetchedAt",
          "mode",
          "fields",
          "jsonld",
          "offerEmitted",
          "entityType",
          "variantsDetected",
          "gaps",
          "llm"
        ],
        "properties": {
          "version": {
            "const": 1
          },
          "url": {
            "type": "string",
            "description": "The URL as requested."
          },
          "finalUrl": {
            "type": "string",
            "description": "The URL after redirects."
          },
          "fetchedAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the page was read — the pack is a dated snapshot, not a feed."
          },
          "mode": {
            "type": "string",
            "enum": [
              "generate",
              "enrich"
            ],
            "description": "`enrich` when the page already carries valid Product JSON-LD: the primary artifact is then a diff of the missing fields, not a duplicate block."
          },
          "fields": {
            "type": "array",
            "description": "One entry per field, in a fixed order: name, description, brand, image, sku, gtin, price, currency, availability, faq.",
            "items": {
              "$ref": "#/components/schemas/ExtractedField"
            }
          },
          "jsonld": {
            "type": "object",
            "description": "The emitted JSON-LD as strings. `null` where the honesty rules blocked emission.",
            "properties": {
              "product": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "faq": {
                "type": [
                  "string",
                  "null"
                ]
              }
            },
            "required": [
              "product",
              "faq"
            ]
          },
          "offerEmitted": {
            "type": "boolean",
            "description": "True only when price, currency and availability were all verified. Derived from the same predicate that decides the JSON-LD, so the flag cannot drift from the output."
          },
          "entityType": {
            "type": "string",
            "enum": [
              "Product",
              "ProductGroup"
            ],
            "description": "Always `Product` in v1. Variants are disclosed via `variantsDetected`, never as an entity upgrade — a ProductGroup carrying one flat Offer would misrepresent a variant product."
          },
          "variantsDetected": {
            "type": "boolean"
          },
          "gaps": {
            "type": "array",
            "description": "Enrich mode: the diff against Google's required + recommended fields.",
            "items": {
              "type": "object",
              "required": [
                "field",
                "state"
              ],
              "properties": {
                "field": {
                  "type": "string"
                },
                "state": {
                  "type": "string",
                  "enum": [
                    "present",
                    "incomplete",
                    "missing"
                  ]
                },
                "existingValue": {
                  "type": "string"
                }
              }
            }
          },
          "platform": {
            "type": [
              "object",
              "null"
            ],
            "description": "The detected e-commerce platform, when one was detected.",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            },
            "required": [
              "id",
              "name"
            ]
          },
          "llm": {
            "type": "string",
            "enum": [
              "used",
              "unavailable",
              "not-needed"
            ],
            "description": "`unavailable` means the gated model call failed and the run continued deterministic-only — never a crash, never a fabricated field."
          },
          "extractionModel": {
            "type": [
              "string",
              "null"
            ],
            "description": "Which model proposed the gap fields, disclosed for methodology."
          }
        }
      },
      "ExtractedField": {
        "description": "A field is either verified — and then it carries its evidence — or unverified, and then it carries a reason and no value. There is no third shape.",
        "type": "object",
        "required": [
          "id",
          "status"
        ],
        "properties": {
          "id": {
            "type": "string",
            "enum": [
              "name",
              "description",
              "brand",
              "image",
              "sku",
              "gtin",
              "price",
              "currency",
              "availability",
              "faq"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "verified",
              "unverified"
            ]
          },
          "value": {
            "description": "Present only when `status` is `verified`.",
            "type": "object",
            "properties": {
              "kind": {
                "type": "string"
              }
            }
          },
          "evidence": {
            "description": "Present only when `status` is `verified`.",
            "type": "object",
            "required": [
              "source",
              "quote",
              "locator"
            ],
            "properties": {
              "source": {
                "type": "string",
                "enum": [
                  "jsonld",
                  "microdata",
                  "og-meta",
                  "llm-verified",
                  "user-confirmed"
                ]
              },
              "quote": {
                "type": "string",
                "description": "Verbatim evidence from the page."
              },
              "locator": {
                "type": "string",
                "description": "Where on the page it came from."
              }
            }
          },
          "reason": {
            "description": "Present only when `status` is `unverified`.",
            "type": "string",
            "enum": [
              "no-evidence",
              "gate-failed",
              "llm-unavailable"
            ]
          }
        }
      }
    }
  }
}