{
  "openapi": "3.0.3",
  "info": {
    "title": "OneGoodArea API",
    "description": "Deterministic UK location intelligence. Scores any UK postcode against four intents (origination / site selection / investment / reference) using seven public datasets. Numbers are computed from real data using fixed formulas; AI narrates the results but never generates the scores.\n\nEvery response carries an `engine_version` string and per-dimension `confidence` values, so a buyer's model risk register can pin to a specific methodology and trust-band the inputs.",
    "version": "2.0.2",
    "contact": {
      "name": "OneGoodArea",
      "url": "https://www.onegoodarea.com",
      "email": "operation@onegoodarea.co.uk"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://www.onegoodarea.com/terms"
    }
  },
  "servers": [
    { "url": "https://www.onegoodarea.com", "description": "Production" }
  ],
  "tags": [
    { "name": "Reports", "description": "Generate area intelligence reports" },
    { "name": "Widget", "description": "Embeddable widget endpoint, cache-only" },
    { "name": "Webhooks", "description": "Outbound webhook subscriptions for event-driven integrations" }
  ],
  "paths": {
    "/api/v1/report": {
      "post": {
        "tags": ["Reports"],
        "summary": "Generate an area intelligence report",
        "description": "Scores a UK postcode or place name against the requested intent. Returns the deterministic score, five weighted dimensions with reasoning and confidence, source attribution, and an AI-generated narrative. Cache-aware (24h TTL per area:intent pair).\n\n**Rate limit:** 30 requests/minute per API key.\n\n**Idempotency:** supply an `Idempotency-Key` header for safe retries. See the parameter description.\n\n**Methodology pinning:** supply an `X-Engine-Version` header to lock the request to a specific engine version — needed for regulated buyers entering responses into a model risk register.",
        "operationId": "generateReport",
        "security": [{ "BearerAuth": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/IdempotencyKey" },
          { "$ref": "#/components/parameters/EngineVersion" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ReportRequest" },
              "example": {
                "area": "Manchester",
                "intent": "moving"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Report generated or served from cache",
            "headers": {
              "X-RateLimit-Limit": { "schema": { "type": "integer" } },
              "X-RateLimit-Remaining": { "schema": { "type": "integer" } },
              "X-RateLimit-Reset": { "schema": { "type": "integer" } },
              "X-Idempotency-Replayed": {
                "description": "`true` when this response was served from the idempotency cache; `false` when freshly generated. Only present if the request supplied an Idempotency-Key.",
                "schema": { "type": "string", "enum": ["true", "false"] }
              },
              "X-Engine-Version": {
                "description": "The methodology version that processed the request. Echoes the request's `X-Engine-Version` header when supplied, or the current default version when omitted. Match against the `engine_version` field inside the response body for audit-trail purposes.",
                "schema": { "type": "string", "example": "2.0.2" }
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ReportResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/AuthError" },
          "403": { "$ref": "#/components/responses/PlanError" },
          "409": {
            "description": "Idempotency-Key conflict: the same key was previously used with a different request body. Use a fresh key.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimitError" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/v1/batch": {
      "post": {
        "tags": ["Reports"],
        "summary": "Score up to 100 postcodes in a single request",
        "description": "Bulk variant of `/api/v1/report` for portfolio-scale buyers (mortgage lenders, insurance underwriters, CRE site-selection teams). Accepts an array of `{ area, intent }` items, processes them with bounded concurrency, and returns a per-item result array.\n\n**Limits:**\n- Max 100 items per request. Larger workloads should be split client-side, or use the async pattern (roadmap).\n- 5 batch requests per minute per API key.\n- Each item counts as 1 report against the monthly quota. Pre-check: if `items.length > remaining_quota`, returns 429 with full details, no quota consumed.\n- Items are processed in parallel with concurrency=5 internally; cache hits resolve in ~100ms, cache misses up to ~30s each.\n\n**Failure handling:** per-item errors do not fail the batch. Each result is either `{ area, intent, report }` (success) or `{ area, intent, error }` (failure).\n\n**Methodology pinning:** `X-Engine-Version` applies to every item in the batch — buyers cannot mix versions inside one call.",
        "operationId": "batchReport",
        "security": [{ "BearerAuth": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/IdempotencyKey" },
          { "$ref": "#/components/parameters/EngineVersion" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/BatchRequest" },
              "example": {
                "items": [
                  { "area": "Manchester", "intent": "moving" },
                  { "area": "M1 1AE", "intent": "business" },
                  { "area": "Edinburgh", "intent": "investing" }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Batch processed. Individual items may have succeeded or failed; check the `error` field on each result.",
            "headers": {
              "X-RateLimit-Limit": { "schema": { "type": "integer" } },
              "X-RateLimit-Remaining": { "schema": { "type": "integer" } },
              "X-RateLimit-Reset": { "schema": { "type": "integer" } },
              "X-Idempotency-Replayed": {
                "description": "`true` when this response was served from the idempotency cache; `false` when freshly generated. Only present if the request supplied an Idempotency-Key.",
                "schema": { "type": "string", "enum": ["true", "false"] }
              },
              "X-Engine-Version": {
                "description": "The methodology version that processed this batch. Applies uniformly to every item.",
                "schema": { "type": "string", "example": "2.0.2" }
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BatchResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/AuthError" },
          "403": { "$ref": "#/components/responses/PlanError" },
          "409": {
            "description": "Idempotency-Key conflict: the same key was previously used with a different request body. Use a fresh key.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimitError" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/widget": {
      "get": {
        "tags": ["Widget"],
        "summary": "Embeddable widget — cached scores only",
        "description": "Lightweight endpoint for the embeddable widget. Returns simplified scores from the 24h cache. Will not generate fresh reports; if a postcode/intent pair has no cached entry, returns a 404 instructing the caller to generate via the main `/api/v1/report` endpoint first.\n\n**Rate limit:** 60 requests/hour per origin/IP. **CORS:** enabled. **Cache-Control:** s-maxage=3600, stale-while-revalidate=86400.",
        "operationId": "widgetReport",
        "parameters": [
          {
            "name": "postcode",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "example": "M1 1AE"
          },
          {
            "name": "intent",
            "in": "query",
            "required": true,
            "schema": { "$ref": "#/components/schemas/Intent" },
            "example": "moving"
          }
        ],
        "responses": {
          "200": {
            "description": "Cached scores",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WidgetResponse" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "404": {
            "description": "No cached report for this postcode/intent pair",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimitError" }
        }
      }
    },
    "/api/v1/webhooks": {
      "post": {
        "tags": ["Webhooks"],
        "summary": "Register a webhook subscription",
        "description": "Subscribe to outbound webhook events. We POST signed payloads to your URL when matching events fire. Stripe-style HMAC-SHA256 signature in the `X-OneGoodArea-Signature` header (format: `t=<unix-ts>,v1=<hex>`).\n\n**Supported event types:**\n- `report.created`: fires after every successful report generation by your API key (live now).\n- `score.changed`: fires when the time-series cron detects a score change >5 points for a postcode you've watched. (Available once the cron is scheduled — see roadmap.)\n\n**Secret:** the per-subscription HMAC signing secret (`whsec_<48-hex>`) is returned ONCE in the create response. Store it server-side; it cannot be retrieved later. Rotate by revoking + recreating.\n\n**URL requirements:** must be HTTPS. Localhost and RFC 1918 private ranges are rejected.\n\n**Delivery:** synchronous POST with 5s timeout. Failed deliveries are recorded in our internal audit log; a Phase-2 retry cron will pick them up with exponential backoff.",
        "operationId": "createWebhookSubscription",
        "security": [{ "BearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WebhookSubscriptionRequest" },
              "example": {
                "url": "https://hooks.acme-lender.example.com/onegoodarea",
                "events": ["report.created"]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Subscription created. The `secret` is returned ONCE and is never recoverable.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/CreatedWebhookSubscription" }
              }
            }
          },
          "400": { "$ref": "#/components/responses/ValidationError" },
          "401": { "$ref": "#/components/responses/AuthError" },
          "403": { "$ref": "#/components/responses/PlanError" },
          "429": { "$ref": "#/components/responses/RateLimitError" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      },
      "get": {
        "tags": ["Webhooks"],
        "summary": "List your active webhook subscriptions",
        "description": "Returns active subscriptions for the API key. Secrets are NEVER included in list responses — only the URL, event types, status, and delivery timestamps.",
        "operationId": "listWebhookSubscriptions",
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Subscription list",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WebhookSubscriptionList" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/AuthError" },
          "403": { "$ref": "#/components/responses/PlanError" },
          "429": { "$ref": "#/components/responses/RateLimitError" }
        }
      }
    },
    "/api/v1/webhooks/{id}": {
      "delete": {
        "tags": ["Webhooks"],
        "summary": "Revoke a webhook subscription",
        "description": "Marks the subscription as `revoked`. No further events will be delivered. Idempotent: revoking an already-revoked subscription returns 404.",
        "operationId": "revokeWebhookSubscription",
        "security": [{ "BearerAuth": [] }],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "example": "whsub_abc123"
          }
        ],
        "responses": {
          "200": {
            "description": "Revoked",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": { "type": "string" },
                    "status": { "type": "string", "example": "revoked" }
                  }
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/AuthError" },
          "403": { "$ref": "#/components/responses/PlanError" },
          "404": {
            "description": "Subscription not found or already revoked",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          },
          "429": { "$ref": "#/components/responses/RateLimitError" }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional opaque string (UUID v4 recommended, 1-255 chars). When supplied, the response is cached for 24 hours keyed by `(your-api-key, idempotency-key)`. A retry with the same key + same request body returns the original response without re-running the engine and without consuming additional quota. If the same key is reused with a different request body, the API returns HTTP 409 with code `IDEMPOTENCY_CONFLICT`. Stripe-style. Recommended for any retry-on-network-failure pattern.",
        "schema": { "type": "string", "minLength": 1, "maxLength": 255, "example": "550e8400-e29b-41d4-a716-446655440000" }
      },
      "EngineVersion": {
        "name": "X-Engine-Version",
        "in": "header",
        "required": false,
        "description": "Optional methodology pin. When supplied, the request is processed against the given engine version so the response can be entered into a model risk register without ambiguity. Omit to use the latest version (default).\n\n**Supported versions:** `2.0.0`, `2.0.1`, `2.0.2`. All three are score-equivalent today — patch versions in the v2.x series changed only the confidence rubric and data-source reliability, not scoring math. So pinning to any v2.x guarantees byte-identical SCORE values; per-dimension `confidence` metadata may refine between patches.\n\n**End-of-life:** versions `1.0.0` / `1.1.0` / `1.2.0` are reconstructed-from-history snapshots; we do not run a frozen engine for them. Requests pinned to a 1.x version return HTTP 400 with code `engine_version_unsupported`.\n\n**Unknown versions** return HTTP 400 with code `engine_version_unknown` and the supported set in `supported_versions`.\n\n**Response:** every successful response carries an `X-Engine-Version` response header echoing the version that processed the request, and the response body's `engine_version` field stamps the same value into the report payload (audit trail).",
        "schema": { "type": "string", "enum": ["2.0.0", "2.0.1", "2.0.2"], "example": "2.0.2" }
      }
    },
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "aiq_<48-char hex>",
        "description": "API key issued from your dashboard at https://www.onegoodarea.com/api-usage. Format: `aiq_` followed by 48 hex characters. Pass as `Authorization: Bearer aiq_...`."
      }
    },
    "schemas": {
      "Intent": {
        "type": "string",
        "description": "The scoring product to apply. Each intent reweights the same five dimensions for a different decision.",
        "enum": ["moving", "business", "investing", "research"],
        "x-product-mapping": {
          "moving": "Origination scoring — residential mortgage suitability and demand-side risk",
          "business": "Site selection — footfall, competition, and commercial viability",
          "investing": "Investment scoring — yield, growth, regeneration, tenant risk",
          "research": "Reference scoring — neutral baseline, equal weights"
        }
      },
      "AreaType": {
        "type": "string",
        "enum": ["urban", "suburban", "rural"],
        "description": "Auto-detected from postcodes.io rural/urban classification. Scores are benchmarked against the relevant peer group."
      },
      "ReportRequest": {
        "type": "object",
        "required": ["area", "intent"],
        "properties": {
          "area": {
            "type": "string",
            "description": "UK postcode (e.g. `M1 1AE`), place name (`Manchester`), or partial. Will be geocoded and resolved to an LSOA before scoring.",
            "minLength": 2,
            "maxLength": 80
          },
          "intent": { "$ref": "#/components/schemas/Intent" }
        }
      },
      "BatchRequest": {
        "type": "object",
        "required": ["items"],
        "properties": {
          "items": {
            "type": "array",
            "description": "Array of (area, intent) pairs to score. Minimum 1, maximum 100. Order is preserved in the response.",
            "minItems": 1,
            "maxItems": 100,
            "items": { "$ref": "#/components/schemas/ReportRequest" }
          }
        }
      },
      "BatchResultItem": {
        "oneOf": [
          {
            "type": "object",
            "required": ["area", "intent", "report"],
            "properties": {
              "area": { "type": "string", "description": "Echo of the input area string" },
              "intent": { "$ref": "#/components/schemas/Intent" },
              "report": { "$ref": "#/components/schemas/AreaReport" }
            }
          },
          {
            "type": "object",
            "required": ["area", "intent", "error"],
            "properties": {
              "area": { "type": "string" },
              "intent": { "type": "string" },
              "error": {
                "type": "string",
                "description": "Per-item failure reason (e.g. invalid intent, geocode failure, upstream error). The batch as a whole still returns 200; this item is just not fulfilled."
              }
            }
          }
        ]
      },
      "BatchResponse": {
        "type": "object",
        "required": ["results", "summary"],
        "properties": {
          "results": {
            "type": "array",
            "description": "Per-item results in input order. Each entry is either a success (has `report`) or an error (has `error`).",
            "items": { "$ref": "#/components/schemas/BatchResultItem" }
          },
          "summary": {
            "type": "object",
            "required": ["total", "succeeded", "failed"],
            "properties": {
              "total": { "type": "integer", "description": "Number of items submitted." },
              "succeeded": { "type": "integer", "description": "Items that produced a report." },
              "failed": { "type": "integer", "description": "Items that returned an error." }
            }
          }
        }
      },
      "WebhookEventType": {
        "type": "string",
        "enum": ["report.created", "score.changed"],
        "description": "Event types that a webhook subscription can fire on. `report.created` is live; `score.changed` becomes active when the time-series cron is scheduled."
      },
      "WebhookSubscriptionRequest": {
        "type": "object",
        "required": ["url", "events"],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS URL we POST signed event payloads to. Must be publicly reachable — localhost and RFC 1918 private ranges are rejected.",
            "example": "https://hooks.acme-lender.example.com/onegoodarea"
          },
          "events": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/WebhookEventType" },
            "minItems": 1,
            "description": "Event types this subscription should fire on. Unknown types are silently filtered."
          }
        }
      },
      "CreatedWebhookSubscription": {
        "type": "object",
        "required": ["id", "url", "events", "secret", "created_at"],
        "properties": {
          "id": { "type": "string", "example": "whsub_abc123def" },
          "url": { "type": "string", "format": "uri" },
          "events": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookEventType" } },
          "secret": {
            "type": "string",
            "description": "HMAC-SHA256 signing secret. Format: `whsec_<48-hex>`. RETURNED ONCE on create; never recoverable. Use to verify the `X-OneGoodArea-Signature` header on incoming webhook deliveries.",
            "example": "whsec_a1b2c3d4..."
          },
          "created_at": { "type": "string", "format": "date-time" }
        }
      },
      "WebhookSubscription": {
        "type": "object",
        "required": ["id", "url", "events", "status", "created_at"],
        "properties": {
          "id": { "type": "string" },
          "url": { "type": "string", "format": "uri" },
          "events": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookEventType" } },
          "status": { "type": "string", "enum": ["active", "revoked"] },
          "created_at": { "type": "string", "format": "date-time" },
          "last_success_at": { "type": "string", "format": "date-time", "nullable": true },
          "last_failure_at": { "type": "string", "format": "date-time", "nullable": true }
        }
      },
      "WebhookSubscriptionList": {
        "type": "object",
        "required": ["subscriptions"],
        "properties": {
          "subscriptions": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/WebhookSubscription" }
          }
        }
      },
      "WebhookEventEnvelope": {
        "type": "object",
        "description": "Shape of the JSON body POST'ed to your webhook URL when an event fires. The body is signed by `X-OneGoodArea-Signature: t=<unix-seconds>,v1=<hmac-sha256-hex>` — verify by recomputing HMAC over `<timestamp>.<raw-body>` with your secret.",
        "required": ["id", "type", "created", "data"],
        "properties": {
          "id": { "type": "string", "example": "evt_abc123def" },
          "type": { "$ref": "#/components/schemas/WebhookEventType" },
          "created": { "type": "integer", "description": "Unix timestamp (seconds) when the event was generated" },
          "data": {
            "type": "object",
            "description": "Event-specific payload. For `report.created`: `{report_id, area, intent, score, engine_version, confidence}`.",
            "additionalProperties": true
          }
        }
      },
      "ReportResponse": {
        "type": "object",
        "required": ["id", "report"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Internal report ID, prefixed `rpt_`",
            "example": "rpt_abc123def"
          },
          "report": { "$ref": "#/components/schemas/AreaReport" }
        }
      },
      "AreaReport": {
        "type": "object",
        "description": "The full area intelligence report.",
        "required": ["area", "intent", "areaiq_score", "sub_scores", "summary", "sections", "recommendations", "generated_at"],
        "properties": {
          "area": { "type": "string", "example": "Manchester" },
          "intent": { "$ref": "#/components/schemas/Intent" },
          "areaiq_score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Overall deterministic score, 0–100. Server-locked: AI cannot drift this value."
          },
          "area_type": { "$ref": "#/components/schemas/AreaType" },
          "sub_scores": {
            "type": "array",
            "minItems": 5,
            "maxItems": 5,
            "items": { "$ref": "#/components/schemas/SubScore" }
          },
          "summary": { "type": "string", "description": "AI-generated 2–3 sentence executive summary, intent-aware." },
          "sections": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/ReportSection" }
          },
          "recommendations": {
            "type": "array",
            "items": { "type": "string" }
          },
          "data_sources": {
            "type": "array",
            "description": "Authoritative public datasets that contributed to this report.",
            "items": { "type": "string" },
            "example": ["postcodes.io", "police.uk", "IMD 2025", "OpenStreetMap", "Environment Agency", "HM Land Registry", "Ofsted"]
          },
          "data_freshness": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/DataFreshness" }
          },
          "property_data": { "$ref": "#/components/schemas/PropertyMarketData" },
          "schools_data": { "$ref": "#/components/schemas/SchoolsData" },
          "confidence": {
            "type": "number",
            "minimum": 0.0,
            "maximum": 1.0,
            "description": "Aggregate confidence across all dimensions, weight-weighted. 1.0 = rich data on every dimension; 0.4 = sparse data or proxy fallback used. See `confidence_reason` on each sub_score for per-dimension explanation."
          },
          "engine_version": {
            "type": "string",
            "pattern": "^\\d+\\.\\d+\\.\\d+$",
            "description": "Methodology version that produced this report. Pin to a specific version in your model risk register. Changelog at /methodology.",
            "example": "2.0.0"
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SubScore": {
        "type": "object",
        "required": ["label", "score", "weight", "summary"],
        "properties": {
          "label": {
            "type": "string",
            "description": "Dimension name, e.g. `Safety & Crime`",
            "example": "Safety & Crime"
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Deterministic dimension score, 0–100"
          },
          "weight": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "This dimension's weight in the overall score. The five weights sum to 100 per intent.",
            "example": 25
          },
          "summary": {
            "type": "string",
            "description": "AI-generated one-sentence summary of the score with specific data references."
          },
          "reasoning": {
            "type": "string",
            "description": "Deterministic reasoning string from the engine — exact data points that drove the score."
          },
          "confidence": {
            "type": "number",
            "minimum": 0.0,
            "maximum": 1.0,
            "description": "Per-dimension confidence: 1.0 HIGH (fresh primary data), 0.7 MEDIUM (partial fallback or older dataset), 0.4 LOW (full proxy fallback), 0.2 NONE (no data, default 50)."
          },
          "confidence_reason": {
            "type": "string",
            "description": "Human-readable explanation of the confidence value."
          }
        }
      },
      "ReportSection": {
        "type": "object",
        "required": ["title", "content"],
        "properties": {
          "title": { "type": "string" },
          "content": { "type": "string" },
          "data_points": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "label": { "type": "string" },
                "value": { "type": "string" }
              }
            }
          }
        }
      },
      "DataFreshness": {
        "type": "object",
        "required": ["source", "period", "status"],
        "properties": {
          "source": { "type": "string", "example": "police.uk" },
          "period": { "type": "string", "example": "Apr 2024 to Mar 2025" },
          "status": {
            "type": "string",
            "enum": ["live", "recent", "static"],
            "description": "live = real-time API; recent = updated within last quarter; static = annual or less frequent release"
          }
        }
      },
      "PropertyMarketData": {
        "type": "object",
        "description": "HM Land Registry sold-price summary for the postcode area.",
        "properties": {
          "postcode_area": { "type": "string", "example": "M1" },
          "median_price": { "type": "integer", "example": 245000 },
          "mean_price": { "type": "integer", "example": 280000 },
          "transaction_count": { "type": "integer", "example": 124 },
          "price_change_pct": {
            "type": "number",
            "nullable": true,
            "description": "Year-over-year change percentage, null if insufficient prior-year data",
            "example": 4.2
          },
          "by_property_type": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "type": { "type": "string", "example": "Flat" },
                "median": { "type": "integer", "example": 198000 },
                "count": { "type": "integer", "example": 80 }
              }
            }
          },
          "tenure_split": {
            "type": "object",
            "properties": {
              "freehold": { "type": "integer" },
              "leasehold": { "type": "integer" }
            }
          },
          "price_range": {
            "type": "object",
            "properties": {
              "min": { "type": "integer" },
              "max": { "type": "integer" }
            }
          },
          "period": { "type": "string", "example": "2024-2025" }
        }
      },
      "SchoolsData": {
        "type": "object",
        "description": "Ofsted school inspection data (England). Estyn (Wales) and Education Scotland on roadmap.",
        "properties": {
          "schools": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": { "type": "string" },
                "phase": { "type": "string", "example": "Primary" },
                "rating": {
                  "type": "string",
                  "enum": ["Outstanding", "Good", "Requires Improvement", "Inadequate", "Not rated"]
                },
                "distance_km": { "type": "number" }
              }
            }
          },
          "rating_breakdown": {
            "type": "object",
            "additionalProperties": { "type": "integer" }
          },
          "inspectorate": {
            "type": "string",
            "enum": ["Ofsted", "Estyn", "Education Scotland"]
          }
        }
      },
      "WidgetResponse": {
        "type": "object",
        "required": ["area", "postcode", "intent", "score", "dimensions", "powered_by"],
        "properties": {
          "area": { "type": "string" },
          "postcode": { "type": "string" },
          "intent": { "$ref": "#/components/schemas/Intent" },
          "score": { "type": "integer", "minimum": 0, "maximum": 100 },
          "area_type": { "$ref": "#/components/schemas/AreaType" },
          "dimensions": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "label": { "type": "string" },
                "score": { "type": "integer" }
              }
            }
          },
          "powered_by": {
            "type": "string",
            "example": "OneGoodArea"
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": { "type": "string" },
          "code": { "type": "string" }
        }
      }
    },
    "responses": {
      "ValidationError": {
        "description": "Invalid request — missing or malformed `area` or `intent`",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "AuthError": {
        "description": "Missing or invalid API key",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "PlanError": {
        "description": "API access requires a Developer, Business, or Growth plan",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "RateLimitError": {
        "description": "Rate limit exceeded",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "ServerError": {
        "description": "Internal server error",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      }
    }
  }
}
