openapi: 3.0.3
info:
  title: Sky Score API
  description: |
    Sky Score is a noise + livability data API for UK property. The score
    is a 0-10 holistic property quality signal computed from four
    components: Quiet (aviation + road noise), Affordability (cohort-relative
    price), Growth (price-trend signal), and Liveability (schools, crime,
    transport, healthcare).

    **Authentication.** All endpoints require an API key passed in the
    `X-Api-Key` header. Free tier provides 1000 requests/month, 5/sec burst,
    2/sec sustained. Contact via the [consumer site](https://skyscore.co.uk/)
    for a key.

    **Methodology.** Every numeric threshold and weight is anchored to a
    published source or explicit editorial decision in the
    [methodology document](https://github.com/billkhiz-bit/london-flight-path-map/blob/master/METHODOLOGY.md).
    The methodology is reproducible: a worked example walks through every
    calculation against a real postcode.

    **Coverage.** 33 London boroughs by UK postcode resolution (postcodes.io).
    5 NYC boroughs by borough-name lookup or 5-digit US ZIP auto-detection
    (~182 residential ZIPs covered; ~110 with per-ZIP centroid for finer
    quiet-score resolution).
  version: "1.0"
  contact:
    name: Sky Score
    url: https://skyscore.co.uk/
  license:
    name: Proprietary
    url: https://github.com/billkhiz-bit/london-flight-path-map/blob/master/LICENSE

servers:
  - url: https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod
    description: Production API

security:
  - ApiKeyAuth: []

tags:
  - name: Score
    description: Single-postcode and bulk property scoring.

paths:
  /v1/regions:
    get:
      tags: [Score]
      summary: List supported cities and boroughs.
      description: |
        Discovery endpoint for integrators. Returns the supported cities,
        their borough lists, postcode formats, and resolver methods. Use
        this to know what queries to issue without scraping responses.
      responses:
        '200':
          description: Supported regions and metadata.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegionsResponse'
        '403':
          description: API key missing or invalid.

  /v1/score:
    get:
      tags: [Score]
      summary: Get Sky Score for a single postcode or borough.
      description: |
        Returns the 0-10 Sky Score for a given UK postcode (resolved via
        postcodes.io to a London borough) or a borough name in a supported
        city. Optionally accepts a persona preset or a custom weights
        override.

        ### Code samples

        **curl**
        ```bash
        curl 'https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod/v1/score?postcode=SW11+1AA' \
          -H 'X-Api-Key: YOUR_KEY'
        ```

        **Python (stdlib)**
        ```python
        from urllib.parse import quote
        from urllib.request import Request, urlopen
        import json

        url = 'https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod/v1/score'
        req = Request(f'{url}?postcode={quote("SW11 1AA")}',
                      headers={'X-Api-Key': 'YOUR_KEY'})
        with urlopen(req, timeout=5) as r:
            data = json.loads(r.read())
        print(data['score'], data['components'])
        ```

        **Python (requests)**
        ```python
        import requests
        r = requests.get(
            'https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod/v1/score',
            params={'postcode': 'SW11 1AA', 'persona': 'family'},
            headers={'X-Api-Key': 'YOUR_KEY'},
            timeout=5,
        )
        r.raise_for_status()
        print(r.json()['score'])
        ```

        **Node.js (fetch, 18+)**
        ```js
        const url = new URL('https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod/v1/score');
        url.searchParams.set('postcode', 'SW11 1AA');
        const r = await fetch(url, { headers: { 'X-Api-Key': 'YOUR_KEY' } });
        const data = await r.json();
        console.log(data.score, data.components);
        ```

        **Browser (fetch)**
        ```js
        const r = await fetch(
          'https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod/v1/score?postcode=SW11+1AA',
          { headers: { 'X-Api-Key': 'YOUR_KEY' } },
        );
        const { score, components } = await r.json();
        ```
      parameters:
        - name: postcode
          in: query
          description: |
            Postcode/ZIP to score. UK postcodes (e.g. `SW11 1AA`) resolve
            via postcodes.io to a London borough. US ZIPs (e.g. `10001`,
            5-digit with optional `+4` suffix) auto-detect to NYC and use
            a static borough lookup. Spaces in UK postcodes are URL-encoded
            (`%20`). Required if `borough` is not provided.
          schema:
            type: string
            example: SW11 1AA
        - name: borough
          in: query
          description: |
            Borough name to score. Required if `postcode` is not provided.
            Supported names depend on `city`.
          schema:
            type: string
            example: Wandsworth
        - name: city
          in: query
          description: City context for borough lookup.
          schema:
            type: string
            enum: [london, nyc]
            default: london
        - name: persona
          in: query
          description: |
            Persona preset that determines the weighting of the four
            components. See the methodology document for the precise weight
            values. Ignored if `weights` is provided.
          schema:
            type: string
            enum: [balanced, family, investor, firsttime, quietlife, renter, commuter, downsizer]
            default: balanced
        - name: weights
          in: query
          description: |
            Custom weights override. Format: `quiet:W,afford:X,growth:Y,live:Z`
            where W+X+Y+Z must equal 1.0 (within ±0.01). If invalid, the
            persona preset (or balanced) is used. When applied, the response
            indicates `persona: "custom"`.
          schema:
            type: string
            example: "quiet:0.5,afford:0.2,growth:0.1,live:0.2"
        - name: include
          in: query
          description: |
            Comma-separated list of top-level response fields to keep. Lets
            integrators reduce response payload to only the fields they
            consume. Meta fields (`apiVersion`, `methodologyVersion`,
            `methodologyUrl`, `generatedAt`, `sources`) are always returned
            regardless of filter. Unknown fields are ignored silently.
            Recognised fields: `score`, `components`, `context`, `location`,
            `persona`, `weights`, `sourceBreakdown`, `plannedComponents`.
          schema:
            type: string
            example: "score,components,context"
      responses:
        '200':
          description: Score successfully computed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScoreResponse'
        '400':
          description: Invalid request (missing postcode/borough, or bad parameter).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: API key missing or invalid.
        '404':
          description: |
            Postcode not recognised by postcodes.io, or borough not in dataset
            for the requested city. Response includes `supportedBoroughs`
            for graceful fallback.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: Rate limit exceeded.

  /v1/score/batch:
    post:
      tags: [Score]
      summary: Bulk Sky Score lookup (up to 100 queries per call).
      description: |
        Submits up to 100 score queries in a single API call. Each query is
        independent, failures do not abort the batch; failed queries return
        an error per item and the batch continues. Suitable for aggregator
        and conveyancing-search workflows that look up many properties at
        once.

        **Counting against rate limits.** A batch call counts as 1 API
        request against your monthly quota, regardless of the number of
        queries. The 100-query limit per batch caps the work per request.

        ### Code samples

        **curl**
        ```bash
        curl -X POST 'https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod/v1/score/batch' \
          -H 'X-Api-Key: YOUR_KEY' \
          -H 'Content-Type: application/json' \
          -d '{"queries":[{"postcode":"SW11 1AA"},{"postcode":"10001","persona":"investor"}]}'
        ```

        **Python (requests)**
        ```python
        import requests
        r = requests.post(
            'https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod/v1/score/batch',
            json={'queries': [
                {'postcode': 'SW11 1AA'},
                {'postcode': 'TW3 4DX', 'persona': 'family'},
                {'borough': 'Hackney', 'city': 'london',
                 'weights': {'quiet': 0.5, 'afford': 0.2, 'growth': 0.1, 'live': 0.2}},
            ]},
            headers={'X-Api-Key': 'YOUR_KEY'},
            timeout=20,
        )
        r.raise_for_status()
        for row in r.json()['results']:
            if row['status'] == 200:
                print(row['location']['borough'], row['score'])
            else:
                print(f'queryIndex {row["queryIndex"]}: {row["error"]}')
        ```

        **Node.js (fetch, 18+)**
        ```js
        const r = await fetch(
          'https://2gjfdzg20c.execute-api.eu-west-2.amazonaws.com/prod/v1/score/batch',
          { method: 'POST',
            headers: { 'X-Api-Key': 'YOUR_KEY', 'Content-Type': 'application/json' },
            body: JSON.stringify({ queries: [
              { postcode: 'SW11 1AA' },
              { postcode: '10001', persona: 'investor' },
            ]}),
          },
        );
        const { results } = await r.json();
        ```
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchRequest'
            example:
              queries:
                - postcode: "SW11 1AA"
                  persona: balanced
                - postcode: "TW3 4DX"
                  persona: family
                - borough: "Hackney"
                  city: london
                  weights:
                    quiet: 0.5
                    afford: 0.2
                    growth: 0.1
                    live: 0.2
      responses:
        '200':
          description: Batch processed (each query may individually succeed or fail).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchResponse'
        '400':
          description: Invalid batch (missing queries array, exceeds limit, malformed JSON).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: API key missing or invalid.
        '429':
          description: Rate limit exceeded.

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        API key issued via the SkyScoreFreeTierKey usage plan (1000 req/month,
        5/sec burst, 2/sec sustained on the free tier). Contact for paid
        tiers.

  schemas:
    ScoreResponse:
      type: object
      required: [score, components, location, persona, weights, methodologyVersion, apiVersion, generatedAt, sources]
      properties:
        score:
          type: number
          format: float
          minimum: 0
          maximum: 10
          description: Headline Sky Score (one decimal precision).
          example: 6.1
        components:
          $ref: '#/components/schemas/Components'
        context:
          $ref: '#/components/schemas/Context'
        location:
          $ref: '#/components/schemas/Location'
        persona:
          type: string
          enum: [balanced, family, investor, firsttime, quietlife, custom]
          description: |
            Which weighting profile produced the score. `"custom"` indicates
            the `weights` query parameter was applied.
        weights:
          $ref: '#/components/schemas/Weights'
        methodologyVersion:
          type: string
          example: "3.1"
        methodologyUrl:
          type: string
          format: uri
        apiVersion:
          type: string
          example: "1.0"
        generatedAt:
          type: string
          format: date-time
        sources:
          type: array
          items:
            type: string
          description: |
            OGL attribution lines for the underlying data sources.
            Consumers redistributing the response must preserve these.
        sourceBreakdown:
          type: object
          description: |
            Per-component data lineage. Each property describes the source
            and methodology for that component. The `/v1/score` endpoint
            does NOT depend on OpenSky Network, aviation noise context is
            sourced from pre-computed DEFRA borough-aggregate Lden bands.
          properties:
            quiet:
              type: string
            afford:
              type: string
            growth:
              type: string
            live:
              type: string
        plannedComponents:
          type: object
          description: |
            Roadmap-visible placeholders for components that are scoped but
            not yet shipped. Each entry has a `status` flag, integrators
            should NOT consume placeholder data as if it were live. The
            shape is stable; live components migrate from this block into
            `components` / `context` when they ship.
          additionalProperties:
            type: object
            required: [status]
            properties:
              status:
                type: string
                enum: [planned, beta]
              source:
                type: string
              eta:
                type: string

    RegionsResponse:
      type: object
      required: [cities, apiVersion, methodologyVersion, generatedAt]
      properties:
        cities:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                enum: [london, nyc]
              name:
                type: string
              country:
                type: string
              currency:
                type: string
              postcodeFormat:
                type: string
              postcodeResolver:
                type: string
              boroughCount:
                type: integer
              boroughs:
                type: array
                items:
                  type: string
              supportedZipCount:
                type: integer
                description: NYC only, number of ZIPs in the static lookup.
        apiVersion:
          type: string
        methodologyVersion:
          type: string
        methodologyUrl:
          type: string
          format: uri
        generatedAt:
          type: string
          format: date-time

    Components:
      type: object
      required: [quiet, afford, growth, live]
      properties:
        quiet:
          type: number
          format: float
          minimum: 0
          maximum: 10
          description: Aviation + road noise impact, 10 = quietest.
        afford:
          type: number
          format: float
          minimum: 0
          maximum: 10
          description: Cohort-relative affordability, 10 = cheapest in cohort.
        growth:
          type: number
          format: float
          minimum: 0
          maximum: 10
          description: Linear price trend scaled to cohort max.
        live:
          type: number
          format: float
          minimum: 0
          maximum: 10
          description: Liveability composite (35% schools + 30% crime + 25% transport + 10% healthcare).

    Context:
      type: object
      description: Underlying inputs used to compute the components.
      properties:
        avgPriceGbp:
          type: number
          description: Borough average sold price in GBP (London).
        avgPriceUsd:
          type: number
          description: Borough average price in USD (NYC).
        priceTrendPct:
          type: number
          description: Annualised price growth percentage.
        noiseImpactBand:
          type: string
          enum: [low, low-moderate, moderate, moderate-high, high, severe]
          description: DEFRA Lden band classification (borough-aggregate).
        quietResolution:
          type: string
          enum: [borough, postcode, raster]
          description: |
            Which resolution tier produced the `quiet` score. Resolves
            highest-precision-first:
              `raster`, DEFRA Lden raster sample at postcode centroid (v3.1)
              `postcode`, Haversine to airports + flight-path geometry (v3.0)
              `borough`, Borough-aggregate Lden band (v2.x fallback)
            Integrators can use this to surface precision metadata to end
            users or filter for higher-precision results.

    Location:
      type: object
      properties:
        city:
          type: string
          enum: [london, nyc]
        postcode:
          type: string
        borough:
          type: string
        longitude:
          type: number
        latitude:
          type: number
        region:
          type: string

    Weights:
      type: object
      required: [quiet, afford, growth, live]
      properties:
        quiet:
          type: number
          minimum: 0
          maximum: 1
        afford:
          type: number
          minimum: 0
          maximum: 1
        growth:
          type: number
          minimum: 0
          maximum: 1
        live:
          type: number
          minimum: 0
          maximum: 1

    BatchRequest:
      type: object
      required: [queries]
      properties:
        queries:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: object
            properties:
              postcode:
                type: string
              borough:
                type: string
              city:
                type: string
                enum: [london, nyc]
              persona:
                type: string
                enum: [balanced, family, investor, firsttime, quietlife]
              weights:
                $ref: '#/components/schemas/Weights'

    BatchResponse:
      type: object
      required: [totalQueries, successCount, errorCount, results, apiVersion, methodologyVersion, generatedAt, sources]
      properties:
        totalQueries:
          type: integer
        successCount:
          type: integer
        errorCount:
          type: integer
        apiVersion:
          type: string
        methodologyVersion:
          type: string
        generatedAt:
          type: string
          format: date-time
        sources:
          type: array
          items:
            type: string
        results:
          type: array
          items:
            type: object
            description: |
              Each result includes `queryIndex` and `status`. On 200, the
              full ScoreResponse fields are merged in. On error, includes
              `error` and optionally `attemptedBorough`/`supportedBoroughs`.
            properties:
              queryIndex:
                type: integer
              status:
                type: integer
              error:
                type: string

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
        attemptedBorough:
          type: string
        supportedBoroughs:
          type: array
          items:
            type: string
        supportedCities:
          type: array
          items:
            type: string
        example:
          type: object
