{
  "openapi": "3.0.3",
  "info": {
    "title": "Suomiatlas read API",
    "version": "1.0.0",
    "description": "Read-only JSON over statistics for every Finnish postal code area (~3,000) and municipality (308): population, age, income, education, employment, housing, dwelling sale prices, crime and air quality. Sources are Statistics Finland (Paavo, ashi, rpk) and the Finnish Meteorological Institute; all data is CC BY 4.0, and `/api/v1/license` returns the machine-readable attribution.\n\n**Start with `/api/v1/search`.** Every other operation takes a code, not a name. Search is diacritic-insensitive, so `q=toolo` finds Töölö.\n\n**Authentication is optional.** Everything here answers anonymously. A bearer token — an API key (`sk_live_…`) minted in an account's API keys panel, or a session JWT — raises what the same request returns. It never gates access to an endpoint.\n\n**A credential is not a subscription.** A lapsed trial and an Area Pass holder both authenticate successfully while still being on the free tier for most things. Tier comes from the account, never from the presence of a token.\n\n**Read this before treating a null as \"no data\".** Free-tier responses are truncated *silently*: rankings return 10 rows however many you ask for, history returns the latest 3 years, and several series arrive as nulls or empty arrays. Nothing in the body says so. A null therefore means either *withheld pending a subscription* or *genuinely absent* — Statistics Finland also suppresses figures for small populations — and this API cannot distinguish them for you. Each operation below states its own caps. Do not report a paywalled figure as missing data, and do not describe a 3-year window as a trend.\n\n**Rate limits** are enforced at the edge, keyed on client IP: 20 requests/second (burst 40) across `/api/`, and a much tighter 6 requests/minute (burst 3) for `report.pdf`. Exceeding either returns **429 with an HTML body**, not JSON.\n\n**No CORS headers are sent**, so cross-origin browser calls fail. Server-side callers, including ChatGPT Actions and agent frameworks, are unaffected.\n\n**On schema fidelity:** this document is OpenAPI 3.0.3 for tool compatibility, and a few invariants are therefore documented in prose and examples rather than enforced by the schema — notably the air-quality `data`-or-`gap` exclusivity. Where a description states an invariant, trust the description.\n\n**If you are an assistant that can only fetch URLs it has already encountered**, or one calling tools rather than URLs, use the Model Context Protocol server at https://suomiatlas.com/api/mcp (Streamable HTTP, hosted, nothing to install) instead of this API. It serves the same data as callable tools, and it adds the one thing this API cannot: every tool result carries a `locked` list naming the fields withheld for the caller's tier, so a truncated answer is labelled as truncated rather than looking like missing data. Human documentation for it is at https://suomiatlas.com/mcp. Every operation below also carries a full example URL, so the endpoints are reachable directly if you prefer.",
    "contact": {
      "name": "Suomiatlas",
      "url": "https://suomiatlas.com/tietoa"
    },
    "license": {
      "name": "CC BY 4.0",
      "url": "https://creativecommons.org/licenses/by/4.0/"
    }
  },
  "servers": [
    {
      "url": "https://suomiatlas.com",
      "description": "Production"
    }
  ],
  "security": [
    {},
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Places",
      "description": "Resolve names to codes, and list the code sets."
    },
    {
      "name": "Areas",
      "description": "Statistics for one postal code area."
    },
    {
      "name": "Municipalities",
      "description": "Statistics for one municipality (kunta)."
    },
    {
      "name": "Air quality",
      "description": "Pollutant readings from FMI measuring stations."
    },
    {
      "name": "Rankings",
      "description": "National and scoped orderings by any metric."
    },
    {
      "name": "Metadata",
      "description": "The metric catalogue, licence and pricing."
    }
  ],
  "paths": {
    "/api/v1/search": {
      "get": {
        "operationId": "searchPlaces",
        "summary": "Resolve a place name to a code",
        "description": "Start here. Every other operation takes a code, and this is the only one that accepts a name. Matching is diacritic-insensitive (`toolo` finds Töölö) and results are best-match first.\n\nNot tier-gated: a name is not paid data.\n\nExample request: https://suomiatlas.com/api/v1/search?q=kallio&limit=5",
        "tags": [
          "Places"
        ],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "The name to look for. Blank or missing is a 400.",
            "schema": {
              "type": "string"
            },
            "example": "Töölö"
          },
          {
            "name": "kind",
            "in": "query",
            "required": false,
            "description": "Restrict to one kind of place. Any other value is a 400.",
            "schema": {
              "type": "string",
              "enum": [
                "postal_area",
                "municipality",
                "region"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Clamped into 1..50 without error: 0 becomes 1, 999 becomes 50.",
            "schema": {
              "type": "integer",
              "default": 10,
              "minimum": 1,
              "maximum": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matches, best first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlaceSearch"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/areas": {
      "get": {
        "operationId": "listAreas",
        "summary": "List every postal code area",
        "description": "Code and name for every Finnish postal code area, ascending by code. A large response — use `/api/v1/search` to resolve a single place instead.\n\nOnly real 5-digit codes are returned; the source data's pseudo-rows (a whole-country aggregate) are dropped.\n\nExample request: https://suomiatlas.com/api/v1/areas",
        "tags": [
          "Places"
        ],
        "responses": {
          "200": {
            "description": "Every area, ascending by code.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AreaSummary"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/kunta": {
      "get": {
        "operationId": "listMunicipalities",
        "summary": "List every municipality",
        "description": "Code and name for all 308 Finnish municipalities. Municipality codes are 3 digits, zero-padded (Helsinki is `091`).\n\nExample request: https://suomiatlas.com/api/v1/kunta",
        "tags": [
          "Places"
        ],
        "responses": {
          "200": {
            "description": "Every municipality.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Kunta"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/maakunnat": {
      "get": {
        "operationId": "listRegions",
        "summary": "List every region",
        "description": "Code and name for every Finnish region (maakunta).\n\nExample request: https://suomiatlas.com/api/v1/maakunnat",
        "tags": [
          "Places"
        ],
        "responses": {
          "200": {
            "description": "Every region.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Maakunta"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/postal-codes": {
      "get": {
        "operationId": "getPostalCodeBoundaries",
        "summary": "Boundaries of all postal code areas as GeoJSON",
        "description": "A GeoJSON `FeatureCollection` of postal code area boundaries, for drawing a map. Large nationally — scope it to one municipality with `kunta`.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes?kunta=091",
        "tags": [
          "Places"
        ],
        "parameters": [
          {
            "name": "kunta",
            "in": "query",
            "required": false,
            "description": "A 3-digit municipality code. Anything else is a 400; omitted means the whole country.",
            "schema": {
              "type": "string",
              "pattern": "^\\d{3}$"
            },
            "example": "091"
          }
        ],
        "responses": {
          "200": {
            "description": "Boundaries.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GeoJsonFeatureCollection"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/geometry": {
      "get": {
        "operationId": "getAreaGeometry",
        "summary": "Boundary of one postal code area",
        "description": "A `FeatureCollection` holding exactly one feature.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/geometry",
        "tags": [
          "Places"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PostalCode"
          }
        ],
        "responses": {
          "200": {
            "description": "One boundary.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GeoJsonFeatureCollection"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/stats": {
      "get": {
        "operationId": "getAreaStats",
        "summary": "All statistics for one postal code area",
        "description": "The main area endpoint: every published metric for one area, with national, municipal and regional percentiles, plus an embedded crime block. **Entirely free** — including the latest dwelling sale prices and crime figures. History is what costs money, not the current picture.\n\nTwo things to read carefully. A null inside `variables` means Statistics Finland suppressed that figure for this area, usually because too few people or dwellings lie behind it — nothing is withheld here for payment. And `nationalMedians` is a median only for some metrics: check `nationalBenchmarkBasis` for the same key before describing any of these numbers, because for average-type metrics (mean incomes, dwelling prices) the value is a weighted mean.\n\nHousing metrics carry their own, usually newer, vintage — see `housingPricesYear`.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/stats",
        "tags": [
          "Areas"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PostalCode"
          },
          {
            "name": "year",
            "in": "query",
            "required": false,
            "description": "Defaults to the area's latest vintage. If the requested year holds no data, the response silently falls back to the latest year — so compare `year` in the response against what you asked for. A non-numeric value is a 404, not a 400.",
            "schema": {
              "type": "integer"
            },
            "example": 2024
          }
        ],
        "responses": {
          "200": {
            "description": "Statistics for the resolved year.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PostalCodeStats"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/history": {
      "get": {
        "operationId": "getAreaHistory",
        "summary": "Multi-year series for one postal code area",
        "description": "Time series for every metric of one area. `years` is the shared x-axis and every series is index-aligned to it, with nulls where a figure is missing.\n\n**Two independent free-tier limits apply here, and neither is announced in the body.**\n\n1. Without Pro, `years` is cut to the **latest 3 years** and every series shortens with it. A 3-year window is not a trend — do not describe it as one. Pro returns the full record from 2010.\n2. Without Pro or an Area Pass covering this exact area, every dwelling-price metric (`housing_price_m2_*`, `housing_txn_count_*`) has **both** its `values` and its `nationalMedians` replaced by nulls, for all years. The keys stay present, so the series looks like an area with no sales rather than a withheld one. Pro or the matching Area Pass returns the full series from 2009.\n\nNote the asymmetry: the year window needs Pro, while the dwelling-price series also accepts an Area Pass for this one area.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/history",
        "tags": [
          "Areas"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PostalCode"
          }
        ],
        "responses": {
          "200": {
            "description": "Series, subject to the two limits above.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PostalCodeHistory"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/trends": {
      "get": {
        "operationId": "getAreaTrends",
        "summary": "Derived trend metrics for one postal code area",
        "description": "Computed change metrics — population growth, income momentum, ageing velocity and so on — each with the window it was measured over in `windowYears`. Income and unemployment momentum use a fixed 5-year window.\n\nFree, deliberately: the latest value of every trend metric is open. The per-year series behind them is the paid part, at `/derived-history`.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/trends",
        "tags": [
          "Areas"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PostalCode"
          },
          {
            "name": "year",
            "in": "query",
            "required": false,
            "description": "Defaults to the latest year in the record. A non-numeric value is a 404.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Trend metrics.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TrendMetrics"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/composites": {
      "get": {
        "operationId": "getAreaCompositeScores",
        "summary": "Composite scores for one postal code area",
        "description": "Four composite indices, each 0–100 with a national rank: `gentrification`, `family_friendliness`, `vitality` and `affluence`.\n\n**Free-tier limit, silent:** the headline `score`, `rank` and `total` are free, but without Pro or an Area Pass for this exact area every `components[].percentile` is **null** while `metric`, `name` and `weight` remain. So the factors behind a score are named but not quantified. A caller with access sees real percentiles there.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/composites",
        "tags": [
          "Areas"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PostalCode"
          },
          {
            "name": "year",
            "in": "query",
            "required": false,
            "description": "Defaults to the latest year. A non-numeric value is a 404.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Composite scores, components possibly redacted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CompositeScores"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/derived-history": {
      "get": {
        "operationId": "getAreaDerivedHistory",
        "summary": "Per-year series for composite scores and trend metrics",
        "description": "The history behind `/composites` and `/trends`: up to 10 years per metric, index-aligned to `years`.\n\n**Paid outright.** Without Pro or an Area Pass for this exact area the whole request is rejected with **402**, and that check runs *before* the area is looked up — so an unknown postal code also answers 402 to a free caller rather than 404. Do not read a 402 here as evidence the area exists.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/derived-history",
        "tags": [
          "Areas"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PostalCode"
          }
        ],
        "responses": {
          "200": {
            "description": "Derived series.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DerivedHistory"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/similar": {
      "get": {
        "operationId": "findSimilarAreas",
        "summary": "Areas statistically similar to this one",
        "description": "Nearest neighbours by statistical profile, not by distance on the map, each with a `similarity` score. Free.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/similar?k=5",
        "tags": [
          "Areas"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PostalCode"
          },
          {
            "name": "year",
            "in": "query",
            "required": false,
            "description": "Defaults to the latest year. A non-numeric value is a 404.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "k",
            "in": "query",
            "required": false,
            "description": "How many neighbours to return. Below 1 is a 400; above 50 is silently clamped to 50, not rejected.",
            "schema": {
              "type": "integer",
              "default": 10,
              "minimum": 1,
              "maximum": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Neighbours, most similar first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SimilarAreas"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/report.pdf": {
      "get": {
        "operationId": "getAreaReportPdf",
        "summary": "Download a PDF report for one area",
        "description": "A rendered PDF summary of one area, returned as an attachment.\n\n**Paid outright:** 402 without Pro or an Area Pass for this exact area. Unlike `/derived-history`, validation runs *first* here — a malformed code is a 400 and an unknown one a 404 for everybody, so a 402 does imply the area exists.\n\nRate-limited far more tightly than the rest of the API: **6 requests per minute**, burst 3. Rendering is synchronous, so expect seconds, not milliseconds, and a 503 if the renderer is unavailable.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/report.pdf?lang=en",
        "tags": [
          "Areas"
        ],
        "parameters": [
          {
            "name": "postalCode",
            "in": "path",
            "required": true,
            "description": "A 5-digit Finnish postal code. This is the one path parameter that is format-checked: anything else is a 400.",
            "schema": {
              "type": "string",
              "pattern": "^\\d{5}$"
            },
            "example": "00120"
          },
          {
            "name": "year",
            "in": "query",
            "required": false,
            "description": "Defaults to the latest year.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "lang",
            "in": "query",
            "required": false,
            "description": "Report language. An unsupported value is silently ignored rather than rejected.",
            "schema": {
              "type": "string",
              "enum": [
                "fi",
                "sv",
                "en"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The PDF, as an attachment.",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "503": {
            "description": "The PDF renderer is unavailable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorMessage"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/kunta/{kuntaCode}/stats": {
      "get": {
        "operationId": "getMunicipalityStats",
        "summary": "Statistics for one municipality",
        "description": "One municipality's population, crime, dwelling price medians and the list of postal code areas inside it.\n\n**Free-tier limits, silent, and Pro-only — an Area Pass never helps here**, because a municipality aggregates all of its areas:\n\n- `populationSeries` is cut to its **last 3 entries**.\n- `housingPriceSeries` is **null** — not an empty array, and not an error. The latest-year medians in `housingPrices` stay free.\n\nExample request: https://suomiatlas.com/api/v1/kunta/091/stats",
        "tags": [
          "Municipalities"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/KuntaCode"
          }
        ],
        "responses": {
          "200": {
            "description": "Municipality statistics, series possibly trimmed or withheld.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KuntaStats"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/postal-codes/{postalCode}/air-quality": {
      "get": {
        "operationId": "getAreaAirQuality",
        "summary": "Air quality for one postal code area",
        "description": "PM2.5, PM10, NO2 and O3 from the nearest FMI measuring station, with WHO and EU benchmarks and the station's identity and distance.\n\nAll four pollutant blocks are always present. **Per block, exactly one of `data` or `gap` is non-null**: `data` when a station is close enough, `gap` when none is (`reason` is `TOO_FAR` or `NO_STATION`; the threshold is 15 km for NO2 and PM10, 30 km otherwise). When `data` is present so is `provenance`. For O3 the whole `benchmarks` object is null — not its individual numbers.\n\n**Free-tier limit, silent:** without Pro or an Area Pass for this exact area, every `data.series` is an **empty array** while `data.latest`, the benchmarks and the station provenance stay. So the current reading is free and the history is not — and an empty `series` here means withheld, not unmeasured.\n\nExample request: https://suomiatlas.com/api/v1/postal-codes/00120/air-quality",
        "tags": [
          "Air quality"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/PostalCode"
          }
        ],
        "responses": {
          "200": {
            "description": "Air quality, series possibly emptied.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AirQuality"
                },
                "examples": {
                  "stationFound": {
                    "summary": "A station in range — data present, gap null",
                    "value": {
                      "areaCode": "00120",
                      "kuntaCode": "091",
                      "kuntaName": "Helsinki",
                      "pollutants": [
                        {
                          "code": "aq_pm25_avg",
                          "unit": "µg/m³",
                          "benchmarks": {
                            "who2021": 5.0,
                            "euLimit": 25.0,
                            "eu2030": 10.0
                          },
                          "data": {
                            "latest": {
                              "year": 2025,
                              "value": 5.4
                            },
                            "series": [
                              {
                                "year": 2024,
                                "value": 5.9
                              },
                              {
                                "year": 2025,
                                "value": 5.4
                              }
                            ]
                          },
                          "provenance": {
                            "fmisid": 100662,
                            "name": "Helsinki Kallio 2",
                            "distanceKm": 2.1,
                            "tier": "local"
                          },
                          "kuntaProvenance": null,
                          "gap": null
                        }
                      ]
                    }
                  },
                  "noStationInRange": {
                    "summary": "No station in range — gap present, data null",
                    "value": {
                      "areaCode": "73970",
                      "kuntaCode": "687",
                      "kuntaName": "Rautavaara",
                      "pollutants": [
                        {
                          "code": "aq_no2_avg",
                          "unit": "µg/m³",
                          "benchmarks": {
                            "who2021": 10.0,
                            "euLimit": 40.0,
                            "eu2030": 20.0
                          },
                          "data": null,
                          "provenance": null,
                          "kuntaProvenance": null,
                          "gap": {
                            "reason": "TOO_FAR",
                            "nearestStation": "Kuopio Savolanniemi",
                            "distanceKm": 62.4,
                            "maxKm": 15.0
                          }
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/kunta/{kuntaCode}/air-quality": {
      "get": {
        "operationId": "getMunicipalityAirQuality",
        "summary": "Air quality across one municipality",
        "description": "The median reading across every station in a municipality, with `kuntaProvenance` naming the stations behind it instead of the single-station `provenance`. Same `data`-or-`gap` rule per pollutant as the area endpoint.\n\n**Free-tier limit, silent, and Pro-only** — an Area Pass covers one postal code and cannot cover a municipality: every `data.series` is emptied, while the latest reading and benchmarks stay.\n\nExample request: https://suomiatlas.com/api/v1/kunta/091/air-quality",
        "tags": [
          "Air quality"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/KuntaCode"
          }
        ],
        "responses": {
          "200": {
            "description": "Air quality, series possibly emptied.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AirQuality"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/v1/air-quality/kunta-ranking": {
      "get": {
        "operationId": "rankMunicipalitiesByPollutant",
        "summary": "Municipalities ranked by one pollutant",
        "description": "Every municipality with a served reading, **cleanest first** (ascending). Free and uncapped.\n\nExample request: https://suomiatlas.com/api/v1/air-quality/kunta-ranking?pollutant=aq_pm25_avg&year=2024",
        "tags": [
          "Air quality"
        ],
        "parameters": [
          {
            "name": "pollutant",
            "in": "query",
            "required": true,
            "description": "Missing is a 400, and so is an unrecognised value — a 400, not a 404.",
            "schema": {
              "type": "string",
              "enum": [
                "aq_pm25_avg",
                "aq_pm10_avg",
                "aq_no2_avg",
                "aq_o3_avg"
              ]
            }
          },
          {
            "name": "year",
            "in": "query",
            "required": true,
            "description": "Missing is a 400. A valid pollutant with no data for the year is also a 400, not an empty list.",
            "schema": {
              "type": "integer"
            },
            "example": 2024
          }
        ],
        "responses": {
          "200": {
            "description": "Ranking, cleanest first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AirQualityRanking"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/rankings": {
      "get": {
        "operationId": "rankAreas",
        "summary": "Rank areas nationally or within a scope",
        "description": "Orders areas by any metric, optionally scoped to a municipality, a region, or a radius around a place.\n\n**Free-tier limit, silent:** `limit` is capped at **10** rows without Pro and 100 with it. Asking for 50 as a free caller returns 10 rows with no error and no flag — check the length you got against the length you asked for.\n\n**Composite metrics are paid:** `gentrification`, `family_friendliness`, `vitality` and `affluence` return **402** without Pro.\n\n**The four crime metrics change the unit of analysis.** For `crime_total_rate`, `crime_violent_rate`, `crime_property_rate` and `crime_narcotics_rate`, municipalities are ranked rather than postal areas — crime is only published at municipality level. The response shape is unchanged, so `entries[].postalCode` then carries a **3-digit municipality code** and `entries[].name` a municipality name. The `kunta` and `maakunta` filters are ignored for these metrics.\n\n**`percentile` is a position, not a verdict.** It says where a value sits in the distribution, nothing about whether that position is good.\n\nExample request: https://suomiatlas.com/api/v1/rankings?variableCode=hr_mtu&year=2024&limit=10",
        "tags": [
          "Rankings"
        ],
        "parameters": [
          {
            "name": "variableCode",
            "in": "query",
            "required": true,
            "description": "Any code from `/api/v1/variables`. Missing or blank is a 400.",
            "schema": {
              "type": "string"
            },
            "example": "hr_mtu"
          },
          {
            "name": "year",
            "in": "query",
            "required": true,
            "description": "Missing is a 400. Not range-checked: an implausible year returns an empty ranking rather than an error. A non-numeric value is a 404.",
            "schema": {
              "type": "integer"
            },
            "example": 2024
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "`top` is highest first, `bottom` lowest first, `balance` closest first to the metric's target value. `balance` is valid **only** for a metric that has a target — today that is `gender_balance_20_39` alone, whose target is an even 1.0 — and is a 400 for any other metric.",
            "schema": {
              "type": "string",
              "enum": [
                "top",
                "bottom",
                "balance"
              ],
              "default": "top"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Below 1 is a 400. Above 100 is silently clamped to 100, and above 10 is silently clamped to 10 for a free caller.",
            "schema": {
              "type": "integer",
              "default": 10,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "kunta",
            "in": "query",
            "required": false,
            "description": "Rank only within this municipality. **Takes precedence over `maakunta`**, which is then ignored entirely. Not format-checked here.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "maakunta",
            "in": "query",
            "required": false,
            "description": "Rank only within this region. Ignored when `kunta` is also given.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "near",
            "in": "query",
            "required": false,
            "description": "A postal code or municipality code to measure from. **Must be given together with `withinKm`** — one without the other is a 400. An unknown code is a 400.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "withinKm",
            "in": "query",
            "required": false,
            "description": "Radius around `near`, in kilometres. Must be finite and greater than 0. Straight-line ground distance, **not** travel time; distances are rounded to 0.1 km.",
            "schema": {
              "type": "number",
              "format": "double",
              "exclusiveMinimum": true,
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The ranking, capped as described above.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ranking"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          }
        }
      }
    },
    "/api/v1/stats": {
      "get": {
        "operationId": "compareAreas",
        "summary": "Compare several postal code areas side by side",
        "description": "One column per area, grouped by metric group, with national benchmarks shared across the columns.\n\n**Free-tier limit, silent:** a free caller's list is **truncated to the first 2 areas**. Passing 5 codes returns 200 with 2 columns and nothing saying 3 were dropped — count `areas` against what you sent. Pro allows 12.\n\nAsking for more than 12 is a 400 for everyone. Duplicates are removed, caller order preserved. `asOf` appears for metrics whose latest published year lags the year you asked for, so a single response can mix vintages — label them.\n\nExample request: https://suomiatlas.com/api/v1/stats?postalCodes=00120,00180",
        "tags": [
          "Areas"
        ],
        "parameters": [
          {
            "name": "postalCodes",
            "in": "query",
            "required": true,
            "description": "Comma-separated postal codes. Required; more than 12 unique codes is a 400.",
            "schema": {
              "type": "string"
            },
            "example": "00120,00180,02150"
          },
          {
            "name": "year",
            "in": "query",
            "required": false,
            "description": "Defaults to the latest year. A non-numeric value is a 404.",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The comparison, possibly truncated to 2 columns.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AreaComparison"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/variables": {
      "get": {
        "operationId": "listVariables",
        "summary": "Catalogue of every available metric",
        "description": "A bare object keyed by group name, each value an array of metric definitions. This is the source of the `variableCode` values the other operations take.\n\nThe catalogue is complete regardless of tier: it lists paid metrics too, so a composite appears here even though ranking by it needs Pro. `type` is `composite` for the four composite indices and `crime` for the four municipality-level crime rates.\n\nExample request: https://suomiatlas.com/api/v1/variables\n\nWhat each group actually counts, when Statistics Finland withholds it, and the misreading it invites is documented at https://suomiatlas.com/en/metrics (Finnish: https://suomiatlas.com/mittarit). Read that before describing any figure from this API: `hr_mtu` is not a salary, and the income-category variables count people rather than euros.",
        "tags": [
          "Metadata"
        ],
        "responses": {
          "200": {
            "description": "Metrics, grouped.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "description": "Group name to the metrics in that group.",
                  "additionalProperties": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/VariableMetadata"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/variable-values": {
      "get": {
        "operationId": "getVariableValues",
        "summary": "One metric's value for every area",
        "description": "Every postal code area's value for a single metric and year — roughly 3,000 entries, intended for painting a choropleth. For the question \"which areas score highest\", `/api/v1/rankings` is the better fit.\n\n**Composite metrics are paid:** a composite code returns **402** without Pro. Everything else, including crime and dwelling prices, is free. Nulls are Statistics Finland suppression, not withholding.\n\nExample request: https://suomiatlas.com/api/v1/variable-values?variableCode=hr_mtu&year=2024",
        "tags": [
          "Metadata"
        ],
        "parameters": [
          {
            "name": "variableCode",
            "in": "query",
            "required": true,
            "description": "Missing or blank is a 400.",
            "schema": {
              "type": "string"
            },
            "example": "hr_mtu"
          },
          {
            "name": "year",
            "in": "query",
            "required": true,
            "description": "Missing is a 400. A non-numeric value is a 404.",
            "schema": {
              "type": "integer"
            },
            "example": 2024
          }
        ],
        "responses": {
          "200": {
            "description": "Postal code to value.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VariableValues"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "402": {
            "$ref": "#/components/responses/PaymentRequired"
          }
        }
      }
    },
    "/api/v1/variable-years": {
      "get": {
        "operationId": "getVariableYears",
        "summary": "Which years one metric is published for",
        "description": "The years a metric has data for, **newest first**. Useful before asking for a year that does not exist. Free.\n\nExample request: https://suomiatlas.com/api/v1/variable-years?variableCode=hr_mtu",
        "tags": [
          "Metadata"
        ],
        "parameters": [
          {
            "name": "variableCode",
            "in": "query",
            "required": true,
            "description": "Missing or blank is a 400.",
            "schema": {
              "type": "string"
            },
            "example": "hr_mtu"
          }
        ],
        "responses": {
          "200": {
            "description": "Years, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VariableYears"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          }
        }
      }
    },
    "/api/v1/license": {
      "get": {
        "operationId": "getLicense",
        "summary": "Data licence and source attribution",
        "description": "The machine-readable licence and per-source attribution for everything this API serves: CC BY 4.0, over four upstream datasets. Cite the original publisher, not Suomiatlas, when reusing figures.\n\nExample request: https://suomiatlas.com/api/v1/license",
        "tags": [
          "Metadata"
        ],
        "responses": {
          "200": {
            "description": "Licence and sources.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LicenseInfo"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/plans": {
      "get": {
        "operationId": "listPlans",
        "summary": "Subscription prices",
        "description": "The purchasable plans and their prices, for showing what a paid tier costs. Anonymous by design. Payment-provider product identifiers are deliberately not exposed.\n\nExample request: https://suomiatlas.com/api/v1/plans",
        "tags": [
          "Metadata"
        ],
        "responses": {
          "200": {
            "description": "Plans with a price.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlanPrice"
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Optional. An API key (`sk_live_…`) from an account's API keys panel, or a session JWT. Sent as `Authorization: Bearer …`. It raises what a request returns; it never gates an endpoint, and holding one does not imply a paid tier."
      }
    },
    "parameters": {
      "PostalCode": {
        "name": "postalCode",
        "in": "path",
        "required": true,
        "description": "A 5-digit Finnish postal code. Not format-checked on this operation: an invalid code produces a 404 rather than a 400.",
        "schema": {
          "type": "string"
        },
        "example": "00120"
      },
      "KuntaCode": {
        "name": "kuntaCode",
        "in": "path",
        "required": true,
        "description": "A 3-digit, zero-padded municipality code — Helsinki is `091`. Resolve one with `/api/v1/search?kind=municipality`.",
        "schema": {
          "type": "string"
        },
        "example": "091"
      }
    },
    "responses": {
      "BadRequest": {
        "description": "A parameter was missing, malformed, or outside what this operation accepts. The message names the parameter.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorMessage"
            },
            "example": {
              "code": 400,
              "message": "Query parameter 'variableCode' is required"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such place, or no data for it. Also what a non-numeric `year` produces, because query-parameter conversion fails before the operation runs.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorMessage"
            },
            "example": {
              "code": 404,
              "message": "No statistics found for postal code: 99999"
            }
          }
        }
      },
      "PaymentRequired": {
        "description": "This data needs a paid entitlement. **Note the different body shape** — `error`, not `code`/`message`. There is no 401 or 403 anywhere in this API: an anonymous caller is a valid caller with fewer entitlements.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PaywallError"
            },
            "example": {
              "error": "A Pro subscription is required for composite scores"
            }
          }
        }
      }
    },
    "schemas": {
      "ErrorMessage": {
        "type": "object",
        "description": "The framework's error shape, used by every 4xx and 5xx except the paywall responses.",
        "properties": {
          "code": {
            "type": "integer",
            "description": "Repeats the HTTP status."
          },
          "message": {
            "type": "string"
          },
          "details": {
            "type": "string",
            "description": "Occasionally present."
          }
        },
        "required": [
          "code",
          "message"
        ]
      },
      "PaywallError": {
        "type": "object",
        "description": "The 402 body. Deliberately documented separately: it shares no field with ErrorMessage, so a client that only parses `code`/`message` reads a paywall as an empty error.",
        "properties": {
          "error": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ]
      },
      "NullableNumberMap": {
        "type": "object",
        "description": "Metric code to value. A key may be present with a null value — Statistics Finland suppresses figures resting on too few people or dwellings.",
        "additionalProperties": {
          "type": "number",
          "format": "double",
          "nullable": true
        }
      },
      "StringMap": {
        "type": "object",
        "additionalProperties": {
          "type": "string"
        }
      },
      "AreaSummary": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "description": "5-digit postal code."
          },
          "name": {
            "type": "string"
          }
        },
        "required": [
          "code",
          "name"
        ]
      },
      "Kunta": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "description": "3-digit municipality code."
          },
          "name": {
            "type": "string"
          }
        },
        "required": [
          "code",
          "name"
        ]
      },
      "Maakunta": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          }
        },
        "required": [
          "code",
          "name"
        ]
      },
      "PlaceSearch": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "The trimmed query, echoed."
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlaceSearchResult"
            }
          }
        },
        "required": [
          "query",
          "results"
        ]
      },
      "PlaceSearchResult": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "postal_area",
              "municipality",
              "region"
            ]
          },
          "code": {
            "type": "string",
            "description": "Feed this to the other operations."
          },
          "name": {
            "type": "string"
          },
          "population": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "Present only for postal areas; null otherwise."
          },
          "populationYear": {
            "type": "integer",
            "nullable": true,
            "description": "Never null when `population` is set."
          }
        },
        "required": [
          "kind",
          "code",
          "name",
          "population",
          "populationYear"
        ]
      },
      "GeoJsonFeatureCollection": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "FeatureCollection"
            ]
          },
          "features": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/GeoJsonFeature"
            }
          }
        },
        "required": [
          "type",
          "features"
        ]
      },
      "GeoJsonFeature": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "Feature"
            ]
          },
          "geometry": {
            "type": "object",
            "additionalProperties": true,
            "description": "A GeoJSON geometry object, in practice a Polygon or MultiPolygon in WGS84. Left free-form deliberately: the API applies no geometry-type validation, so a stricter schema here would promise a guarantee that does not exist.",
            "example": {
              "type": "Polygon",
              "coordinates": [
                [
                  [
                    24.93,
                    60.16
                  ],
                  [
                    24.95,
                    60.16
                  ],
                  [
                    24.95,
                    60.17
                  ],
                  [
                    24.93,
                    60.16
                  ]
                ]
              ]
            }
          },
          "properties": {
            "$ref": "#/components/schemas/GeoJsonProperties"
          }
        },
        "required": [
          "type",
          "geometry",
          "properties"
        ]
      },
      "GeoJsonProperties": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "municipalityCode": {
            "type": "string"
          },
          "maakuntaCode": {
            "type": "string",
            "nullable": true,
            "description": "Nullable, unlike its siblings: the municipality-to-region mapping is ingested separately from the geometry, so an area can legitimately have no region yet."
          }
        },
        "required": [
          "postalCode",
          "name",
          "municipalityCode",
          "maakuntaCode"
        ]
      },
      "PostalCodeStats": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "year": {
            "type": "integer",
            "description": "The year actually served, which may differ from the one requested."
          },
          "variables": {
            "$ref": "#/components/schemas/NullableNumberMap"
          },
          "variableNames": {
            "$ref": "#/components/schemas/StringMap"
          },
          "variableGroups": {
            "type": "object",
            "description": "Group name to the metrics in that group, for laying out a panel.",
            "additionalProperties": {
              "type": "array",
              "items": {
                "$ref": "#/components/schemas/VariableGroupEntry"
              }
            }
          },
          "nationalMedians": {
            "allOf": [
              {
                "$ref": "#/components/schemas/NullableNumberMap"
              }
            ],
            "description": "**Not always a median.** The name is kept for compatibility; for average-type metrics (mean incomes, dwelling prices) the value is a population-weighted mean. Read `nationalBenchmarkBasis` for the same key before describing the number."
          },
          "nationalBenchmarkBasis": {
            "allOf": [
              {
                "$ref": "#/components/schemas/StringMap"
              }
            ],
            "description": "How each national figure was produced — e.g. `area-median`, `finland-wide`."
          },
          "nationalBenchmarkCoverage": {
            "allOf": [
              {
                "$ref": "#/components/schemas/NullableNumberMap"
              }
            ],
            "description": "Share of known positive weight behind the figure, 0–1. Sparse: absent for the median-across-areas basis."
          },
          "ratioBenchmarks": {
            "type": "object",
            "description": "National benchmarks for numerator/denominator pairs, keyed `\"numeratorCode:denominatorCode\"`.",
            "additionalProperties": {
              "$ref": "#/components/schemas/RatioBenchmark"
            }
          },
          "percentiles": {
            "allOf": [
              {
                "$ref": "#/components/schemas/NullableNumberMap"
              }
            ],
            "description": "Where this area sits nationally, 0–100. A position, not a judgement."
          },
          "kuntaPercentiles": {
            "$ref": "#/components/schemas/NullableNumberMap"
          },
          "maakuntaPercentiles": {
            "$ref": "#/components/schemas/NullableNumberMap"
          },
          "maakuntaName": {
            "type": "string",
            "nullable": true
          },
          "crime": {
            "type": "object",
            "allOf": [
              {
                "$ref": "#/components/schemas/CrimeStats"
              }
            ],
            "nullable": true,
            "description": "Municipality-level crime for this area's municipality. Null when the municipality or crime year is unknown."
          },
          "housingPricesYear": {
            "type": "integer",
            "nullable": true,
            "description": "Dwelling-price metrics carry their own, usually newer, vintage than `year`."
          }
        },
        "required": [
          "postalCode",
          "year",
          "variables",
          "variableNames",
          "variableGroups",
          "nationalMedians",
          "nationalBenchmarkBasis",
          "nationalBenchmarkCoverage",
          "ratioBenchmarks",
          "percentiles",
          "kuntaPercentiles",
          "maakuntaPercentiles",
          "maakuntaName",
          "crime",
          "housingPricesYear"
        ]
      },
      "VariableGroupEntry": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "value": {
            "type": "number",
            "format": "double",
            "nullable": true
          }
        },
        "required": [
          "code",
          "name",
          "value"
        ]
      },
      "RatioBenchmark": {
        "type": "object",
        "properties": {
          "ratio": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "coverage": {
            "type": "number",
            "format": "double",
            "description": "Share of areas behind the figure, 0–1."
          }
        },
        "required": [
          "ratio",
          "coverage"
        ]
      },
      "CrimeStats": {
        "type": "object",
        "nullable": true,
        "description": "Crime is published per municipality, never per postal area, so this block describes the area's whole municipality.",
        "properties": {
          "kuntaCode": {
            "type": "string"
          },
          "kuntaName": {
            "type": "string"
          },
          "year": {
            "type": "integer"
          },
          "maakuntaName": {
            "type": "string",
            "nullable": true
          },
          "metrics": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CrimeMetric"
            }
          }
        },
        "required": [
          "kuntaCode",
          "kuntaName",
          "year",
          "maakuntaName",
          "metrics"
        ]
      },
      "CrimeMetric": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "unit": {
            "type": "string",
            "example": "per 1,000 inhab."
          },
          "value": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "nationalPercentile": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "maakuntaPercentile": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "trend": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TrendPoint"
            }
          }
        },
        "required": [
          "code",
          "name",
          "unit",
          "value",
          "nationalPercentile",
          "maakuntaPercentile",
          "trend"
        ]
      },
      "TrendPoint": {
        "type": "object",
        "properties": {
          "year": {
            "type": "integer"
          },
          "value": {
            "type": "number",
            "format": "double"
          }
        },
        "required": [
          "year",
          "value"
        ]
      },
      "PostalCodeHistory": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "years": {
            "type": "array",
            "description": "The shared x-axis, ascending. **Only the latest 3 entries without Pro.**",
            "items": {
              "type": "integer"
            }
          },
          "series": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/MetricSeries"
            }
          },
          "variableNames": {
            "$ref": "#/components/schemas/StringMap"
          }
        },
        "required": [
          "postalCode",
          "years",
          "series",
          "variableNames"
        ]
      },
      "MetricSeries": {
        "type": "object",
        "description": "Both arrays are index-aligned to `years`. For dwelling-price metrics without Pro or a matching Area Pass, both are all-null — withheld, not absent.",
        "properties": {
          "values": {
            "type": "array",
            "items": {
              "type": "number",
              "format": "double",
              "nullable": true
            }
          },
          "nationalMedians": {
            "type": "array",
            "items": {
              "type": "number",
              "format": "double",
              "nullable": true
            }
          }
        },
        "required": [
          "values",
          "nationalMedians"
        ]
      },
      "TrendMetrics": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "year": {
            "type": "integer"
          },
          "metrics": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TrendMetric"
            }
          }
        },
        "required": [
          "postalCode",
          "year",
          "metrics"
        ]
      },
      "TrendMetric": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "value": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "unit": {
            "type": "string"
          },
          "windowYears": {
            "type": "integer",
            "description": "How many years the change was measured over. Quote it: a 5-year figure is not an annual one."
          }
        },
        "required": [
          "code",
          "name",
          "value",
          "unit",
          "windowYears"
        ]
      },
      "CompositeScores": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "year": {
            "type": "integer"
          },
          "composites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CompositeScore"
            }
          }
        },
        "required": [
          "postalCode",
          "year",
          "composites"
        ]
      },
      "CompositeScore": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "enum": [
              "gentrification",
              "family_friendliness",
              "vitality",
              "affluence"
            ]
          },
          "name": {
            "type": "string"
          },
          "version": {
            "type": "integer",
            "description": "The scoring formula's version."
          },
          "score": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "0–100."
          },
          "rank": {
            "type": "integer",
            "nullable": true,
            "description": "1 is highest, out of `total`. Null when the area has no score."
          },
          "total": {
            "type": "integer",
            "description": "How many areas have a score for this composite."
          },
          "components": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ComponentBreakdown"
            }
          }
        },
        "required": [
          "id",
          "name",
          "version",
          "score",
          "rank",
          "total",
          "components"
        ]
      },
      "ComponentBreakdown": {
        "type": "object",
        "properties": {
          "metric": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "percentile": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "**Null for callers without Pro or an Area Pass for this area** — withheld, not unknown."
          },
          "weight": {
            "type": "number",
            "format": "double",
            "description": "This component's share of the score."
          }
        },
        "required": [
          "metric",
          "name",
          "percentile",
          "weight"
        ]
      },
      "DerivedHistory": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "years": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Ascending, at most 10."
          },
          "series": {
            "type": "object",
            "description": "Composite id or trend metric code to values, index-aligned to `years`.",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "number",
                "format": "double",
                "nullable": true
              }
            }
          },
          "names": {
            "$ref": "#/components/schemas/StringMap"
          },
          "units": {
            "$ref": "#/components/schemas/StringMap"
          }
        },
        "required": [
          "postalCode",
          "years",
          "series",
          "names",
          "units"
        ]
      },
      "SimilarAreas": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "year": {
            "type": "integer"
          },
          "neighbors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SimilarArea"
            }
          }
        },
        "required": [
          "postalCode",
          "year",
          "neighbors"
        ]
      },
      "SimilarArea": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "similarity": {
            "type": "number",
            "format": "double",
            "description": "Higher is more similar."
          }
        },
        "required": [
          "postalCode",
          "name",
          "similarity"
        ]
      },
      "KuntaStats": {
        "type": "object",
        "properties": {
          "kuntaCode": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "maakuntaName": {
            "type": "string",
            "nullable": true
          },
          "population": {
            "$ref": "#/components/schemas/YearValue"
          },
          "populationSeries": {
            "type": "array",
            "description": "**Only the last 3 entries without Pro.**",
            "items": {
              "$ref": "#/components/schemas/YearValue"
            }
          },
          "crime": {
            "type": "object",
            "allOf": [
              {
                "$ref": "#/components/schemas/CrimeStats"
              }
            ],
            "nullable": true
          },
          "housingPrices": {
            "type": "array",
            "description": "Latest-year medians per dwelling type. Free.",
            "items": {
              "$ref": "#/components/schemas/HousingPriceMedian"
            }
          },
          "housingPriceSeries": {
            "type": "array",
            "nullable": true,
            "description": "**Null — not an empty array — without Pro.** An Area Pass does not unlock it.",
            "items": {
              "$ref": "#/components/schemas/HousingPriceSeries"
            }
          },
          "postalCodes": {
            "type": "array",
            "description": "The postal code areas inside this municipality.",
            "items": {
              "$ref": "#/components/schemas/AreaSummary"
            }
          }
        },
        "required": [
          "kuntaCode",
          "name",
          "maakuntaName",
          "population",
          "populationSeries",
          "crime",
          "housingPrices",
          "housingPriceSeries",
          "postalCodes"
        ]
      },
      "YearValue": {
        "type": "object",
        "properties": {
          "year": {
            "type": "integer"
          },
          "value": {
            "type": "number",
            "format": "double",
            "nullable": true
          }
        },
        "required": [
          "year",
          "value"
        ]
      },
      "HousingPriceMedian": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "year": {
            "type": "integer"
          },
          "medianEurPerM2": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "areasCounted": {
            "type": "integer",
            "description": "How many areas the median rests on. A low count is a thin market — say so rather than quoting the figure bare."
          }
        },
        "required": [
          "code",
          "name",
          "year",
          "medianEurPerM2",
          "areasCounted"
        ]
      },
      "HousingPriceSeries": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "series": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/YearValue"
            }
          }
        },
        "required": [
          "code",
          "name",
          "series"
        ]
      },
      "AirQuality": {
        "type": "object",
        "properties": {
          "areaCode": {
            "type": "string",
            "nullable": true,
            "description": "The postal code asked for; null on the municipality endpoint."
          },
          "kuntaCode": {
            "type": "string"
          },
          "kuntaName": {
            "type": "string"
          },
          "pollutants": {
            "type": "array",
            "description": "All four pollutants, always present: aq_pm25_avg, aq_pm10_avg, aq_no2_avg, aq_o3_avg.",
            "items": {
              "$ref": "#/components/schemas/PollutantBlock"
            }
          }
        },
        "required": [
          "areaCode",
          "kuntaCode",
          "kuntaName",
          "pollutants"
        ]
      },
      "PollutantBlock": {
        "type": "object",
        "description": "Invariants this schema cannot enforce, so read them here: exactly one of `data` and `gap` is non-null; when `data` is present, the area endpoint fills `provenance` and the municipality endpoint fills `kuntaProvenance`; and `benchmarks` is null in its entirety for O3, rather than carrying null numbers. See the paired examples on the area operation.",
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "aq_pm25_avg",
              "aq_pm10_avg",
              "aq_no2_avg",
              "aq_o3_avg"
            ]
          },
          "unit": {
            "type": "string",
            "example": "µg/m³"
          },
          "benchmarks": {
            "type": "object",
            "allOf": [
              {
                "$ref": "#/components/schemas/AirQualityBenchmarks"
              }
            ],
            "nullable": true
          },
          "data": {
            "type": "object",
            "allOf": [
              {
                "$ref": "#/components/schemas/AirQualityData"
              }
            ],
            "nullable": true
          },
          "provenance": {
            "type": "object",
            "allOf": [
              {
                "$ref": "#/components/schemas/StationProvenance"
              }
            ],
            "nullable": true
          },
          "kuntaProvenance": {
            "type": "object",
            "allOf": [
              {
                "$ref": "#/components/schemas/KuntaProvenance"
              }
            ],
            "nullable": true
          },
          "gap": {
            "type": "object",
            "allOf": [
              {
                "$ref": "#/components/schemas/AirQualityGap"
              }
            ],
            "nullable": true
          }
        },
        "required": [
          "code",
          "unit",
          "benchmarks",
          "data",
          "provenance",
          "kuntaProvenance",
          "gap"
        ]
      },
      "AirQualityBenchmarks": {
        "type": "object",
        "nullable": true,
        "description": "Reference limits for this pollutant, for saying whether a reading is high.",
        "properties": {
          "who2021": {
            "type": "number",
            "format": "double"
          },
          "euLimit": {
            "type": "number",
            "format": "double"
          },
          "eu2030": {
            "type": "number",
            "format": "double"
          }
        },
        "required": [
          "who2021",
          "euLimit",
          "eu2030"
        ]
      },
      "AirQualityData": {
        "type": "object",
        "nullable": true,
        "properties": {
          "latest": {
            "$ref": "#/components/schemas/AirQualityYearValue"
          },
          "series": {
            "type": "array",
            "description": "**Empty without Pro or an Area Pass for this area** — withheld, not unmeasured. `latest` stays populated either way.",
            "items": {
              "$ref": "#/components/schemas/AirQualityYearValue"
            }
          }
        },
        "required": [
          "latest",
          "series"
        ]
      },
      "AirQualityYearValue": {
        "type": "object",
        "properties": {
          "year": {
            "type": "integer"
          },
          "value": {
            "type": "number",
            "format": "double"
          }
        },
        "required": [
          "year",
          "value"
        ]
      },
      "StationProvenance": {
        "type": "object",
        "nullable": true,
        "description": "Which station the reading came from. Quote the distance: a reading from 25 km away describes the region, not the street.",
        "properties": {
          "fmisid": {
            "type": "integer",
            "description": "The FMI station identifier."
          },
          "name": {
            "type": "string"
          },
          "distanceKm": {
            "type": "number",
            "format": "double"
          },
          "tier": {
            "type": "string",
            "enum": [
              "local",
              "regional"
            ]
          }
        },
        "required": [
          "fmisid",
          "name",
          "distanceKm",
          "tier"
        ]
      },
      "KuntaProvenance": {
        "type": "object",
        "nullable": true,
        "properties": {
          "stations": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StationRef"
            }
          },
          "stationCount": {
            "type": "integer"
          }
        },
        "required": [
          "stations",
          "stationCount"
        ]
      },
      "StationRef": {
        "type": "object",
        "properties": {
          "fmisid": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          }
        },
        "required": [
          "fmisid",
          "name"
        ]
      },
      "AirQualityGap": {
        "type": "object",
        "nullable": true,
        "description": "Why this pollutant has no reading. Present exactly when `data` is null — and it means no station is close enough, never that the air is clean.",
        "properties": {
          "reason": {
            "type": "string",
            "enum": [
              "TOO_FAR",
              "NO_STATION"
            ],
            "description": "`TOO_FAR`: a station exists but is beyond the threshold. `NO_STATION`: none measures this pollutant at all."
          },
          "nearestStation": {
            "type": "string",
            "nullable": true,
            "description": "Null when `reason` is NO_STATION."
          },
          "distanceKm": {
            "type": "number",
            "format": "double",
            "nullable": true
          },
          "maxKm": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "The threshold applied: 15 km for NO2 and PM10, 30 km otherwise."
          }
        },
        "required": [
          "reason",
          "nearestStation",
          "distanceKm",
          "maxKm"
        ]
      },
      "AirQualityRanking": {
        "type": "object",
        "properties": {
          "pollutant": {
            "type": "string"
          },
          "year": {
            "type": "integer"
          },
          "entries": {
            "type": "array",
            "description": "Ascending — cleanest air first, so rank 1 is the lowest concentration.",
            "items": {
              "$ref": "#/components/schemas/AirQualityRankingEntry"
            }
          }
        },
        "required": [
          "pollutant",
          "year",
          "entries"
        ]
      },
      "AirQualityRankingEntry": {
        "type": "object",
        "properties": {
          "kuntaCode": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "value": {
            "type": "number",
            "format": "double"
          },
          "rank": {
            "type": "integer"
          }
        },
        "required": [
          "kuntaCode",
          "name",
          "value",
          "rank"
        ]
      },
      "Ranking": {
        "type": "object",
        "properties": {
          "variableCode": {
            "type": "string"
          },
          "year": {
            "type": "integer"
          },
          "order": {
            "type": "string",
            "enum": [
              "top",
              "bottom",
              "balance"
            ]
          },
          "entries": {
            "type": "array",
            "description": "**At most 10 rows without Pro**, whatever `limit` asked for.",
            "items": {
              "$ref": "#/components/schemas/RankingEntry"
            }
          },
          "origin": {
            "allOf": [
              {
                "$ref": "#/components/schemas/RankingOrigin"
              }
            ],
            "description": "Absent — the key is missing, not null — unless `near` was supplied."
          },
          "withinKm": {
            "type": "number",
            "format": "double",
            "description": "Absent unless `near` was supplied."
          }
        },
        "required": [
          "variableCode",
          "year",
          "order",
          "entries"
        ]
      },
      "RankingEntry": {
        "type": "object",
        "properties": {
          "rank": {
            "type": "integer",
            "description": "1-based, within the scope requested."
          },
          "postalCode": {
            "type": "string",
            "description": "A 5-digit postal code — **except for the four `crime_*_rate` metrics, where it is a 3-digit municipality code**, because crime is ranked by municipality."
          },
          "name": {
            "type": "string",
            "description": "Area name, or municipality name for crime metrics."
          },
          "value": {
            "type": "number",
            "format": "double"
          },
          "percentile": {
            "type": "number",
            "format": "double",
            "nullable": true,
            "description": "Mid-rank position within the *scoped* distribution, so a scoped ranking's percentiles are not national ones."
          },
          "distanceKm": {
            "type": "number",
            "format": "double",
            "description": "Absent unless a radius was requested. Straight-line, rounded to 0.1 km."
          }
        },
        "required": [
          "rank",
          "postalCode",
          "name",
          "value",
          "percentile"
        ]
      },
      "RankingOrigin": {
        "type": "object",
        "description": "The place a radius was measured from.",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "postal_area",
              "municipality"
            ]
          }
        },
        "required": [
          "code",
          "name",
          "kind"
        ]
      },
      "AreaComparison": {
        "type": "object",
        "properties": {
          "year": {
            "type": "integer"
          },
          "groups": {
            "type": "array",
            "description": "Metric groups and their members, in display order.",
            "items": {
              "$ref": "#/components/schemas/ComparisonGroup"
            }
          },
          "nationalMedians": {
            "allOf": [
              {
                "$ref": "#/components/schemas/NullableNumberMap"
              }
            ],
            "description": "As on the stats endpoint, a weighted mean for average-type metrics. Check `nationalBenchmarkBasis`."
          },
          "nationalBenchmarkBasis": {
            "$ref": "#/components/schemas/StringMap"
          },
          "nationalBenchmarkCoverage": {
            "$ref": "#/components/schemas/NullableNumberMap"
          },
          "asOf": {
            "type": "object",
            "description": "Present only for metrics whose latest published year lags the requested one — so a single response can mix vintages. Label those rows rather than presenting one year.",
            "additionalProperties": {
              "type": "integer"
            }
          },
          "areas": {
            "type": "array",
            "description": "One column per area. **Truncated to 2 without Pro** — compare its length against how many codes you sent.",
            "items": {
              "$ref": "#/components/schemas/AreaColumn"
            }
          }
        },
        "required": [
          "year",
          "groups",
          "nationalMedians",
          "nationalBenchmarkBasis",
          "nationalBenchmarkCoverage",
          "asOf",
          "areas"
        ]
      },
      "ComparisonGroup": {
        "type": "object",
        "properties": {
          "groupName": {
            "type": "string"
          },
          "variables": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VariableRef"
            }
          }
        },
        "required": [
          "groupName",
          "variables"
        ]
      },
      "VariableRef": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          }
        },
        "required": [
          "code",
          "name"
        ]
      },
      "AreaColumn": {
        "type": "object",
        "properties": {
          "postalCode": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "values": {
            "$ref": "#/components/schemas/NullableNumberMap"
          },
          "percentiles": {
            "$ref": "#/components/schemas/NullableNumberMap"
          }
        },
        "required": [
          "postalCode",
          "name",
          "values",
          "percentiles"
        ]
      },
      "VariableMetadata": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "description": "Pass this as `variableCode` elsewhere."
          },
          "name": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "description": "`composite` marks the four Pro-only indices; `crime` marks the four municipality-level rates."
          },
          "unit": {
            "type": "string",
            "nullable": true
          }
        },
        "required": [
          "code",
          "name",
          "type",
          "unit"
        ]
      },
      "VariableValues": {
        "type": "object",
        "properties": {
          "variableCode": {
            "type": "string"
          },
          "year": {
            "type": "integer"
          },
          "values": {
            "allOf": [
              {
                "$ref": "#/components/schemas/NullableNumberMap"
              }
            ],
            "description": "Postal code to value, about 3,000 entries. Nulls are suppression, not withholding."
          }
        },
        "required": [
          "variableCode",
          "year",
          "values"
        ]
      },
      "VariableYears": {
        "type": "object",
        "properties": {
          "variableCode": {
            "type": "string"
          },
          "years": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Newest first."
          }
        },
        "required": [
          "variableCode",
          "years"
        ]
      },
      "LicenseInfo": {
        "type": "object",
        "properties": {
          "license": {
            "type": "string",
            "example": "CC BY 4.0"
          },
          "licenseUrl": {
            "type": "string"
          },
          "attribution": {
            "type": "string",
            "description": "The attribution text to reproduce when reusing figures."
          },
          "sources": {
            "type": "array",
            "description": "The four upstream datasets. Credit the publisher named here, not Suomiatlas.",
            "items": {
              "$ref": "#/components/schemas/DataSource"
            }
          }
        },
        "required": [
          "license",
          "licenseUrl",
          "attribution",
          "sources"
        ]
      },
      "DataSource": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "publisher": {
            "type": "string"
          },
          "url": {
            "type": "string"
          }
        },
        "required": [
          "name",
          "publisher",
          "url"
        ]
      },
      "PlanPrice": {
        "type": "object",
        "properties": {
          "interval": {
            "type": "string",
            "enum": [
              "MONTHLY",
              "ANNUAL",
              "ONE_TIME"
            ]
          },
          "priceCents": {
            "type": "integer"
          },
          "currency": {
            "type": "string",
            "example": "EUR"
          }
        },
        "required": [
          "interval",
          "priceCents",
          "currency"
        ]
      }
    }
  }
}
