# havatahmin API — full reference

> 7-day temperature forecast (tmax/tmin/tmean, calibrated 90% interval) for Turkey: 81 of 81 provinces (76 with their own station model, 5 by transfer from the nearest station: bitlis, kahramanmaras, karabuk, sirnak, sivas, which have no /v1/verification file) and 911 of 973 districts answer in the latest run. Updated up to twice a day, no fixed schedule. Static JSON, no server. Latest run: 2026-10-10T12, model v4_20261010_1050_v4b. License: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), credit havatahmin.abso.net; place names GeoNames (CC BY 4.0).

Base URL: https://havatahmin.abso.net . All text is UTF-8, CORS is open. Machine-readable: https://havatahmin.abso.net/openapi.json (OpenAPI 3.1 with real examples), https://havatahmin.abso.net/index.json. Human documentation (Turkish): https://havatahmin.abso.net/api/ .

## Endpoints
| Path | Auth | Returns |
|---|---|---|
| /v1/status.json | none | latest run, model version, freshness (stale, age_hours, next_run_expected, missing) |
| /index.json | none | endpoint list, coverage, errors, limits, license and province slugs in one document |
| /v1/places.json | none | every province + district: name, slug, path, lat, lon, elev_m (~112 KB) |
| /v1/places.min.json | none | compact place index: [path, name, lat, lon] rows for provinces and districts (~45 KB) |
| /v1/forecast/{il}.json | none | 7-day forecast for a province (point forecast at its main station), plus each anchoring station's own days |
| /v1/forecast/{il}/{ilce}.json | none | 7-day forecast for a district |
| /v1/hourly/{il}.json | none | hourly temperature curve for a province: 24 local hours for each of the 7 days (t = corrected, t_raw = ECMWF HRES) |
| /v1/hourly/{il}/{ilce}.json | none | hourly temperature curve for a district |
| /v1/aq/{il}.json | none | air quality: daily mean PM2.5, PM10, NO2, O3, SO2, CO (µg/m³) for D+1..D+3 at the province's monitoring stations |
| /v1/aq/stations.json | none | every air-quality station with its coordinates, district and the province file that holds its forecast |
| /v1/districts/{il}.json | none | districts of a province, each with slug, path, display_name and has_forecast |
| /v1/search/{prefix}.json | none | name search shard: provinces, districts and ~54,000 villages/neighbourhoods whose folded name starts with {prefix} |
| /v1/search/index.json | none | search rules, lapse rates and the list of existing shard prefixes |
| /v1/alerts/{il}.json | api key | frost / heat / sudden-change flags for the week at the province's main station |
| /v1/alerts/{il}/{ilce}.json | api key | same flags for a district |
| /v1/verification/{il}.json | api key | MAE vs raw HRES per lead over the last 30 days, against the station thermometer; 404 until a run is verified or for a province without a station |
| /v1/verification/all.json | api key | same, across all served places |

/latest.json is an alias of /v1/status.json. Every entry in /v1/places.json and /v1/districts/{il}.json has a ready-made `path` ("mugla" or "mugla/bodrum"): GET /v1/forecast/{path}.json. Example: Bodrum is /v1/forecast/mugla/bodrum.json.

## Time rule
All dates are Istanbul (UTC+3, no DST). `issued` and `valid` are calendar dates; to answer for a date D pick the day whose `valid` equals D; `lead` = `valid` - `issued` in days (lead 1 = the day after `issued`). `produced_at`, `status.time` and `next_run_expected` carry +03:00; `generated_at` (places.json, index.json) is UTC with offset. `cycle` (00Z/12Z) is the UTC hour of the ECMWF run.

## Freshness rule
`stale` = today_istanbul > `issued` + 1 day, evaluated when the file was written; the file is static, so compare now with `stale_after` (issued + 2 days, 00:00 Istanbul): at or past it, treat the run as stale even if `stale` reads false. `produced_at` is when the calculator computed the run (data time, +03:00), usually hours after the ECMWF cycle named by `issued` + `cycle`; `age_hours` = hours since `produced_at` at export; `status.time` is when the files were rendered. There is no fixed schedule: a run is published when the operator's machine runs the update, usually twice a day (the 00Z run between ~10:30 and 11:00, the 12Z run between ~20:30 and 23:00 Istanbul). `next_run_expected` is the usual earliest time of the following run, an expectation, not a promise: in the past means not published yet, so rely on `stale` and `age_hours`. `hres_latest_run` is the newest ECMWF HRES input per cycle (YYYYMMDD), not a forecast. `missing.provinces`/`missing.districts` list the slugs that 404 in this run. `provinces_with_forecast` counts provinces with their own station model; a province without one is served by transfer from the nearest station (`method` "transfer") and still answers at /v1/forecast/{il}.json, but is counted in `districts_with_forecast`. Use `missing` for what really 404s; index.json `coverage` has the counts as served.

Current status (GET /v1/status.json; `missing.districts` shortened here):
```json
{
 "time": "2026-10-10T22:54:05+03:00",
 "model_version": "v4_20261010_1050_v4b",
 "latest_run": "2026-10-10T12",
 "produced_at": "2026-10-10T22:54:01+03:00",
 "provinces_served": 81,
 "provinces_with_forecast": 76,
 "districts_total": 973,
 "districts_with_forecast": 916,
 "stations": 99,
 "hres_latest_run": {
  "00": "20261010",
  "12": "20261010"
 },
 "synop_latest_day": "2026-10-10",
 "age_hours": 0.0,
 "stale": false,
 "stale_after": "2026-10-12T00:00:00+03:00",
 "next_run_expected": "2026-10-11T10:30:00+03:00",
 "missing": {
  "provinces": [],
  "districts": [
   "adana/feke",
   "adana/saimbeyli",
   "adana/tufanbeyli",
   "antalya/akseki",
   "antalya/alanya",
   "... 62 in total"
  ]
 },
 "products": {
  "raw": true,
  "precip": "precip_v1_20261010",
  "hourly": true,
  "aq": "aq_v1_20261010"
 }
}
```

## Resolve any place name: province, district, village, neighbourhood or coordinates
One recipe for every kind of name (the search shards contain provinces and districts too, with offset 0):
1. Fold the name: lowercase; ç→c, ğ→g, ı→i, İ→i, ö→o, ş→s, ü→u; strip every other diacritic by NFKD decomposition (â→a, î→i, û→u: "Hakkâri" → "hakkari"); keep only a-z and 0-9 for the shard key, so "Yaşamkent" → "yasamkent", "Yeni Mahalle" → "yenimahalle".
2. Take the first 3 characters of that key and GET /v1/search/{abc}.json (e.g. /v1/search/yas.json). A name whose key has only 2 characters uses a 2-character shard (e.g. /v1/search/of.json for "Of"). Only prefixes that occur exist: /v1/search/index.json lists them in `prefixes`; a 404 means no place name starts with those characters.
3. Pick the entry whose `slug` equals your folded name with spaces and punctuation as single hyphens ("yeni-mahalle"); compare against `slug`, not `name` (`name` keeps Turkish letters). Entries are ordered provinces, then districts, then settlements by GeoNames rank and population; several places can share a slug, so use `district`/`province` (or the first entry when you have no context). `kind` tells what you matched.
4. GET /v1/forecast/{path}.json (the entry's district, or the province itself for a province entry) and add `offset.tmax`, `offset.tmin`, `offset.tmean` to `value`, `p05` and `p95` of every day. For provinces and districts the offset is 0 and `path` is their own slug. `has_forecast` false means that district has no forecast in this run (also in /v1/status.json `missing`).
Coordinates: there is no coordinate endpoint. Nearest place = the district row of /v1/places.min.json (`[path, name, lat, lon]`; district rows are the ones whose `path` contains "/") with the smallest haversine distance; then step 4 with offset 0. Skip province rows: a city-centre point would otherwise snap to the province station instead of its central district. This is the nearest district centre, not the containing polygon, so near a district border or on a mountain it can pick the neighbour (tested 2026-10-03: differences up to 2-3 C; over Lake Van up to 5 C).

**Example 1, offset 0: Yaşamkent (Çankaya, Ankara)** → key "yasamkent", shard /v1/search/yas.json, entry:
```json
{
 "name": "Yaşamkent",
 "kind": "neighbourhood",
 "district": "Çankaya",
 "province": "Ankara",
 "path": "ankara/cankaya",
 "slug": "yasamkent",
 "lat": 39.86139,
 "lon": 32.65889,
 "elev_m": 1100.0,
 "elev_diff_m": 1,
 "offset": {
  "tmax": 0.0,
  "tmin": 0.0,
  "tmean": 0.0
 },
 "has_forecast": true
}
```
→ GET /v1/forecast/ankara/cankaya.json, use the values as they are.

**Example 2, non-zero offset: Yaşlıkavak (Çatak, Van)**, `elev_diff_m` 1171 m above its district: GET /v1/forecast/van/catak.json; on 2026-10-11 the district's tmax is 17.1 (p05 15.3, p95 18.7); add `offset.tmax` -7.6 → 9.5 (7.7..11.1); tmin gets -5.3, tmean -7.0. Alert thresholds apply to the shifted values.
```json
{
 "name": "Yaşlıkavak",
 "kind": "settlement",
 "district": "Çatak",
 "province": "Van",
 "path": "van/catak",
 "slug": "yaslikavak",
 "lat": 37.96667,
 "lon": 42.86667,
 "elev_m": 2678.0,
 "elev_diff_m": 1171,
 "offset": {
  "tmax": -7.6,
  "tmin": -5.3,
  "tmean": -7.0
 },
 "has_forecast": true
}
```

`elev_diff_m` is the place's elevation minus the district's (clipped to ±1500 m); `offset` = -lapse × elev_diff_m / 100 with lapse per 100 m {'tmax': 0.65, 'tmin': 0.45, 'tmean': 0.6}, the same rule as the calculator's own search (results may differ from it by 0.1 °C through rounding). Place names: GeoNames (CC BY 4.0).

## Province vs district
A province forecast is the point forecast at the province's main station (`verified_against.station`); provinces without a station model are served by transfer from the nearest station (`method` "transfer"). For a province-wide answer (e.g. "is there frost risk anywhere in Erzurum"), query every district listed in /v1/districts/{il}.json. A 404 means no forecast in the latest run, too far from every station, or a misspelled slug: check /v1/status.json `missing` and /v1/districts/{il}.json `has_forecast`.

## Rate limit and errors
Rate limit: **300 requests per client IP per 5 minutes**, with or without a key. Above that every request gets a JSON 403 `{"error": "rate limit exceeded: more than 300 requests from this IP in 5 minutes (or a method other than GET/HEAD/OPTIONS: the API is read-only)", "fix": "cache responses (they change twice a day) and spread requests out; building something on this API? request a free API key via key_request_url and tell us your expected volume", "key_request_url": "https://www.abso.net/iletisim-formu/", "docs": "https://havatahmin.abso.net/llms-full.txt"}` until the rate drops (tell it apart from the key error by the `error` text: CloudFront cannot emit a 429 and sends no Retry-After header). Cache responses on your side (they change at most twice a day); fetching every place is ~1000 requests, spread them over 20 minutes. Hosted agents that share egress IPs should carry a key. Keys are free and tell us who you are: Request an API key via the contact form: https://www.abso.net/iletisim-formu/ (state name, purpose and expected daily requests).

Auth: `x-api-key: <key>` header or `?key=<key>`. The edge decides in this order: unsafe path (`..`, `//`, `\`, percent-encoded `.`/`/`) -> 400 `{"error": "invalid path", "docs": "https://havatahmin.abso.net/llms-full.txt"}`; unknown path or slug -> 404 `{"error": "not found", "hint": "unknown slug, or no forecast in this run (see /v1/status.json missing); slugs: /v1/places.json", "docs": "https://havatahmin.abso.net/llms-full.txt"}` (with or without a key); a present but unknown key -> 403 `{"error": "invalid api key", "key_request_url": "https://www.abso.net/iletisim-formu/", "docs": "https://havatahmin.abso.net/llms-full.txt"}` on every path, free ones included; a missing key on a key endpoint -> 401 `{"error": "api key required: send x-api-key header or ?key=", "key_request_url": "https://www.abso.net/iletisim-formu/", "docs": "https://havatahmin.abso.net/llms-full.txt"}`. /il/{il} and /api without the trailing slash -> 301 to the HTML page (the query string is kept). All error bodies carry `docs`; 401/403 carry `key_request_url`; 404 bodies are cacheable for 30 s, the others are not.

## Example: GET /v1/forecast/adana.json (2 of 7 days shown, `stations` omitted)
```json
{
 "place": "Adana",
 "display_name": "Adana",
 "province": "Adana",
 "province_display_name": "Adana",
 "kind": "place",
 "lat": 37.0,
 "lon": 35.32,
 "issued": "2026-10-10",
 "cycle": "12Z",
 "model_version": "v4_20261010_1050_v4b",
 "produced_at": "2026-10-10T22:54:01+03:00",
 "method": "station model",
 "elev_diff_m": 0.0,
 "verified_against": {
  "station": "17352",
  "distance_km": 5.0
 },
 "days": [
  {
   "valid": "2026-10-11",
   "lead": 1,
   "tmax": {
    "value": 31.8,
    "p05": 29.3,
    "p95": 33.2,
    "official_333": 32.1
   },
   "tmin": {
    "value": 20.3,
    "p05": 18.7,
    "p95": 22.6,
    "official_333": 19.7
   },
   "tmean": {
    "value": 24.7,
    "p05": 24.6,
    "p95": 26.9
   },
   "raw_hres": {
    "tmax": 29.9,
    "tmin": 21.3,
    "tmean": 24.9
   },
   "raw": {
    "gust_max_ms": 9.6,
    "wind_ms": 2.6,
    "wind_dir_deg": 39.1,
    "cloud_pct": 61.5,
    "rh_mean_pct": 50.4,
    "rh_min_pct": 34.9,
    "precip_mm": 0.3,
    "snowfall_mm": 0.0,
    "snow_depth_cm": 0.0,
    "solar_kwh_m2": 4.0,
    "msl_hpa": 1012.8,
    "cape_jkg": 562.4,
    "precip_type": "rain",
    "precip_type_code": 1
   },
   "precip": {
    "pop_01": 0.4,
    "pop_1": 0.27,
    "pop_5": 0.14,
    "mm": 0.1,
    "q90": 7.7,
    "method": "model",
    "window": "06-06 UTC (09:00-09:00 Istanbul)"
   }
  },
  {
   "valid": "2026-10-12",
   "lead": 2,
   "tmax": {
    "value": 26.9,
    "p05": 25.0,
    "p95": 29.8,
    "official_333": 27.8
   },
   "tmin": {
    "value": 19.5,
    "p05": 18.3,
    "p95": 21.8,
    "official_333": 19.9
   },
   "tmean": {
    "value": 23.2,
    "p05": 22.2,
    "p95": 24.9
   },
   "raw_hres": {
    "tmax": 25.3,
    "tmin": 18.7,
    "tmean": 21.8
   },
   "raw": {
    "gust_max_ms": 8.8,
    "wind_ms": 1.7,
    "wind_dir_deg": 29.1,
    "cloud_pct": 71.3,
    "rh_mean_pct": 72.8,
    "rh_min_pct": 55.3,
    "precip_mm": 2.4,
    "snowfall_mm": 0.0,
    "snow_depth_cm": 0.0,
    "solar_kwh_m2": 3.4,
    "msl_hpa": 1014.5,
    "cape_jkg": 959.8,
    "precip_type": "rain",
    "precip_type_code": 1
   },
   "precip": {
    "pop_01": 0.28,
    "pop_1": 0.24,
    "pop_5": 0.12,
    "mm": 0.2,
    "q90": 6.8,
    "method": "model",
    "window": "06-06 UTC (09:00-09:00 Istanbul)"
   }
  }
 ],
 "precip_model_version": "precip_v1_20261010",
 "products": {
  "raw": "ECMWF HRES values for the place and local day, uncorrected and unverified (the honesty label is the name): gust = day max, precip/snowfall = day accumulation (water equivalent), cloud/humidity/pressure = day mean, rh_min = day min, precip_type = most frequent ECMWF ptype of the day (none|rain|freezing rain|snow|wet snow|rain-snow mix|ice pellets). Null when the store lacks the run.",
  "precip": "rain probability and amount for the 06-06 UTC day (09:00-09:00 Istanbul) of each lead, from the calculator's precipitation model on the HRES raw fields: pop_01/pop_1/pop_5 = P(>= 0.1 / 1 / 5 mm), mm = median amount, q90 = 90th percentile; method 'model' on a station, 'anchor' on a district (the nearest station's values, rain is local). Not the same window as tmax/tmin (calendar day).",
  "hourly": "/v1/hourly/adana.json"
 }
}
```

## Products beyond temperature (2026-10-10)
Every `days[]` row of a forecast also carries two additive blocks; the hourly curve and the air quality are their own files. status.products says what this run carries: `{"raw": true, "precip": "precip_v1_20261010", "hourly": true, "aq": "aq_v1_20261010"}`.
- `raw`: ECMWF HRES values for the place and local day, uncorrected and unverified (the honesty label is the name): gust = day max, precip/snowfall = day accumulation (water equivalent), cloud/humidity/pressure = day mean, rh_min = day min, precip_type = most frequent ECMWF ptype of the day (none|rain|freezing rain|snow|wet snow|rain-snow mix|ice pellets). Null when the store lacks the run.
- `precip`: rain probability and amount for the 06-06 UTC day (09:00-09:00 Istanbul) of each lead, from the calculator's precipitation model on the HRES raw fields: pop_01/pop_1/pop_5 = P(>= 0.1 / 1 / 5 mm), mm = median amount, q90 = 90th percentile; method 'model' on a station, 'anchor' on a district (the nearest station's values, rain is local). Not the same window as tmax/tmin (calendar day).
- hourly: /v1/hourly/{path}.json: 24 local hours per lead; t = the served curve (HRES shape rescaled to our Tmin/Tmax plus a per-hour correction fitted on thermometer reports), t_raw = ECMWF HRES's own curve interpolated to the hour. Hour 0..23 Istanbul of the day `valid`. Verified hourly error (prod, Sept-Oct 2026, 100 stations): 1.57 °C for `t` vs 2.15 °C for `t_raw`.
- air quality: /v1/aq/{il}.json: daily mean PM2.5, PM10, NO2, O3, SO2, CO (µg/m³) for D+1..D+3 at the province's air-quality stations, issued with the 00Z run from observations to the end of D-1; persistence = the D-1 observed mean. Stations: /v1/aq/stations.json. Provinces without a station answer 404; `persistence` is the naive baseline to compare against.
- Rain in the next two hours (nowcast) is planned as an API-key endpoint; not published yet.
The temperatures (tmax/tmin/tmean, 90% band) stay the verified product; `raw` is published for completeness and is not corrected or scored.

## Field notes not obvious from the JSON alone
- `kind` is "place" for provinces, "district" for districts. `place`/`province` are the calculator's names (ASCII for provinces); `display_name`/`province_display_name` are the proper Turkish spellings for display (also in /v1/places.json and /v1/districts/{il}.json). Match names by folding to the slug (ç->c ğ->g ı/İ->i ö->o ş->s ü->u, NFKD-strip â/î/û, spaces and punctuation -> single hyphens), never by comparing spellings.
- A value of 0.0 is never written as -0.0.
- `method` is "station model" for provinces with their own station; "transfer" (shifted along the HRES gradient from the anchor station) or "transfer+lapse" (also an elevation correction via `elev_diff_m`) for districts and for provinces without a station model.
- `stations` (provinces only) maps a WMO station id to that station's own `days` array.
- `raw_hres` is the uncorrected ECMWF HRES value the model started from, per day.
- `value`/`p05`/`p95` are all °C; p05 <= value <= p95 always.
- Alerts: `frost` "likely" when tmin <= 0 (carries `tmin`) or "possible" when tmin.p05 <= 0 (carries `tmin_p05`); `heat` "likely" when tmax >= 35 (`tmax`) or "possible" when tmax.p95 >= 35 (`tmax_p95`); `sudden_change` when |Δ daily mean| >= 6°C (carries `delta_tmean`, no `level`). Thresholds are applied to the published rounded values, so exactly 0.0 or 35.0 does trigger; for a village or neighbourhood apply them to the offset-shifted values.
- Provinces served by transfer (bitlis, kahramanmaras, karabuk, sirnak, sivas) have no station of their own, so they have no /v1/verification/{il}.json (404); /v1/verification/all.json pools the stations that do.

## Discovery files
/llms.txt (this document's summary), /openapi.json, /index.json, /sitemap.xml, /.well-known/api-catalog (RFC 9727), /.well-known/ard.json (ARD 0.91), /.well-known/ai-catalog.json (ARD predecessor path). `Accept: text/markdown` on / returns this file.
