{
  "openapi": "3.1.0",
  "info": {
    "title": "haio public API",
    "version": "1.0.0",
    "summary": "Free, read-only Singapore property reference data from haio.sg.",
    "description": "The endpoints haio documents for third parties: Singapore MRT stations, schools, URA planning areas, the HDB concessionary loan rate, project search, and monthly price and volume aggregates by segment. No authentication. Everything here is what a signed-out visitor to haio.sg already sees; signed-in and haio+ data is not part of this API.\n\nRate limit: 300 requests per 60 seconds per client IP, enforced at the Cloudflare edge. Above it the edge answers HTTP 429 with the JSON error `rate_limited` for 60 seconds, so back off for 60 seconds on a 429. Responses carry `RateLimit-Policy` (draft-ietf-httpapi-ratelimit-headers); no remaining-quota header is sent because the origin cannot see the edge counter.\n\nErrors: `{\"error\": {\"code\", \"message\", \"hint\"}}` with a stable snake_case `code`. Unknown /api/ paths answer 404 `not_found`; other methods than GET answer 405 `method_not_allowed`.",
    "contact": {
      "name": "haio",
      "email": "contact@haio.sg",
      "url": "https://haio.sg/contact/"
    },
    "termsOfService": "https://haio.sg/tos/"
  },
  "servers": [
    {
      "url": "https://haio.sg"
    }
  ],
  "security": [],
  "externalDocs": {
    "description": "Human-readable docs",
    "url": "https://haio.sg/docs/"
  },
  "tags": [
    {
      "name": "rates",
      "description": "Published loan rates"
    },
    {
      "name": "search",
      "description": "Find projects by name"
    },
    {
      "name": "map",
      "description": "Map reference layers"
    },
    {
      "name": "trends",
      "description": "Price and volume aggregates over time"
    }
  ],
  "paths": {
    "/api/hdb-loan-rate": {
      "get": {
        "operationId": "getHdbLoanRate",
        "tags": [
          "rates"
        ],
        "summary": "HDB concessionary loan rate in force",
        "description": "The HDB concessionary housing loan interest rate for the current quarter, as posted by HDB (the CPF Ordinary Account rate + 0.10%). `ratePct` and `period` are null when no current rate is published.",
        "responses": {
          "200": {
            "description": "The current rate.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ratePct": {
                      "type": [
                        "number",
                        "null"
                      ],
                      "description": "Annual rate in percent, e.g. 2.6."
                    },
                    "period": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The quarter the rate applies to, as HDB words it."
                    },
                    "sourceUrl": {
                      "type": "string",
                      "format": "uri",
                      "description": "HDB page the rate was read from."
                    }
                  },
                  "required": [
                    "ratePct",
                    "period",
                    "sourceUrl"
                  ]
                },
                "example": {
                  "ratePct": 2.6,
                  "period": "1 Oct–31 Dec 2026",
                  "sourceUrl": "https://www.hdb.gov.sg/managing-my-home/finances/loan-matters/interest-rate"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/EdgeChallenge"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Error",
            "description": "Unexpected server error (`internal_error`)."
          }
        }
      }
    },
    "/api/search/projects": {
      "get": {
        "operationId": "searchProjects",
        "tags": [
          "search"
        ],
        "summary": "Search condo and landed projects by name",
        "description": "Fuzzy name search over Singapore condo and landed projects, up to 12 results. Prices are asking prices of current listings (`priceBasis: asking`) or haio's median transacted price (`priceBasis: median`). With `q` absent, empty or shorter than 2 characters the endpoint returns a few featured projects instead of matches.",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Project name or part of it, e.g. `marina`. Optional: absent or under 2 characters returns featured projects.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matching projects, best match first.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "results": {
                      "type": "array",
                      "maxItems": 12,
                      "items": {
                        "$ref": "#/components/schemas/ProjectSearchResult"
                      }
                    }
                  },
                  "required": [
                    "results"
                  ]
                },
                "example": {
                  "results": [
                    {
                      "type": "condo",
                      "id": "fa42a6cb-bd77-4f08-a700-85a84aab2fd3",
                      "name": "Marina Collection",
                      "slug": "marina-collection",
                      "district": "D04 - Harbourfront / Telok Blangah",
                      "tenure": "99-year Leasehold",
                      "price": 6700000,
                      "priceBasis": "asking",
                      "psf": 1768,
                      "segmentRegion": "CCR",
                      "planningArea": "Harbourfront / Telok Blangah",
                      "href": "/condo/marina-collection"
                    }
                  ]
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/EdgeChallenge"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Error",
            "description": "Unexpected server error (`internal_error`)."
          },
          "503": {
            "description": "The search failed (`search_unavailable`).",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/map/stations": {
      "get": {
        "operationId": "listMrtStations",
        "tags": [
          "map"
        ],
        "summary": "MRT and LRT stations, existing and future",
        "description": "Every Singapore MRT/LRT station with coordinates and line codes, including announced future stations (`future: true`, with `openingYear` where known). `degraded: true` means the station table could not be read and the list is a fallback.",
        "responses": {
          "200": {
            "description": "All stations.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "stations": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Station"
                      }
                    },
                    "degraded": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "stations",
                    "degraded"
                  ]
                },
                "example": {
                  "stations": [
                    {
                      "id": "mrt-ADMIRALTY-1.44041,103.80086",
                      "name": "Admiralty",
                      "lat": 1.440407,
                      "lng": 103.800857,
                      "future": false,
                      "openingYear": null,
                      "phase": null,
                      "lines": [
                        "NS"
                      ]
                    }
                  ],
                  "degraded": false
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/EdgeChallenge"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Error",
            "description": "Unexpected server error (`internal_error`)."
          }
        }
      }
    },
    "/api/map/schools": {
      "get": {
        "operationId": "listSchools",
        "tags": [
          "map"
        ],
        "summary": "Schools with coordinates",
        "description": "Singapore primary, secondary, tertiary and international schools with coordinates and flags (GEP, SAP). Pass all four bounding-box parameters to limit the result to a map viewport. If any of the four is absent or not a number, the box is ignored and the whole island is returned (not a 400).",
        "parameters": [
          {
            "name": "minLng",
            "in": "query",
            "required": false,
            "description": "Bounding box west edge (longitude), e.g. 103.8. Use together with the other three.",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "minLat",
            "in": "query",
            "required": false,
            "description": "Bounding box south edge (latitude), e.g. 1.28.",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "maxLng",
            "in": "query",
            "required": false,
            "description": "Bounding box east edge (longitude), e.g. 103.9.",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "maxLat",
            "in": "query",
            "required": false,
            "description": "Bounding box north edge (latitude), e.g. 1.32.",
            "schema": {
              "type": "number"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Schools, plus counts by level.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "schools": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/School"
                      }
                    },
                    "counts": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "primary": {
                          "type": "integer"
                        },
                        "secondary": {
                          "type": "integer"
                        },
                        "other": {
                          "type": "integer"
                        }
                      },
                      "required": [
                        "total",
                        "primary",
                        "secondary",
                        "other"
                      ]
                    }
                  },
                  "required": [
                    "schools",
                    "counts"
                  ]
                },
                "example": {
                  "schools": [
                    {
                      "id": "970",
                      "name": "Anglo-Chinese School (International)",
                      "lat": 1.3077471,
                      "lng": 103.7985415,
                      "type": "international",
                      "isGep": false,
                      "isSap": false,
                      "rankingTier": null,
                      "affiliatedSecondary": null
                    }
                  ],
                  "counts": {
                    "total": 528,
                    "primary": 182,
                    "secondary": 261,
                    "other": 85
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/EdgeChallenge"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Error",
            "description": "Unexpected server error (`internal_error`)."
          },
          "503": {
            "description": "The school table could not be read (`database_unavailable`).",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/map/planning-areas": {
      "get": {
        "operationId": "listPlanningAreas",
        "tags": [
          "map"
        ],
        "summary": "URA planning-area boundaries (GeoJSON)",
        "description": "The 55 URA Master Plan 2025 planning areas as a GeoJSON FeatureCollection of MultiPolygons (simplified to about 11 m), with source and licence metadata (Singapore Open Data Licence). Planning areas are not postal districts.",
        "responses": {
          "200": {
            "description": "GeoJSON FeatureCollection.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "type": {
                      "const": "FeatureCollection"
                    },
                    "meta": {
                      "type": "object",
                      "description": "Source, licence, plan year and simplification notes.",
                      "additionalProperties": true
                    },
                    "features": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlanningAreaFeature"
                      }
                    }
                  },
                  "required": [
                    "type",
                    "meta",
                    "features"
                  ]
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/EdgeChallenge"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Error",
            "description": "Unexpected server error (`internal_error`)."
          },
          "503": {
            "description": "The planning-area table could not be read (`planning_areas_unavailable`).",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/trendmaker/series": {
      "get": {
        "operationId": "getPriceSeries",
        "tags": [
          "trends"
        ],
        "summary": "Price and volume over time for a market segment",
        "description": "Median and average price per square foot, median and average price, and transaction count per month, quarter or year, for one segment: a bedroom count, a postal district (1-28), a market region (CCR, RCR, OCR), or one project by slug. Market segments count resale only; a project counts every sale type (`saleBasis`). Buckets with no sales are absent.",
        "parameters": [
          {
            "name": "dim",
            "in": "query",
            "required": true,
            "description": "Which kind of segment `key` names.",
            "schema": {
              "type": "string",
              "enum": [
                "bedroom",
                "district",
                "region",
                "project"
              ]
            }
          },
          {
            "name": "key",
            "in": "query",
            "required": true,
            "description": "The segment: 1-5 for bedroom, 1-28 for district, CCR/RCR/OCR for region, a project slug (from searchTrendProjects) for project.",
            "schema": {
              "type": "string",
              "minLength": 1
            }
          },
          {
            "name": "tenure",
            "in": "query",
            "required": false,
            "description": "Property type. Default condo.",
            "schema": {
              "type": "string",
              "enum": [
                "condo",
                "landed",
                "hdb"
              ],
              "default": "condo"
            }
          },
          {
            "name": "bucket",
            "in": "query",
            "required": false,
            "description": "Time bucket. Default month.",
            "schema": {
              "type": "string",
              "enum": [
                "month",
                "quarter",
                "year"
              ],
              "default": "month"
            }
          },
          {
            "name": "bedroom",
            "in": "query",
            "required": false,
            "description": "Optional exact bedroom filter on top of any segment (condo and hdb only). Anything but 1-5 is a 400.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 5
            }
          },
          {
            "name": "months",
            "in": "query",
            "required": false,
            "description": "How many months back. Default 240. Anything but an integer from 1 to 480 is a 400.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 480,
              "default": 240
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The series.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PriceSeries"
                },
                "example": {
                  "dim": "district",
                  "key": "9",
                  "tenure": "condo",
                  "bucket": "month",
                  "bedroom": null,
                  "label": "District 9",
                  "saleBasis": "resale",
                  "points": [
                    {
                      "month": "2026-04",
                      "medianPsf": 2425,
                      "avgPsf": 2491,
                      "medianPrice": 2680000,
                      "avgPrice": 3464174,
                      "volume": 45
                    }
                  ],
                  "source": "segment_monthly_price_agg"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error",
            "description": "A parameter is invalid (`invalid_parameter`), or no project has that slug (`unknown_project`)."
          },
          "403": {
            "$ref": "#/components/responses/EdgeChallenge"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Error",
            "description": "Unexpected server error (`internal_error`)."
          },
          "502": {
            "$ref": "#/components/responses/Error",
            "description": "The aggregate query failed (`upstream_failed`)."
          }
        }
      }
    },
    "/api/trendmaker/projects": {
      "get": {
        "operationId": "searchTrendProjects",
        "tags": [
          "trends",
          "search"
        ],
        "summary": "Find a project slug for the price series",
        "description": "Type-ahead over condo and HDB projects, up to 8 results, returning the `slug` getPriceSeries takes as `key` with `dim=project`. A `q` that is absent or shorter than 2 characters returns 200 with empty `results` (not a 400).",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Project or block name; 2+ characters to get matches.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "tenure",
            "in": "query",
            "required": false,
            "description": "Limit to one property type.",
            "schema": {
              "type": "string",
              "enum": [
                "condo",
                "hdb"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matching projects.",
            "headers": {
              "RateLimit-Policy": {
                "$ref": "#/components/headers/RateLimit-Policy"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "results": {
                      "type": "array",
                      "maxItems": 8,
                      "items": {
                        "type": "object",
                        "properties": {
                          "slug": {
                            "type": "string"
                          },
                          "label": {
                            "type": "string",
                            "description": "Display name."
                          },
                          "district": {
                            "type": [
                              "integer",
                              "null"
                            ],
                            "description": "Postal district 1-28."
                          },
                          "tenure": {
                            "type": "string",
                            "enum": [
                              "condo",
                              "hdb"
                            ]
                          }
                        },
                        "required": [
                          "slug",
                          "label",
                          "district",
                          "tenure"
                        ]
                      }
                    },
                    "error": {
                      "const": false,
                      "description": "Always false on a 200; failures are 4xx/5xx."
                    }
                  },
                  "required": [
                    "results",
                    "error"
                  ]
                },
                "example": {
                  "results": [
                    {
                      "slug": "marina-one-residences",
                      "label": "MARINA ONE RESIDENCES",
                      "district": 1,
                      "tenure": "condo"
                    }
                  ],
                  "error": false
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error",
            "description": "`tenure` is not condo or hdb (`invalid_parameter`)."
          },
          "403": {
            "$ref": "#/components/responses/EdgeChallenge"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/Error",
            "description": "Unexpected server error (`internal_error`)."
          },
          "503": {
            "description": "The lookup failed (`search_unavailable`).",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "headers": {
      "RateLimit-Policy": {
        "description": "The edge rate-limit policy (draft-ietf-httpapi-ratelimit-headers): \"api\";q=300;w=60 means 300 requests per 60-second window per client IP.",
        "schema": {
          "type": "string",
          "example": "\"api\";q=300;w=60"
        }
      }
    },
    "responses": {
      "Error": {
        "description": "An error, as JSON.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "Only GET, HEAD and OPTIONS are supported.",
        "headers": {
          "Allow": {
            "schema": {
              "type": "string",
              "example": "GET, HEAD, OPTIONS"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "method_not_allowed",
                "message": "POST is not supported here; use GET, HEAD, OPTIONS.",
                "hint": "Public API docs: https://haio.sg/docs/ (OpenAPI: https://haio.sg/openapi.json)."
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "The client exceeded 300 requests per 60 seconds; Cloudflare answers this JSON for the next 60 seconds. Wait 60 seconds before retrying.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "rate_limited",
                "message": "Too many requests: the limit is 300 per 60 seconds per client IP. Wait 60 seconds, then retry.",
                "hint": "Public API docs: https://haio.sg/docs/ (OpenAPI: https://haio.sg/openapi.json)."
              }
            }
          }
        }
      },
      "EdgeChallenge": {
        "description": "Cloudflare challenged the request (HTML, not JSON) because it classed the client as automated and could not verify it. Not the rate limit (that is a 429).",
        "content": {
          "text/html": {
            "schema": {
              "type": "string"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "pattern": "^[a-z_]+$",
                "description": "Stable machine-readable code, e.g. not_found, invalid_parameter."
              },
              "message": {
                "type": "string",
                "description": "What went wrong, for humans."
              },
              "hint": {
                "type": "string",
                "description": "Where to look next."
              }
            },
            "required": [
              "code",
              "message",
              "hint"
            ]
          }
        },
        "required": [
          "error"
        ]
      },
      "ProjectSearchResult": {
        "type": "object",
        "required": [
          "type",
          "id",
          "name",
          "slug",
          "href"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "condo",
              "landed"
            ]
          },
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "district": {
            "type": [
              "string",
              "null"
            ],
            "description": "Postal district with its name, e.g. `D04 - Harbourfront / Telok Blangah`."
          },
          "tenure": {
            "type": [
              "string",
              "null"
            ]
          },
          "price": {
            "type": [
              "number",
              "null"
            ],
            "description": "SGD; see priceBasis."
          },
          "priceBasis": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "asking",
              "median",
              null
            ]
          },
          "psf": {
            "type": [
              "number",
              "null"
            ],
            "description": "SGD per square foot, same basis as price."
          },
          "beds": {
            "type": [
              "integer",
              "null"
            ]
          },
          "baths": {
            "type": [
              "integer",
              "null"
            ]
          },
          "sqft": {
            "type": [
              "number",
              "null"
            ]
          },
          "subtype": {
            "type": [
              "string",
              "null"
            ]
          },
          "image_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "launch_date": {
            "type": [
              "string",
              "null"
            ],
            "description": "YYYY-MM-DD."
          },
          "preview_date": {
            "type": [
              "string",
              "null"
            ]
          },
          "soldUnits": {
            "type": [
              "integer",
              "null"
            ]
          },
          "totalUnits": {
            "type": [
              "integer",
              "null"
            ]
          },
          "segmentRegion": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "CCR",
              "RCR",
              "OCR",
              null
            ]
          },
          "segmentSource": {
            "type": [
              "string",
              "null"
            ]
          },
          "planningArea": {
            "type": [
              "string",
              "null"
            ]
          },
          "href": {
            "type": "string",
            "description": "Path of the project's haio page, relative to https://haio.sg."
          }
        }
      },
      "Station": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "lat": {
            "type": "number"
          },
          "lng": {
            "type": "number"
          },
          "future": {
            "type": "boolean",
            "description": "Announced but not yet open."
          },
          "openingYear": {
            "type": [
              "integer",
              "null"
            ]
          },
          "phase": {
            "type": [
              "string",
              "null"
            ]
          },
          "lines": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Line codes, e.g. NS, EW, CR."
          }
        },
        "required": [
          "id",
          "name",
          "lat",
          "lng",
          "future",
          "openingYear",
          "phase",
          "lines"
        ]
      },
      "School": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "lat": {
            "type": "number"
          },
          "lng": {
            "type": "number"
          },
          "type": {
            "type": "string",
            "enum": [
              "primary",
              "secondary",
              "tertiary",
              "international"
            ]
          },
          "isGep": {
            "type": "boolean",
            "description": "Offers the Gifted Education Programme."
          },
          "isSap": {
            "type": "boolean",
            "description": "Special Assistance Plan school."
          },
          "rankingTier": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 4
          },
          "affiliatedSecondary": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "id",
          "name",
          "lat",
          "lng",
          "type",
          "isGep",
          "isSap",
          "rankingTier",
          "affiliatedSecondary"
        ]
      },
      "PlanningAreaFeature": {
        "type": "object",
        "properties": {
          "type": {
            "const": "Feature"
          },
          "properties": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string"
              },
              "code": {
                "type": "string"
              },
              "anchor": {
                "type": "array",
                "items": {
                  "type": "number"
                },
                "minItems": 2,
                "maxItems": 2,
                "description": "[lng, lat] label point."
              }
            },
            "required": [
              "name",
              "code",
              "anchor"
            ]
          },
          "geometry": {
            "type": "object",
            "description": "GeoJSON MultiPolygon.",
            "additionalProperties": true
          }
        },
        "required": [
          "type",
          "properties",
          "geometry"
        ]
      },
      "PriceSeries": {
        "type": "object",
        "required": [
          "dim",
          "key",
          "tenure",
          "bucket",
          "bedroom",
          "label",
          "saleBasis",
          "points"
        ],
        "properties": {
          "dim": {
            "type": "string",
            "enum": [
              "bedroom",
              "district",
              "region",
              "project"
            ]
          },
          "key": {
            "type": "string"
          },
          "tenure": {
            "type": "string",
            "enum": [
              "condo",
              "landed",
              "hdb"
            ]
          },
          "bucket": {
            "type": "string",
            "enum": [
              "month",
              "quarter",
              "year"
            ]
          },
          "bedroom": {
            "type": [
              "integer",
              "null"
            ]
          },
          "label": {
            "type": "string",
            "description": "Human label, e.g. District 9, or the project's name."
          },
          "saleBasis": {
            "type": "string",
            "enum": [
              "resale",
              "all"
            ]
          },
          "points": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "month": {
                  "type": "string",
                  "description": "Bucket label: YYYY-MM, YYYY-Qn or YYYY."
                },
                "medianPsf": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "avgPsf": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "medianPrice": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "avgPrice": {
                  "type": [
                    "number",
                    "null"
                  ]
                },
                "volume": {
                  "type": "integer",
                  "description": "Transactions in the bucket."
                }
              },
              "required": [
                "month",
                "medianPsf",
                "avgPsf",
                "medianPrice",
                "avgPrice",
                "volume"
              ]
            }
          },
          "source": {
            "type": "string",
            "description": "Which aggregate produced the points."
          },
          "notice": {
            "type": [
              "string",
              "null"
            ],
            "description": "A caveat about this series, when there is one."
          },
          "pendingMigration": {
            "type": "boolean"
          }
        }
      }
    }
  }
}
